Skip to content

feat(harmony): drive HarmonyOS devices over hdc - #767

Merged
latekvo merged 133 commits into
mainfrom
feat/harmonyos-emulator
Oct 22, 2026
Merged

latekvo merged 133 commits into
mainfrom
feat/harmonyos-emulator

Conversation

@latekvo

@latekvo latekvo commented Aug 10, 2026 •

Copy link
Copy Markdown
Member

Adds a harmony platform behind the harmonyos feature flag (off by default, argent enable harmonyos). list-devices reports HarmonyOS phones (over hdc) and DevEco Studio emulator instances. boot-device starts an instance and returns the hdc connect key it registers as. The tools below then drive that key, whether it is a phone or a booted emulator.

Capability HarmonyOS emulator Android emulator
Availability Behind argent enable harmonyos Always on
list-devices ✅ (needs DevEco Studio + hdc) ✅
boot-device ✅ via DevEco's Emulator manager; Huawei serves images only inside mainland China ✅ AVD
describe ✅ uitest dumpLayout; the status bar is included ✅
screenshot / screenshot-diff ✅ (no rotation param) ✅
await-ui-element / await-screen-idle ✅ (flag suspended-panel reads) ✅
gesture-tap ✅ (double-tap; 3+ taps sent separately) ✅
gesture-swipe ✅ (durationMs sets speed, clamped to 200-40000 px/s) ✅
gesture-pinch ✅ ✅
gesture-rotate / gesture-custom (long-press) ❌ straight-line input only ✅
keyboard ⚠️ text + enter/backspace/space/arrow-left/right; no delayMs, no newlines ✅ full
paste ❌ ✅
button ⚠️ home / back / power only ✅ incl. volume, appSwitch
launch-app / restart-app ✅ (activity ignored) ✅
reinstall-app ✅ one .hap via hdc uninstall + hdc install ✅
open-url ✅ via aa start -U; always returns a verify-the-screen note ✅
run-sequence ✅ ✅
Landscape ✅ when the app rotates itself ✅
rotate / fold / shake ❌ ✅
settings-permissions ❌ ✅
Screen recording ❌ no readable frame stream ✅
Flows / e2e ⚠️ explicit device only; coordinate steps, launch, uncropped snapshot, wait, script; selectors error per step ✅
React Native debugger / profiler ❌ ✅
Native profiler / network logs ❌ ✅

Transport: uitest over hdc shell for input, layout and capture. The second pinch finger comes from /system/bin/uinput -T -m. Calls to uitest are serialized per device because overlapping invocations block each other and then get SIGKILLed. Every input tool refuses to inject while the panel is suspended, because uitest reports No Error for input that lands nowhere. An input cancelled while it waits in that queue injects nothing.

Flag off: list-devices never runs hdc or the Emulator manager. Any harmony-* id, and boot-device with harmonyInstance, get a 400 that names argent enable harmonyos. Tool descriptions and schemas contain no HarmonyOS text; the skills carry it in argent-device-interact/references/harmonyos.md and two one-line pointers to it. Description spans marked <flag:harmonyos> are rendered per request in GET /tools, so toggling the flag needs no tool-server restart.

Other platforms: one behaviour change. Secret redaction also matches a value in its shell-quoted form (' → '\''), so a secret containing ' no longer leaks through a failed Android input text call.

Docs: packages/docs marks HarmonyOS as experimental and flag-gated, and documents the flag in the configuration reference. The skill guidance moved to argent-device-interact/references/harmonyos.md.

