Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/evidence/harmonyos-trajectory-slider-2026-11-08.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# HarmonyOS densified trajectory pan drags a real slider, 2026-11-08

Evidence for #4239 (inject gesture pan as a densified touch-panel
trajectory): one densified pan through the panel evdev chain dragged a real
slider-captcha knob 439 px across its track, and the app's own
machine-readable oracle scored the alignment at 3 px off target — inside its
20 px tolerance, `RESULT=PASS`.

## Device

- HUAWEI 畅享 90 Plus, model `JDY-AL50`, HarmonyOS 7.0.0.111 (SP10C00E111R4P3), API 26
- Serial `77TYD26826028730`, USB, `hdc shell` as uid 2000 (shell)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The device serial is a personal hardware identifier. Replace it with a placeholder such as <device-serial> here and in the commands, so the evidence note keeps only the technical content.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/evidence/harmonyos-trajectory-slider-2026-11-08.md, line 12:

<comment>The device serial is a personal hardware identifier. Replace it with a placeholder such as `<device-serial>` here and in the commands, so the evidence note keeps only the technical content.</comment>

<file context>
@@ -0,0 +1,62 @@
+## Device
+
+- HUAWEI 畅享 90 Plus, model `JDY-AL50`, HarmonyOS 7.0.0.111 (SP10C00E111R4P3), API 26
+- Serial `77TYD26826028730`, USB, `hdc shell` as uid 2000 (shell)
+- Surface: `com.stringzhao.captchaab`, its slider-puzzle page — a test harness
+  app whose status bar carries a machine-readable verdict row
</file context>
Suggested change
- Serial `77TYD26826028730`, USB, `hdc shell` as uid 2000 (shell)
- Serial `<device-serial>`, USB, `hdc shell` as uid 2000 (shell)

- Surface: `com.stringzhao.captchaab`, its slider-puzzle page — a test harness
app whose status bar carries a machine-readable verdict row
(`ROUND=... RESULT=... DETAIL=... TYPE=slider`), so the oracle needs no
screenshots.

## Run

Knob at the track's left edge (x≈165, y≈1084); the puzzle gap sat 439 px to
the right. One densified pan, default transport (the panel node):

```sh
$ node bin/agent-device.mjs gesture pan 165 1084 439 0 900 \
--platform harmonyos --serial 77TYD26826028730
Panned (165, 1084) by (439, 0)
```

The verdict row before and after:

```
ROUND=1 RESULT=NONE DETAIL=- PASS=0 FAIL=0 TYPE=slider
ROUND=1 RESULT=PASS DETAIL=offset=3px tol=20 PASS=1 FAIL=0 TYPE=slider
```

The after screenshot shows the knob at ≈x 606 on the track and the puzzle
piece seated in the gap. The requested displacement (165 + 439 = 604) and the
scored landing (offset 3 px inside a 20 px tolerance) agree.

## Why this evidences the transport

The slider only moves while a touch sequence holds, carries, and releases it —
a straight-line UIInput swipe also moves it, but this branch's pan lowers the
stroke as a densified sample chain through the panel's input node with a
success sentinel (a denied or failed panel write can never report a completed
gesture; a reboot-renumbered node re-probes). The pass at 3 px shows the
sample chain held the drag across its whole length on hardware.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This PASS does not establish that the panel transport ran: the command omits the JSON backend disclosure, and the document says the uiInput fallback also moves the slider. Record the successful backend/trajectoryFallback result or device-side sample evidence, otherwise label this as slider-only evidence.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/evidence/harmonyos-trajectory-slider-2026-11-08.md, line 47:

<comment>This PASS does not establish that the panel transport ran: the command omits the JSON backend disclosure, and the document says the `uiInput` fallback also moves the slider. Record the successful `backend`/`trajectoryFallback` result or device-side sample evidence, otherwise label this as slider-only evidence.</comment>

<file context>
@@ -0,0 +1,62 @@
+stroke as a densified sample chain through the panel's input node with a
+success sentinel (a denied or failed panel write can never report a completed
+gesture; a reboot-renumbered node re-probes). The pass at 3 px shows the
+sample chain held the drag across its whole length on hardware.
+
+## Reproduce
</file context>


## Reproduce