Out of scope: the cross-platform secret-redaction rewrite (#797), stale screenshots from a suspended panel (#792), and capability-checking flow tool: steps (#796; an unsupported tool there fails on a service error instead of the refusal).

Verification
  • Measured on a physical HarmonyOS 6.0.1 handset over USB and on DevEco Studio 6.1's Emulator 6.1.1.200 (macOS). No device screenshots are included because the handset is a personal phone.
  • Huawei serves emulator images only inside mainland China. Elsewhere boot-device can start an instance only if one is already installed, and it says so instead of reporting a boot it did not perform.
  • screenshot uses the same default scale as the other platforms (ARGENT_SCREENSHOT_SCALE, default 0.25). A rotation argument is refused because uitest screenCap cannot take one.
  • open-url attaches a caveat to every URL, because aa start -U reports success for anything the system accepts, including schemes that only open a chooser.
  • await-ui-element returns cause: "unreadable" (not unmet) when it times out on a suspended panel's last frame. Both wait tools add a note to a success only when the read came from a suspended panel.
  • Cancellation: test/harmony-input-cancel.test.ts cancels tap, swipe, pinch, button and keyboard while another uitest call holds the queue. All 5 cases fail on the previous head (each reports success and injects). Removing the signal from any one call site fails that site's case.
  • Flag gate: test/harmony-flag-gate.test.ts and test/harmony-flag-tool-text.test.ts. Removing each gate (capability, connect key, boot, list-devices, GET /tools rendering, required filtering) fails a case. E2E on the emulator: toggling flags.json between requests flips GET /tools and MCP tools/list between 0 and 118 HarmonyOS mentions, and the id between 400 and driving.
  • reinstall-app: on the emulator with a minimal signed .hap over HTTP. A mismatched bundle id and a non-zip .hap are refused with 400 and installTime is unchanged. A matching .hap reinstalls (installTime changes, launch-app + describe show the app), including when it was not installed. Each check in test/harmony-reinstall.test.ts fails when its branch is removed.
  • Landscape: the render-service resolution stays portrait when the display rotates. With a video fullscreen in landscape, a tap at the pause button's frame landed off-screen before the fix (playback kept advancing) and pauses it after.
  • Local gates on the merge with main: prettier, eslint, tsc --build, the tool-server test typecheck, knip, docusaurus build, every workspace's tests, and the full tool-server suite (6 shards). The one local failure is e2e-tool-aliases test fails on macOS while argent-lens is off #1526, which is macOS-only and independent of this PR.

Adds a `harmony` platform driven by DevEco Studio's `Emulator` manager:
`list-devices` reports emulator instances and `boot-device` starts one by
name. Device ids are `harmony-<instanceName>`, minted by argent the way
`chromium-cdp-<port>` is, so classification never depends on a vendor
serial shape - the connect key of a running HarmonyOS emulator could not be
observed on a host that cannot obtain an image.

The manager's exit code is not a success signal: `-create`, `-stop` and
`-install` report failures with exit 0, while `-start` exits 1. So
`emulatorFailure` classifies from stdout against diagnostics measured
against DevEco Studio 6.1, and `runHarmonyEmulator` deliberately does not
reject on a non-zero exit, keeping one verdict path instead of two that
disagree.

Dispatch rejects the platform explicitly rather than falling through:
chromium is the last branch of `dispatchByPlatform`, so a HarmonyOS device
would otherwise be handed to the CDP handler and its result reported as a
success.

Huawei serves HarmonyOS emulator images only within mainland China. Outside
it no image downloads, so no instance can be created; boot-device reports
what the manager said instead of claiming a boot it did not perform, and
distinguishes that restriction from an empty instance list, which is also
what a host that has simply not created one yet looks like.

Interaction tools are out of scope - screenshot, describe, input and
app-lifecycle return 501 on a HarmonyOS device - because with no bootable
image their behaviour could not be verified.
`__resetHarmonyBinaryCacheForTests` had no caller, and `HarmonyInstance`
was only ever used as an internal return element type, so it becomes a
plain type alias. Together they pushed the knip second pass one over its
215-issue ceiling; the run is back at 215 with no harmony entries.
`list-devices` backstops every discovery branch at BRANCH_DEADLINE_MS, an
invariant that only holds while each branch's worst case stays below it -
otherwise the backstop truncates a branch that would have completed and
drops a real device. The HarmonyOS branch inherited runHarmonyEmulator's
30s default, which is above the 25s deadline, so it now passes its own 6s
bound; `Emulator -list` reads a local directory in under 0.1s, making that
headroom rather than a real cost.

The invariant test covered only the Vega and Android branches, so it stayed
green through the breach; it now sums the HarmonyOS branch too and fails if
the bound is raised past the deadline.
Adds the `hdc` transport the platform was missing, and with it physical
HarmonyOS device discovery.

`hdc` exits 0 for everything — an unknown target, a missing file, a remote
command that exits 3 — so `runHdc` classifies from the `[Fail]` prefix rather
than the status, and `runHdcShell` recovers the remote command's own exit code
by echoing it back through a sentinel. Without that, a `uitest` that is not
installed is indistinguishable from one that ran.

`Emulator -list -details` turns out to report `isRunning`, form factor and OS
version as JSON, so instances no longer carry state "unknown"; discovery reads
the richer form, which costs the same ~30ms.

HarmonyOS ids now split by what they address: `harmony-emulator-<instance>` for
a DevEco instance that boot-device starts, `harmony-<connectKey>` for a target
hdc drives. A running emulator appears under both, exactly as a running AVD
appears in `avds` and in `adb devices`.

Verified against a physical HarmonyOS 6.0.1 handset (Mate 60) over USB and
against DevEco Studio 6.1's Emulator 6.1.1.200.
Eleven tools now accept a HarmonyOS target: describe, screenshot, gesture-tap,
gesture-swipe, keyboard, button, launch-app, restart-app, open-url,
await-ui-element and await-screen-idle.

All of it goes through the device's own `uitest`, which is HarmonyOS'
uiautomator, screencap and input in one binary, reached over `hdc`. That is the
same shape `button` and `keyboard` already use on Android, where presses go over
`adb` rather than the simulator-server HID transport.

`describe` parses `uitest dumpLayout` into the shared tree. Each top-level child
is a window, labelled with the bundle that owns it, so an app plus the status
bar reads as two named windows rather than two anonymous stacks. ArkUI layout
scaffolding is walked through unless it is itself clickable — ArkUI builds real
buttons out of Stack and Row. A `UIExtensionComponent` renders in another
process and the dump carries the node with no children, so it is labelled rather
than emitted as an empty container that reads like an empty screen.

Two device behaviours drive the error handling. `aa start` exits 0 on failure
and puts the useful code on its second line, so success is read off stdout and
the coded line is kept. And `aa start -a` does not take `mainAbility`: it takes
the matching `abilityInfos` name, which Calculator spells short and Settings
spells qualified — passing `mainAbility` verbatim launches one and fails the
other with 10104001.

`button` exposes home/back/power, the three keys `uitest` names, and `keyboard`
exposes the five keycodes whose effect was observed in a real text field. The
rest are refused rather than mapped from documentation: `uitest` accepts any
keycode and answers `No Error` whatever it does, so an unverified mapping would
be indistinguishable from a working one.

Verified end to end against a physical HarmonyOS 6.0.1 handset through the
tool-server's HTTP API: launch-app, await-screen-idle, describe, four
gesture-taps and a second describe assert the calculator reads 15; gesture-swipe
moves a Settings list; keyboard types into the search field and backspace
removes a character; button home and back change the foreground bundle;
open-url's failure path reports the code and its success path carries the caveat
that HarmonyOS reports success for a web URL that opens nothing.
`run-sequence` and `screenshot-diff` supported iOS and Android but rejected
HarmonyOS, so a HarmonyOS session paid an `hdc` process spawn per interaction
and had no visual-regression path at all.

`run-sequence` is platform-neutral by construction — every step re-dispatches
through its own tool — so it only needed the outer gate opened; a step the
platform cannot run (`gesture-pinch`) is still reported per-step after the
preceding steps have landed.

`screenshot-diff` needed a capture branch: HarmonyOS has no simulator-server
controller, so a live side is captured with `uitest screenCap` over `hdc` and
the service is not requested at all, which would otherwise throw before the
capture ran.

`rotation` is now refused on both rather than accepted and dropped. `uitest
screenCap` writes the display in its current orientation and takes no
orientation argument, so honouring the parameter would return an unrotated
image — and a diff of the wrong orientation reads as a whole-screen change.
The `udid` descriptions of all eleven HarmonyOS-capable tools said which id
shapes they take and omitted this one; they now name it.

Verified on a physical HarmonyOS 6.0.1 handset: a `button power` step through
`run-sequence` moves `powerStatus` SUSPEND→ON, a two-step button + keyboard
sequence completes 2/2, and `screenshot-diff` with `captureCurrent` reports
`unchanged` against a static screen and then one changed region of 369 px once
the lock-screen clock ticked a minute. Both rotation guards return 400 from
the device path.
@latekvo latekvo changed the title feat(harmony): add HarmonyOS emulator discovery and boot feat(harmony): drive HarmonyOS devices over hdc Aug 10, 2026
The guard ran while the capture strategy was being chosen, so it also
rejected a two-path diff that merely carried `rotation` — a parameter that
is inert for a static diff on every platform, making HarmonyOS the one
platform where an unused argument failed the call. It now runs inside the
capture itself, where the parameter actually has no effect to give.
…forms

`keyboard`, `launch-app` and `open-url` each enumerate the platforms they
drive, and each enumeration was left describing the set from before this
branch — an agent reading `keyboard`'s description had no way to learn the
tool works on HarmonyOS, or that only four of its named keys land there.
The asleep hint only fired when the parsed tree had no windows at all, on
the belief that a suspended display dumps a bare root. It does not: measured
on a Mate 60 at `powerStatus=POWER_STATUS_SUSPEND`, `uitest dumpLayout`
returns both `com.ohos.sceneboard` windows, visible flags and all — a tree
an agent cannot tell apart from a live screen, while every tap injected
against it is a silent no-op. So the hint now keys on the power status it is
about, and the empty-tree hint keeps the case it was written for.
@latekvo
latekvo marked this pull request as ready for review August 10, 2026 21:20
@latekvo
latekvo marked this pull request as draft August 11, 2026 12:03
`boot-device` returned the instance id, which no interaction tool
accepts, so booting a HarmonyOS emulator left the caller with nothing to
drive and no way to find out what it had become: the emulator manager
names instances and never reports a port, `hdc` names connect keys and
never mentions an instance, and the two share no field to join on.

It now snapshots the connected targets, starts the instance, and waits
for the one that appears — arrival being the only signal that does not
depend on how an emulator's key is spelled, which is unobservable
outside mainland China. `force` restarts an instance that is already
running, which is also the only way to resolve the key of one argent did
not start. Whenever the key cannot be had, the payload still names the
instance and carries a `note` saying which of the three reasons applied.

Also stops reading `harmony-<connectKey>` as an instance name, so a
phone's id no longer silently starts an instance named after a serial.
`hdc tconn` registers an `ip:port` target and lists it before any
handshake succeeds, so a TCP row with state `Offline` is a state a
booted emulator can genuinely be found in — which is what the boot
path's wait-for-Connected is there for. The fixture is now that output
verbatim rather than a plausible spelling of it.

Also drops two stale claims: `harmony-cli.ts` said nothing here talks to
a booted device, which stopped being true when the interaction layer
landed.
@latekvo

latekvo commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Knip is red for a reason outside this PR: main itself fails Dead Code at f136c02. #663 added three exported types nothing imports (StrippedName, ComponentAnnotation, ComponentNameResolution in react-profiler/component-names.ts), taking the shared parked-issue total from 215 to 218 against a --max-issues 215 ceiling.

Measured on this branch with build output removed, the way knip.jsonc says to count it: 215 before merging main in, 218 after — this branch contributes zero. #769 already fixes it; this check goes green on a re-run once that lands.

The other 13 checks pass.

@latekvo
latekvo marked this pull request as ready for review August 11, 2026 13:09
Both of these only showed up once a real emulator existed to boot.

`Emulator -start` is not a launcher that hands off — it is the
emulator's supervisor, and it runs for as long as the emulator does. It
was being awaited with the boot budget as its subprocess timeout, so a
boot spent the whole budget inside the start, gave the arrival wait
nothing, and then the expiring timeout killed the emulator it had just
started: 905s to return the wrong id and leave a dead instance behind.
It is now spawned detached, exactly as the Android emulator is, with the
arrival wait polling alongside and an early exit surfacing the manager's
own diagnostic instead of waiting out the budget. Measured after: 8s to
a drivable connect key.

`-stop` likewise returns once it has asked, not once the emulator is
gone, and the guest's `hdc` endpoint dies well before the process does.
A restart that waited on the target dropping called `-start` into
"Failed to start emulator: this emulator instance is already running" —
now a recognised diagnostic — so it waits on `isRunning` instead, the
only signal that tracks the process itself.

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat]: Static review of this PR at f41ae423: read the full diff, then each changed file in whole together with its callers and its nearest sibling implementation (the Android path, the other tree sources, the other capability declarations), and checked every added or changed test against the question of whether it would still pass with the production hunk reverted. Pre-PR behaviour was read from the merge base (git show <merge-base>:<path>) rather than inferred.

An earlier pass over 727e385e is superseded: that head has since been reworked, and the two findings it produced (the boot-device description asserting no interaction tool targets HarmonyOS, and the shape of the dispatch test) no longer apply as written. Everything below was derived fresh against the current head by two reviewers on different lenses, and each observation then survived an adversarial refutation check; anything the check could not confirm was dropped.

Nothing here was reproduced on a device — this was a static read.

chromium: { handler: chromiumHandler },
});

await expect(execute({}, { udid: "harmony-Phone_1" })).rejects.toThrow(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat]: The test guarding the new harmony dispatch arm never reaches it — assertSupported rejects first.

The test "rejects a HarmonyOS device instead of falling through to the chromium handler" (packages/tool-server/test/cross-platform-tool.test.ts:190-210) never reaches the new harmony arm, so it does not pin the guard its comment describes. Trigger: resolveDevice("harmony-Phone_1") returns { platform: "harmony", kind: "device" } — src/utils/device-info.ts:149-152 yields kind "emulator" only for a harmony-emulator- prefix — while the test's capability at line 200 declares harmony: { emulator: true } with no device key. assertSupported (called at src/utils/cross-platform-tool.ts:98, check at src/utils/capability.ts:150) therefore throws UnsupportedOperationError: Tool 'test' is not supported on harmony device (kind 'device' not supported). before the device.platform === "harmony" block at cross-platform-tool.ts:142 is evaluated.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat]: Fixed in d677d3b.