Open the app to its slider page, read the machine row, run the pan, read the
row again:

```sh
hdc -t 77TYD26826028730 shell "aa start -b com.stringzhao.captchaab -a EntryAbility"
node bin/agent-device.mjs press @e11 --platform harmonyos --serial 77TYD26826028730

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: @e11 is not minted in this reproduction: after starting the app, the script presses it without a fresh snapshot, although refs are valid only in the latest output. Add a snapshot step and use its returned ref, or use a stable selector or coordinate action.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/evidence/harmonyos-trajectory-slider-2026-11-08.md, line 56:

<comment>`@e11` is not minted in this reproduction: after starting the app, the script presses it without a fresh snapshot, although refs are valid only in the latest output. Add a snapshot step and use its returned ref, or use a stable selector or coordinate action.</comment>

<file context>
@@ -0,0 +1,62 @@
+
+```sh
+hdc -t 77TYD26826028730 shell "aa start -b com.stringzhao.captchaab -a EntryAbility"
+node bin/agent-device.mjs press @e11 --platform harmonyos --serial 77TYD26826028730
+node bin/agent-device.mjs gesture pan 165 1084 439 0 900 --platform harmonyos --serial 77TYD26826028730
+node bin/agent-device.mjs snapshot --platform harmonyos --serial 77TYD26826028730 | grep 'ROUND='
</file context>