Confirmed exactly as described, then proven by mutation. A probe printed what the test actually caught: UnsupportedOperationError: Tool 'test' is not supported on harmony device (kind 'device' not supported). — the |harmony alternative of the regex matched it, so the test passed on the 400 path. Deleting the entire device.platform === "harmony" arm from cross-platform-tool.ts left test/cross-platform-tool.test.ts at 8/8 green.

The fix declares both kinds (harmony: { device: true, emulator: true }), drives both a harmony-Phone_1 and a harmony-emulator-argent_phone id through the same assertion, and swaps the regex for rejects.toBeInstanceOf(NotImplementedOnPlatformError) — matching the sibling at test/chromium-dispatch.test.ts:67. Re-running the same mutation now fails it with AssertionError: promise resolved "'from-chromium'" instead of rejecting, which is the exact fallthrough the comment describes.

opts.source === "cdp-dom" ||
opts.source === "vega-automation"
opts.source === "vega-automation" ||
opts.source === "harmony-uitest"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat]: The harmony-uitest nested-renderer addition is the one nested source with no test, though the identical defect on a sibling source has a named regression test.

No test pins harmony-uitest in the nested-renderer condition. Every other source in that disjunction has a test asserting Mode: nested for its exact source string — uiautomator and android-devtools in packages/tool-server/test/describe-format-tree.test.ts (the latter at :491, carrying the comment "Regression: nested mode previously keyed on 'uiautomator' only, so 'android-devtools' responses rendered flat and lost all descendants"), cdp-dom in test/chromium-format-tree.test.ts:35, vega-automation in test/vega-source-parser.test.ts:137. formatDescribeTree is never called with { source: "harmony-uitest" } anywhere in the suite, and test/harmony-describe.test.ts only asserts tree.children.length and the hint string — it never renders, so it cannot observe the mode. Deleting || opts.source === "harmony-uitest" from format-tree.ts:188 still typechecks (plain boolean chain, no exhaustiveness guard) and leaves the whole suite green.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat]: Fixed in d677d3b.

test/harmony-describe.test.ts now renders what describeHarmony actually returns — its tree and its source — through formatDescribeTree, and asserts Source: harmony-uitest, Mode: nested, and that the 04:39 clock text nested one level inside a WindowScene survives. Feeding the real return value rather than a hand-built { source: "harmony-uitest" } ties the two halves together: a change to either the source string or the renderer condition breaks it.

Verified by the mutation you named — deleting || opts.source === "harmony-uitest" from format-tree.ts:188 now fails with expected 'Source: harmony-uitest\nMode: flat\nC…' to contain 'Mode: nested'.

Comment thread README.md Outdated
| **Desktop & web** | Electron and Chromium apps (incl. React Native Web / Expo web) over CDP | Mouse / keyboard |
| **HarmonyOS** | Phones over `hdc`, and DevEco Studio emulator instances | Touch / gesture |