node bin/agent-device.mjs gesture pan 165 1084 439 0 900 --platform harmonyos --serial 77TYD26826028730
node bin/agent-device.mjs snapshot --platform harmonyos --serial 77TYD26826028730 | grep 'ROUND='
```

The knob start position and the gap move per round; read both from a
screenshot before the drag.
156 changes: 156 additions & 0 deletions packages/platform-harmonyos/src/__tests__/input-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { beforeEach, test, vi } from 'vitest';
import type { GesturePlan } from '@agent-device/contracts/gesture-plan-types';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';

const { runHarmonyShell, sleep, readHarmonyGestureViewport, invalidateHarmonyGestureViewport } =
vi.hoisted(() => ({
Expand Down Expand Up @@ -150,3 +151,158 @@ test('HarmonyOS orientation is explicitly unsupported', async () => {
/orientation control/,
);
});

const PANEL_LISTING = [
'I: Bus=0018 Vendor=0000 Product=0000 Version=0000',
'N: Name="goodix touchscreen"',
'H: Handlers=event5',
].join('\n');

const PANEL_MMI_DUMP = [
'deviceId:4 | deviceName:input_mt_wrapper | deviceType:19 | bus:0 | version:0 | product:0 | vendor:0 | phys:null | isVirtual:0 | isLocal:1',
' axisType:POSITION_Y | minimum:0 | maximum:12831 | fuzz:0 | flat:0 | resolution:1',
' axisType:POSITION_X | minimum:0 | maximum:5759 | fuzz:0 | flat:0 | resolution:1',
].join('\n');

const PORTRAIT_RENDER_DUMP =
'screen[0]: id=0, powerStatus=POWER_STATUS_ON, screenType=EXTERNAL_TYPE, render resolution=1080x2406, physical resolution=720x1604, isVirtual=false';

const PANEL_DEVICE: DeviceInfo = { ...DEVICE, id: 'harmony-panel-1' };
const LOCKED_DEVICE: DeviceInfo = { ...DEVICE, id: 'harmony-locked-1' };

const PAN_PLAN: GesturePlan = {
topology: 'single',
intent: 'pan',
executionProfile: 'timed-pan',
durationMs: 320,
viewport: { x: 0, y: 0, width: 1080, height: 2406 },
pointers: [
{
pointerId: 0,
samples: [
{ offsetMs: 0, point: { x: 200, y: 2000 } },
{ offsetMs: 320, point: { x: 880, y: 1200 } },
],
},
],
};

test('a pan on a locked-down build falls back to the uiInput swipe and reports the reason', async () => {
runHarmonyShell.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });

const result = await performHarmonyGesture(LOCKED_DEVICE, PAN_PLAN);

assert.deepEqual(runHarmonyShell.mock.calls[0]?.[1], ['cat', '/proc/bus/input/devices']);
assert.deepEqual(runHarmonyShell.mock.calls[1]?.[1], [
'uitest',
'uiInput',
'swipe',
200,
2000,
880,
1200,
3281,
]);
assert.deepEqual(result, {
backend: 'harmonyos-hdc-uiinput',
command: 'swipe',
velocity: 3281,
viewport: PAN_PLAN.viewport,
trajectoryFallback: 'touch-node-listing-unreadable',
});
});

test('a resolvable pan transport injects the densified script and verifies its success marker', async () => {
runHarmonyShell
.mockResolvedValueOnce({ stdout: PANEL_LISTING, stderr: '', exitCode: 0 })
.mockResolvedValueOnce({ stdout: PANEL_MMI_DUMP, stderr: '', exitCode: 0 })
.mockResolvedValueOnce({ stdout: PORTRAIT_RENDER_DUMP, stderr: '', exitCode: 0 })
.mockResolvedValueOnce({
stdout: 'AGENT_DEVICE_TRAJECTORY_INJECTED',
stderr: '',
exitCode: 0,
});

const result = await performHarmonyGesture(PANEL_DEVICE, PAN_PLAN);

assert.deepEqual(runHarmonyShell.mock.calls[0]?.[1], ['cat', '/proc/bus/input/devices']);
assert.deepEqual(runHarmonyShell.mock.calls[1]?.[1], [
'hidumper',
'-s',
'MultimodalInput',
'-a',
'-d',
]);
assert.deepEqual(runHarmonyShell.mock.calls[2]?.[1], [
'hidumper',
'-s',
'RenderService',
'-a',
'screen',
]);
const [words, options] = runHarmonyShell.mock.calls[3]?.slice(1) ?? [];
assert.deepEqual(words?.slice(0, 2), ['sh', '-c']);
const script = String(words?.[2]);
assert.ok(script.includes('echo AGENT_DEVICE_TRAJECTORY_INJECTED'));
assert.ok(script.includes("uinput sendevent '/dev/input/event5' 3 53 1067"));
assert.ok(script.includes("uinput sendevent '/dev/input/event5' 3 54 10670"));
assert.ok(options.timeoutMs > PAN_PLAN.durationMs);
assert.deepEqual(result, {
backend: 'harmonyos-sendevent-trajectory',
deviceNode: '/dev/input/event5',
sampleCount: 21,
durationMs: 320,
viewport: PAN_PLAN.viewport,
});
});

test('a failed panel write reports the typed injection failure instead of success', async () => {
runHarmonyShell
.mockResolvedValueOnce({ stdout: PANEL_LISTING, stderr: '', exitCode: 0 })
.mockResolvedValueOnce({ stdout: PANEL_MMI_DUMP, stderr: '', exitCode: 0 })
.mockResolvedValueOnce({ stdout: PORTRAIT_RENDER_DUMP, stderr: '', exitCode: 0 })
.mockResolvedValueOnce({
stdout: 'Input error in device node, deviceNode:/dev/input/event5',
stderr: '',
exitCode: 0,
});

await assert.rejects(
() => performHarmonyGesture({ ...PANEL_DEVICE, id: 'harmony-panel-2' }, PAN_PLAN),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.deepEqual(error.details, {
reason: 'touch-trajectory-injection-failed',
deviceNode: '/dev/input/event5',
sampleCount: 21,
});
return true;
},
);
});

test('a listed panel with an unreadable axis range falls back instead of writing untransformed points', async () => {
runHarmonyShell
.mockResolvedValueOnce({ stdout: PANEL_LISTING, stderr: '', exitCode: 0 })
.mockResolvedValueOnce({
stdout: 'deviceId:4 | deviceName:fingerprint | deviceType:3',
stderr: '',
exitCode: 0,
});

const result = await performHarmonyGesture({ ...PANEL_DEVICE, id: 'harmony-panel-3' }, PAN_PLAN);

assert.equal(result.backend, 'harmonyos-hdc-uiinput');
assert.equal(result.trajectoryFallback, 'touch-panel-range-absent');
assert.deepEqual(runHarmonyShell.mock.calls[2]?.[1], [
'uitest',
'uiInput',
'swipe',
200,
2000,
880,
1200,
3281,
]);
});
Loading
Loading