> **HarmonyOS drives whatever `hdc` is connected to.** Connect a phone with USB debugging enabled and it appears as `harmony-<connectKey>` (`kind: "device"`), driven by `describe`, `screenshot`, `screenshot-diff`, `gesture-tap`, `gesture-swipe`, `keyboard`, `button`, `launch-app`, `restart-app`, `open-url`, `run-sequence` and the two `await-*` tools - all through the device's own `uitest`. DevEco Studio's emulator instances appear separately as `harmony-emulator-<name>`, which `boot-device` starts; it then waits for the instance to register with `hdc` and returns that connect key, so a booted emulator is driven by the same tools through the same id shape as a phone. Huawei restricts the emulator **image** download to mainland China, so on a host outside it no instance can be created until an image is obtained. Multi-touch and free-form touch (`gesture-pinch`, `gesture-rotate`, `gesture-custom`) return `501 NotImplementedOnPlatform`, on both device kinds: `uitest uiInput` injects only whole gestures (click / longClick / swipe / drag / fling), one contact at a time, and the level below is shut - `hdc shell` is uid 2000 and `/dev/input` is `Permission denied` on a retail phone and on Huawei's own emulator image alike, where `hdc smode` answers `Cannot set root run mode in undebuggable version`. `rotate`, the profilers and the debugger also return 501: the device exposes no orientation CLI, and argent's profiler and debugger surfaces have no HarmonyOS ingest path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat]: Docs promise 501 for the unsupported HarmonyOS tools; every one of them actually returns 400.

The README (and two skill docs) state that the HarmonyOS-unsupported tools return 501 NotImplementedOnPlatform; every one of them returns 400 instead. Trigger: call gesture-pinch, gesture-rotate, gesture-custom, rotate, any profiler tool, or any debugger tool with a harmony-<connectKey> id. None of those tools declares a harmony block in its capability (gesture-pinch/index.ts:66, gesture-rotate/index.ts:86, gesture-custom/index.ts:51, rotate/index.ts:20, native-profiler-start.ts:51, and RN_ONLY_TOOL_CAPABILITY / DEBUGGER_TOOL_CAPABILITY in debugger/debugger-service-ref.ts:60 and :20), so the HTTP capability gate at http.ts:791-808 throws UnsupportedOperationError and answers 400 with TOOL_CAPABILITY_UNSUPPORTED_OPERATION and the message "Tool '' is not supported on harmony device." The 501 / NotImplementedOnPlatformError path is only reachable from dispatchByPlatform (utils/cross-platform-tool.ts:147) when a capability does declare harmony but wires no branch — and every tool in this PR that declares harmony: also wires a handler, so no shi …

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat]: Fixed in 1840a2b.

Confirmed against a live server, not just a read: stood up createHttpApp from this branch's dist/ with the real tool definitions and POSTed a harmony- id.

gesture-pinch           -> HTTP 400  {"error":"Tool 'gesture-pinch' is not supported on harmony device (no harmony support declared)."}
gesture-rotate          -> HTTP 400
gesture-custom          -> HTTP 400
rotate                  -> HTTP 400
native-profiler-start   -> HTTP 400
debugger-component-tree -> HTTP 400

Re-run with harmony-emulator-argent_phone: identical 400s ("…on harmony emulator"), so the README's explicit "on both device kinds" was wrong on both kinds.

I checked whether this is loose house style before changing anything, and it is not. README.md:32 was the only occurrence of 501 in any markdown in the repo, and the nearest twin describing the identical mechanism names the gate and the literal message instead: argent-metro-debugger/SKILL.md:12 — "are RN-only and reject Chromium at the capability gate with Tool 'X' is not supported on chromium app".

Worse than the wrong number, the class named is the opposite of the reason printed beside it. capability.ts:34-43 reserves NotImplementedOnPlatformError for "capability could say yes once filled in; the work just hasn't happened", and gives pinch on Android — adb has no multi-touch as the UnsupportedOperationError example. One contact and /dev/input denied is that first case verbatim. So the docs now say the tools are refused at the capability gate with the literal message they actually produce; the code is correct as it stands.

if (params.text) {
// `uitest uiInput text` types into whatever holds focus, in one shot — there
// is no per-character injection, so `delayMs` has nothing to pace (the tool
// description already lists the platforms that ignore it).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat]: delayMs is silently ignored on HarmonyOS, but the param description omits it and the code comment claims otherwise.

keyboard's delayMs is accepted and silently dropped on HarmonyOS, and both places that describe it say otherwise. typeHarmony (packages/tool-server/src/tools/keyboard/platforms/harmony.ts:49-68) never reads params.delayMs — harmonyTypeText issues one uitest uiInput text (packages/tool-server/src/utils/harmony-uitest.ts:201-203) — while the same parameter IS honoured on iOS (keyboard/simulator-server-keys.ts:22) and Chromium (keyboard/platforms/chromium.ts:11). Trigger: keyboard with text and delayMs against a platform: "harmony" device (cross-platform-tool.ts:142 dispatches straight to harmonyImpl; no flag or schema branch rejects delayMs there). Impact: the schema description at keyboard/index.ts:42 enumerates exactly the platforms that ignore it — "Ignored on Android phones/tablets ..., on Vega ..., and on TV targets ..." — and HarmonyOS is not among them, so an agent pacing input into a debounced HarmonyOS field is told the delay applies, gets one-shot typing, and receives no signal. That line is unchanged by this PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat]: Fixed in 1840a2b.

Both halves were true, and the code comment was the load-bearing one: its parenthetical asserted the tool description already listed the ignoring platforms, which is exactly what would stop a reviewer from opening index.ts:42 to check. The enumeration there named Android phones/tablets, Vega and TV, and HarmonyOS was absent — a negative-only enumeration, so its silence reads as an affirmative claim that the delay applies.

index.ts:42 now reads "…on HarmonyOS (uitest uiInput text types the whole string in one shot)…", which makes the comment true without touching it. argent-device-interact/SKILL.md:173 got the same clause — its positive half ("applies to the iOS simulator and Chromium") was already correct, but its negative list had the same hole.

Left as-is deliberately: run-sequence's step-level delayMs is a runner sleep, honoured on harmony, and a different parameter — worth not conflating.

One divergence the finding did not name, and it is wider than per-character pacing: on iOS and Chromium delayMs is also the hold duration for a lone key press (simulator-server-keys.ts:30-32, chromium.ts:73-85 both sleep between keyDown and keyUp), while harmonyKeyEvent is one atomic uiInput keyEvent. The current wording ("between key presses") does not cover that on any platform, so I have not tried to fix it here.

Comment thread packages/tool-server/src/tools/button/index.ts
},
});
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat]: describe and await-ui-element still enumerate only iOS/Android/Chromium/Vega as tree sources after adding HarmonyOS.

describe and await-ui-element both gained a HarmonyOS path in this PR — harmony: { device: true } in each capability, a harmony dispatch branch (describe/index.ts:138) and fetchTree branch (await-ui-element/index.ts:250-252) routing to describeHarmony, and "HarmonyOS id" added to each udid param — but neither tool's description prose was updated, and in both the prose is an enumeration that was exhaustive before this PR and no longer is:

  • describe/index.ts:155-162 documents one backend per platform ("On iOS, uses the AXRuntime…", "On Android, runs uiautomator dump", "On Chromium, walks the renderer's DOM…", "On Vega (Fire TV), reads the on-device automation toolkit…") and stops. HarmonyOS's uitest dumpLayout backend, the source: "harmony-uitest" value it puts in the response header, and its two runtime hints (display suspended; empty layout dump) are undocumented — including the display-off case, which is the HarmonyOS analogue of the Vega "empty tree, relaunch the app" caveat this same paragraph spells out.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat]: Fixed in 1840a2b — and the sweep found seven more of the same class.

Both prose blocks are byte-identical to origin/main: the PR changed only udid, capability, the import and the dispatch branch in each. describe now documents the uitest dumpLayout backend, the source: "harmony-uitest" value, and both runtime hints — including the suspended-display case, which as you note is the analogue of the Vega empty-tree caveat in the same paragraph, and strictly worse: an empty tree is visibly empty, a last-composited one is not. await-ui-element's four-item parenthetical gained the fifth backend.

The wider class, all in the same commit. contract.ts:72-80 is a comment whose stated purpose is that agents branch on source, and harmony-uitest was the one union member it did not explain — its twin in format-tree.ts:180-182 was updated, so this was an isolated miss. gesture-tap:133 picks the discovery tool by platform and stopped at Chromium. launch-app's bundleId and activity, restart-app's activity, and open-url's scheme list each enumerate every platform but this one — and open-url's note line now carries the aa start -U caveat that HARMONY_WEB_URL_CAVEAT only delivers after a call, which is a poor time to learn it. Five tools' "fails if … not reachable" sentences named the sim-server, the emulator backend and Chromium CDP but not hdc. Two more enumerations in argent-device-interact/SKILL.md — the describe row at :86, and :230, which listed exactly the three platforms await-screen-idle supported before this PR added the fourth, so as written it asserted HarmonyOS was unsupported.

keyboard's searchHint also named Vega and TV while omitting the platform it had just gained, and it is a deferred tool, so "harmony type text" would not have surfaced it. Added there and to describe / screenshot-diff / restart-app.

Not touched, having checked them: await-screen-idle's description is correctly generic, and run-sequence / boot-device / list-devices were all updated properly — run-sequence's per-step platform tags are the reference the rest should have matched.

`resolveHarmonyKeycode` indexed HARMONY_KEYCODES bare and tested the result
against undefined, so "constructor" / "__proto__" / "toString" resolved to a
prototype value, passed the guard, and reached `uiInput keyEvent ${key}` -
which `harmonyKeyEvent` interpolates unquoted, against runHdcShell's documented
contract. The four sibling backends all use `Object.hasOwn` plus a case-fold
and each carries a comment naming this exact failure; HarmonyOS had neither,
so `key: "Enter"` also 400'd here alone.

Backfills the harmony cases the sibling test files were each missing: the
per-backend prototype-key bucket in keyboard-error-taxonomy, the text-then-key
ordering pair in keyboard-key-order, and the button loop asserting every
HarmonyOS button reaches its own `uitest` key name over hdc.

Also fixes two tests that could not fail. The dispatch test declared
`harmony: { emulator: true }` while driving a `kind: "device"` id, so
assertSupported rejected before the arm it names; deleting the whole harmony
dispatch arm left it green. And nothing rendered a `harmony-uitest` tree, so
dropping it from format-tree's nested condition was free.
…me HarmonyOS in the enumerations it was left out of

The three docs promising `501 NotImplementedOnPlatform` for gesture-pinch /
gesture-rotate / gesture-custom / rotate / the profilers / the debugger
described a path none of them takes: none declares a `harmony` capability, so
the HTTP gate refuses them with UnsupportedOperationError (400) before dispatch
runs. 501 is reachable only for a tool that declares harmony and wires no
branch, and every tool here that declares it wires one. The class named is also
the opposite of the reason given beside it: capability.ts reserves
NotImplementedOnPlatform for work not yet done, and the README's own
justification - one contact, /dev/input denied - is the platform-impossible
case UnsupportedOperation exists for.

The rest are enumerations that were exhaustive before HarmonyOS and stopped
being so. `describe` and `await-ui-element` list one tree backend per platform
and omitted `uitest dumpLayout`, its `harmony-uitest` source value, and the
suspended-display hint - the analogue of the Vega empty-tree caveat spelled out
in the same paragraph. gesture-tap's discovery-tool advice is picked by
platform; launch-app's `bundleId` and `activity`, restart-app's `activity`,
open-url's scheme list and the "not reachable" sentence on five tools each
enumerate every platform but this one. `keyboard`'s `delayMs` lists exactly the
platforms that ignore it, and HarmonyOS - which types in one `uitest uiInput
text` shot - was not among them, which is what the comment beside the harmony
backend claimed it was.
Each of these ships conditional behaviour, not a delegation, and each sat
beside a test file that already held one case per other backend:

- gesture-tap: the clickCount===2 native doubleClick fast path, the paced
  loop above it, and the normalized -> device-pixel conversion, against a
  non-square display and fractions where a swapped axis reads differently
- gesture-swipe: the settle -> swipe/fling verb choice, which reads backwards
  at a glance, plus the duration->velocity conversion and both ends of its clamp
- open-url: the web-URL caveat appended to the shared deep-link note, and the
  omit case for a custom scheme that already fails loudly on device
- await-ui-element / await-screen-idle: that an unreadable screen cannot
  confirm `hidden`. HarmonyOS reaches that guarantee differently from its
  siblings - its fetcher throws rather than returning a flagged empty tree -
  so this pins the shared pollDescribeTree guard from a fifth direction.

Every assertion was checked against a mutation of the code it covers:
deleting the doubleClick path, swapping the tap axes, inverting the swipe
verb, dropping the velocity clamp, reversing the swipe endpoints, dropping
and unconditionally attaching the URL caveat, neutering isBlindRead, deleting
the harmony fetchTree branch, and passing the prefixed device id where the
connect key belongs. All fail. Neutering isBlindRead also reddens the iOS and
Android cases, confirming the guard is shared rather than re-implemented.
…w, and enforce the hdc timeout

Three defects the sibling implementations already handle.

**Frames were clamped per component instead of clipped.** ArkUI reports real
off-screen bounds for rows scrolled past the edge of a List - the old comment
said so - and clamping x, y, width and height independently turned a row at
[0,-400][1216,-260] into y=0 height=0.052: a full-width tap target at the top
of the screen, with the same frame as the row genuinely straddling that edge.
A row past the bottom reported a tap centre of y=1.026, and one past the right
edge x=1.078. Both the Android and Vega adapters clip to the screen rect before
normalising, and the Android call site carries a comment describing this exact
failure; harmony now reuses the helper the Android one already exports.

**The same-rect collapse discarded everything but `clickable`.** ArkUI sets
`.id()` and the state flags on the OUTER component, so the wrapper of a
same-rect pair is usually the only node that knows it is checked, disabled or
scrollable. Collapsing it dropped the identifier agents and flows select on,
turned a List holding one full-height row into a plain Cell with no scrollable
region to swipe, reduced a Toggle to an unlabelled Image with no on/off state,
and reported a disabled Button as an ordinary tappable one. The collapse now
fires only when the wrapper genuinely knows nothing its child does not, which
is what the comment always claimed it did.

**`hdc` and `Emulator` were spawned without a kill signal.** `execFile`'s
`timeout` sends `killSignal` once and never escalates, and the default SIGTERM
is what a client blocked on a wedged daemon ignores. Measured against a stub
that traps TERM: still running 6s after a 1s timeout, versus 1005ms with
SIGKILL. That left every per-call HarmonyOS budget advisory - list-devices has
a deadline backstop, the interaction tools have none. adb, vega-cli and simctl
all set SIGKILL, each with a written rationale.

Every assertion is mutation-checked, including per-field over the new collapse
guard: seven of its eight fields fail the suite individually when dropped, and
`checked` is pinned jointly with `checkable` (a checked switch is always
checkable, so isolating it would mean asserting on a dump shape that does not
occur). `value` was dropped from the guard as unreachable - it is only ever set
alongside a label, which the collapse already refuses.
holdUitestQueue takes the call's signal and refuses with an AbortError
before each injection, so a tap/swipe/pinch/button/keyboard call cancelled
while queued no longer injects and reports success. Also rewords the
boot-device in-flight comment in present tense.
Recommending gesture-scroll/gesture-drag for cdp-dom is a Chromium change
unrelated to HarmonyOS; HarmonyOS uses the default list either way.
Comment thread packages/tool-server/src/tools/flows/flow-run.ts
# Conflicts:
#	packages/skills/rules/argent.md
#	packages/skills/skills/argent-device-interact/SKILL.md
#	packages/skills/skills/argent-screen-recording/SKILL.md
#	packages/skills/skills/argent-test-ui-flow/SKILL.md
#	packages/tool-server/src/tools/await-screen-idle/index.ts
#	packages/tool-server/src/tools/await-ui-element/index.ts
#	packages/tool-server/src/tools/devices/boot-device.ts
#	packages/tool-server/src/tools/devices/list-devices.ts
#	packages/tool-server/src/tools/gesture-drag/index.ts
#	packages/tool-server/src/tools/gesture-scroll/index.ts
#	packages/tool-server/src/tools/gesture-tap/index.ts
#	packages/tool-server/src/tools/keyboard/index.ts
#	packages/tool-server/src/tools/launch-app/index.ts
#	packages/tool-server/src/tools/open-url/index.ts
#	packages/tool-server/src/tools/restart-app/index.ts
#	packages/tool-server/src/tools/run-sequence/index.ts
#	packages/tool-server/src/tools/screen-recording/screen-recording-start.ts
#	packages/tool-server/src/tools/screenshot/index.ts
#	packages/tool-server/src/utils/vega-screen.ts
#	packages/tool-server/test/screenshot-default-scale.test.ts
# Conflicts:
#	packages/docs/docs/reference/tools.mdx
#	packages/skills/rules/argent.md
#	packages/skills/skills/argent-device-interact/SKILL.md
#	packages/skills/skills/argent-screen-recording/SKILL.md
#	packages/skills/skills/argent-test-ui-flow/SKILL.md
list-devices skips hdc and DevEco when the flag is off; harmony ids are
refused at the capability gate and at harmonyConnectKey, the chokepoint every
drive path takes; boot-device refuses HarmonyOS instances. GET /tools renders
<flag:...> spans and x-argent-flag properties per request.
- wait tools: a zero-budget deadline poll on HarmonyOS settles as unsettled
  instead of self-refusing (a hidden wait timed out unreadable), and the shared
  loop keeps main's handling of a real final-poll error
- harmonyDisplay prefers an awake panel over the first one listed
- cap the in-queue display re-read at HARMONY_DISPLAY_TIMEOUT_MS
- boot notes no longer suggest a longer bootTimeoutMs, which a retry ignores
- a harmony-emulator- id gets a capability message naming the id to drive
- e2e: skip the post-swipe tree settle where the runner has no tree; the
  recorder says an await:/assert: conversion fails there
- tests: flag gate + mutation-checked cases
Always-loaded text (the argent rule, skill descriptions, README headline)
no longer mentions HarmonyOS. Every HarmonyOS fact from the skill bodies
moves to argent-device-interact/references/harmonyos.md, reached through
one pointer line in argent-device-interact and one in the e2e YAML
reference. The e2e section there states what the runner actually does on
a named harmony device: coordinate tap, selector-less swipe/pinch, launch,
snapshot without cropOn, wait and tool steps run; selector steps, idle,
long-press and rotate do not.

Docs mark HarmonyOS as experimental and off by default, and list the
harmonyos flag in the configuration reference.
…rmonyos flag

Wrap every HarmonyOS-specific span of the advertised tool text (descriptions,
.describe() texts, searchHints) in <flag:harmonyos> tags, gate boot-device's
harmonyInstance property with x-argent-flag, and render boot-device's
target-selection error through renderFlagGated. With the flag off the text
matches origin/main; with it on it matches the untagged text, except for a few
lists reordered so the off form keeps its 'or'.

extract-tools.mjs emits flag spans as if every flag were on, so SpiderShield
scores the full text.
The render service's resolution is the panel mode and stays portrait when
the display rotates, while uitest coordinates and layout bounds rotate with
it. A tap at a describe frame in landscape landed off-screen and was still
reported as tapped. Read the display manager's ScreenRotation for the same
screen and swap the size on a quarter turn.
…scriptions

- redactSecretsFromError also matches a value as a shell-quoted command line
  carries it, so a typed secret containing ' no longer leaks through a failed
  HarmonyOS uitest or Android input text call
- gesture-swipe: on HarmonyOS durationMs sets speed, clamped to uitest's range
- open-url: HarmonyOS fails whenever aa start -U refuses the URI
# Conflicts:
#	packages/tool-server/src/tools/gesture-swipe/index.ts
#	packages/tool-server/src/tools/gesture-tap/index.ts
Validates the .hap's module.json bundleName before uninstalling, tolerates
only 'not installed' on the uninstall, and reads the result from hdc's msg:
line since hdc exits 0 either way.
…r load

A 40ms budget let the first failing read miss the deadline under a full
suite run, so the note said 'Ran out of time' instead of the dump failure.
…tion

Keep one statement that HarmonyOS support is experimental and off by
default, in supported-platforms. Drop the repeated experimental and
flag qualifiers from passing mentions.
#1438 removed every em dash from tool descriptions. This branch brought
back nine: four in text served with the harmonyos flag off (button,
keyboard `key`) and five inside flag spans (boot-device, describe,
gesture-tap). With the flag off, the button and keyboard `key` text is
now byte-identical to main again.
@latekvo

latekvo commented Oct 22, 2026

Copy link
Copy Markdown
Member Author

Done a final verification pass, hidden the entire feature behind a feature flag, i believe this works well as a base for the support of this platform - all the most important features are ported and tested.

@latekvo

latekvo commented Oct 22, 2026

Copy link
Copy Markdown
Member Author

The remaining CIs are Mac runners. They were passing on the last completed pass. No mac changes since. Verified and the changes to existing surface are minimal. So taking the risk onto myself and merging without waiting for them - queued for 2h already and likely will have to wait till 1AM before they even start running.

@latekvo
latekvo merged commit e3b98e1 into main Oct 22, 2026
17 checks passed
@latekvo
latekvo deleted the feat/harmonyos-emulator branch October 22, 2026 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants