Skip to content

Commit 494eb52

Browse files
thymikeeagent
andauthored
refactor: migrate get to the request-bound device runtime (#1877)
* refactor: migrate get to the request-bound device runtime `get` declares `elementReadRuntimeUse` (required `captureSnapshot`, preferred `readTextAtPoint`), admits once from exact owner facts, refuses before binding, and binds exactly once. Its capability bucket, the static HarmonyOS/Web command sets that augmented it, and `requireCommandSupported` admission for `get` are gone; `'get'` leaves the `createSelectorRuntime` capability union. The neutral `readTextAtPoint` operation replaces the branch-per-family legacy `read` dispatch on the `get` path. Every local family and both providers now classify it exhaustively — Web, HarmonyOS, Vega and every provider row report it unavailable, which is behaviour-preserving because the legacy dispatch had no arm for them and threw on every call before falling back. R36 is the new parametrized cutover row. * fix(get): admit before the direct-iOS fast path; close the element-read outcome Review blockers on #1877. 1. `dispatchGetViaRuntime` could complete the direct-iOS selector query before `resolveBoundGetRuntime`. Once `get` declares `device-runtime`, ADR 0019 requires resolve -> admit -> bind before anything in the request path operates, so admission now runs first for every target shape and the fast path is a fast path *within* an admitted request. Regression: an eligible direct selector cannot operate when facts refuse admission. 2. `readTextAtPoint` returned `Promise<string>` and `readTextForNode` caught any throw and fell back, assigning a typed diagnostic after an untyped failure. It now returns a closed `ElementTextReadOutcome`; fallback happens only for the contract's classified reasons; unexpected errors propagate. The reason union is derived from its runtime list so the two cannot drift, and an unhandled reason is a compile error at the consumer. This retires the generic catch the start record promised. * feat(daemon): land the selector capture seam with get as its first consumer Takes ownership of the request-bound selector capture seam from #1876, which cannot ship standalone: with find's cutover deferred it had no consuming command (ADR 0019 §10) and was not dead-code clean (check:production-exports 19 -> 20). `get` is its first consumer, so it lands here. Adopts find's handoff as given. The one shape change, approved by the coordinator: the selector family gets its own capture uses carrying a PREFERRED `readTextAtPoint`, declared ALONGSIDE the snapshot uses so `snapshot`/`diff` keep binding exactly what they bind today. The read is surfaced through the existing arms of `bindSnapshotCaptureRuntime`, reusing the same selectActiveAppSnapshot / selectSnapshotWithoutActiveApp selectors — no second plan-to-operation dispatch. `get` now runs through `createBoundSelectorRuntime`; `resolveBoundGetRuntime` and its test are deleted as superseded, and `'get'` leaves the `createSelectorRuntime` capability union. The legacy read adapter survives for `find <q> get text` and is selected by which command constructed the runtime — never by failure, family, environment, or flag — so `get` cannot reach it. It retires in find's cutover, where the last consumer moves. * refactor: retire the read dispatch alias across both selector consumers Read-only `find` now constructs a BOUND selector backend, so `get text` and `find <q> get text` execute the same bound `readTextAtPoint` instead of one binding it and the other dispatching the legacy `read`. This moves find's READ LEG only: find's descriptor stays LEGACY_PLATFORM_EXECUTION and it claims no cutover row. With no consumer left, the whole chain goes: the `read` registry entry and its `dispatch: {}` projection, `DISPATCH_HANDLERS.read`, `handleReadCommand`, `interaction-read-legacy-dispatch.ts`, and the duplicate platform reader branches it carried. `read` was the only `dispatch-alias` descriptor, so that catalog group goes too. Deleting the registry entry drops 'read' from DescriptorDispatchCommandName, which makes a surviving DISPATCH_HANDLERS.read a compile error rather than something R36 has to police. R36 now claims the retirement it can prove. `find.test.ts` is over the size tripwire, so its handler invocation is extracted to find-handler-fixture.ts and the pin lowered 1237 -> 1221. * refactor(daemon): apply the seam addendum after #1876 was re-scoped Two edits, per find's ADDENDUM.md: 1. `includeRects` returns to `buildRuntimeCaptureInput`. It was removed from #1876 as unconsumed; the selector capture path is genuinely its first consumer (a Web rect capture requests bounds explicitly), so it lands here under the same rule that moved the seam. `snapshot`/`diff` pass nothing. 2. The per-capture `signal` is dropped, not restored. `CaptureSnapshotInput` has no such field on this stack — it moved to `wait` (#1875) with the regression that proves per-poll abort and quiescence. `get` captures once per resolution and never polls, so nothing here needs it. The seam test and fixture coverage for it moves with the contract rather than being kept against a field that no longer exists. * refactor(get): retire the direct-iOS selector shortcut `get` declares device-runtime, so its request path must reach the platform only through operations R36 declares. `dispatchDirectIosSelectorGet` reached `runAppleRunnerCommand` through a path the row declares no operation for; admitting before a bypass is not executing through the seam, so the bypass is removed rather than ordered after admission. Every target shape — including the simple iOS `id=` selector — now resolves through the bound capture. `queryDirectIosSelector` itself stays: `offscreen-target-probe.ts` still consumes it and it remains single-copy. `dispatchDirectIosSelectorIs` belongs to `is` (#1883). Two get-only helpers (`readDirectIosGetSelector`, `buildDirectIosGetResult`) became unreachable and are deleted with the caller. Declaring `querySelector` as a fact-admitted preferred operation was rejected on duplication, not correctness: the offscreen probe takes a plain session and cannot consume a bound operation, so it would ship the query twice until Wave 5 moves the probe — the deferred-duplication shape this PR was already overruled for on the `read` alias. It returns as a declared, §9-measured operation in a later unit that also moves the probe. Cost, stated plainly: `get text id=…` loses its tree-capture skip on iOS. No fallback was added and the latency is not recovered elsewhere. R36's singularExecution claim is now what the code does rather than aspirational. * refactor: ride the Interactor seam for the element read; drop the bespoke host Two operations of the same class were reaching their mechanics two different ways: `findText` rides `Interactor` via `localInteractors.resolve`, while `readTextAtPoint` had its own host port. That is duplication of MECHANISM, so the read now rides the same seam. `Interactor` gains `readTextAtPoint?`, implemented on the Apple, Android and Linux interactors where those mechanics already live. `src/platform-runtime-element-text-host.ts` and its `elementText` host wiring are deleted; the contract binds through the resolver exactly as the snapshot runtime does. Size honesty: this removes an 89-line module but the four readers still have to exist, so they moved into the interactors rather than vanishing. Net production change is ~4 lines, not ~89. The duplication of mechanism is what is actually fixed; Wave 5/6 retires the seam for both operations together. Also from the size investigation: - `ElementTextRuntimeExecution` was byte-identical to `SnapshotRuntimeExecution`; removed and reused, as `find-text-runtime.ts` does. - Removed a stranded, stale comment in `selector-capture-binding.ts` that still claimed a duplication this branch had already retired. - `FrozenUnavailablePlatformRuntimeFacts` is derived from its input type rather than restated, removing a 14-line clone group my new cell had pushed over the detector threshold. * refactor: migrate is to the request-bound device runtime (#1883) * refactor: migrate is to the request-bound device runtime `is` declares the shared selector capture use, admits once from exact owner facts, refuses before binding, and binds exactly once. Its capability bucket, the static HarmonyOS/Web command sets that augmented it, and `requireCommandSupported` admission for `is` are gone; `'is'` leaves the `createSelectorRuntime` capability union. Admission now runs BEFORE the direct-iOS selector fast path. ADR 0019 requires resolve -> admit -> bind before anything in a `device-runtime` command's request path reaches the device, so that query becomes a fast path *within* an admitted request rather than a way around exact-owner facts. The rule is documented once, on `createBoundSelectorRuntime`, replacing the two duplicated call-site comments `get` and `is` were each carrying. Declared behaviour change: `is` takes the active-app plan split, so the facts decide per family. On iOS `appBundleId` is the XCUITest attach target — with no tracked app the runner's own process comes to the foreground, displaces the app under test, and the capture then answers confidently about the runner's own blank screen. An iOS `is` on a session with no tracked app is now a typed SESSION_NOT_FOUND refusal carrying the `open` hint. Refusing beats displacing-and-lying. Android captures the real launcher in that state and is unchanged, which is what the platform facts already encoded. The two Apple watchOS cells move from capability-admitted-then-runner-failure to a typed unavailable refusal, the same classification snapshot, diff, and get already landed. R37 is the new parametrized cutover row. `find` keeps `createSelectorRuntime` and its `requireCommandSupported` call, so `captureData` stays optional and `captureSnapshotWithInteractor` stays: this unit is not the last selector unit. * fix(is): a failing iOS assertion fails instead of exiting zero Reverses part of #557, on thymikee's explicit instruction. `is` is an assertion: the docs state it "exits non-zero on failure". The direct-iOS fast path broke that contract — it reported a failed predicate as a completed command, so on device $ agent-device is text id=… "Wrong Expected Text" Passed: is text (exit 0) because `{ok: true, pass: false}` reaches `isCliOutput`, which renders "Passed: is <predicate>" without reading `pass`. A failing assertion reported as success lets a replay run on past a broken state. Now: Error (COMMAND_FAILED): is text failed for selector id=…: expected="Wrong Expected Text" actual="Apple Account, …" (exit 1) The renderer needed no patch: a negative can no longer produce a success envelope, so it is correct by construction. Direction chosen deliberately. Making the two paths agree could have gone either way, and "an agent asked a question and got an answer" is a real argument for the other one. This follows the DOCUMENTED contract rather than merely the incumbent behaviour, and the alternative is a far larger change: a zero-exit `is` would alter every platform and path, break scripts that rely on it failing the shell, and needs its own PR, docs, and probably a major version. It is also already how `is hidden` and `is exists` behave end to end. PASSING assertion, and that arm still answers with zero captures (pinned). Only the negative falls through — what #557's own summary asked for, "preserving snapshot fallback for misses", refusing fallback only for hard failures like ambiguity. The fall-through was #557's own design, never armed: the `| null` return and the caller's `if (!payload) return null;` guard were unreachable. This makes that dead guard live. Measured on iPhone 17 (median of 9, warm daemon): predicate holds 0.14s / 0 snapshots, unchanged; predicate fails 0.25s / 1 snapshot. ~+0.11s on failing assertions only. Correctness gain beyond the envelope: the fast path evaluates a ONE-NODE tree, so `visible` cannot see the ancestor geometry a list row inherits and its negative can be wrong. Falling through re-asks the real tree and can turn a spurious negative into a pass. The #557 pin moved with its reasoning at the pin site. * fix(layering): let a cutover row state a data-only admission retirement Review blocker on #1883: R37 claimed `legacyRetirement.routeNames: ['WEB_QUERY_COMMANDS_WITH_IS', 'HARMONYOS_IS_SUPPORT']`. Neither identifier has ever existed. They satisfied the non-empty shape check while proving nothing — the vacuous registry claim AGENTS.md warns about, and a green gate that would stay green if the deletion were reverted. The cause was the model, not the row. Every `LegacyRetirementClaim` form names something that must NOT exist, which a row can always satisfy by inventing a name. `is` retired no module, route, or dispatch projection because it had none: its legacy admission was a capability bucket plus membership in two static platform command sets, so its real retirement is a DATA deletion the model could not express. Rather than patch around that with sentinels or a per-command policy file — both forbidden by the playbook — this generalizes the model. `staticCommandSets` names the sets themselves and is proven from both sides: each must still be DECLARED in production source, and must no longer list the command. A fictional set fails the first half; a skipped deletion fails the second. That is what an identifier-shaped claim cannot state. R37 now claims HARMONYOS_SUPPORTED_COMMANDS and WEB_QUERY_COMMANDS, which is the deletion it actually performed. Planted red, both halves, against the real gate: [R37 is-runtime-cutover] 2 violation(s): (is cutover row):1 — claims retired static command set 'WEB_QUERY_COMMANDS_WITH_IS', which no production source declares (is cutover row):1 — claims retired static command set 'HARMONYOS_IS_SUPPORT', which no production source declares [R37 is-runtime-cutover] 2 violation(s): src/core/capabilities.ts:59 — static command set WEB_QUERY_COMMANDS still admits is so the exact claim that shipped is now rejected by name, and so is restoring the membership it claims to have removed. Mechanism cases live with the other planted-row tests; layering goes 177 -> 181. * test(is): pin the exit-code guarantee independently of what answers the predicate Prep for the Blocker 1 retirement, which deletes `buildDirectIosIsResult` — the function the #557 reversal fixed. The reversal's guarantee must not evaporate with it, so it gets a case that does not know how the daemon decided. `is` is documented to "exit non-zero on failure". The reversal proved that at the JSON envelope; nothing pinned it at the CLI boundary, which is where the defect was actually visible (`Passed: is text`, exit 0). This asserts the CLI contract directly: a `predicate_failed` response exits 1 and never renders as passed. It survives the retirement untouched, because it asserts the outcome rather than the path. Planted red with the exact pre-#1739 envelope the shortcut produced (`{ok: true, data: {pass: false}}`): `exitSpy.calls` is `[]` — no exit call at all — so the case fails, which is the regression it exists to catch. Unpushed on purpose: the restack will carry it into the retirement cycle. * refactor(is): retire the direct-iOS selector shortcut thymikee's ruling (option b). `is` declares `device-runtime`, so its request path must reach the device only through the operations R37 declares. It did not: a simple iOS `id=`/`label=` target was answered by a direct XCUITest querySelector without any capture, ordered after admission but not executing through the seam. This is not retired because it was wrong. `wait` hypothesized that the degenerate one-node evaluation mis-answers `is visible` for off-viewport nodes, traced it through the code convincingly, then tested it on device and it did not reproduce — XCUITest's own query is conservative about visibility, so the degenerate evaluation never gets the chance. It is retired because it was an undeclared, unmeasured bypass that made R37's singularExecution claim false: the same class of untruth as the sentinel retirement names fixed in the previous commit. Declaring querySelector as a real operation instead was rejected for a concrete reason: offscreen-target-probe.ts consumes queryDirectIosSelector with a plain session and cannot take a bound operation, so declaring it now would ship it twice until Wave 5 moves the probe — the deferred-duplication shape that got get's read deferral overruled. It returns as a declared, fact-admitted, section 9-measured operation in the unit that also moves the probe. Retired: dispatchDirectIosSelectorIs, its call site, buildDirectIosIsResult, and resolveDirectIosSelectorQuery — each had exactly one caller, all on this path — plus the ResolvedDirectIosSelectorQuery type they orphaned and two imports. queryDirectIosSelector itself stays: the offscreen probe still consumes it and it remains single-copy. Latency cost, stated plainly and not softened: a held predicate on a simple iOS selector goes from ~0.14s with no capture to ~0.25s with one, measured as the median of 9 warm runs on iPhone 17. There is no fallback and no fast path. R37's comment finally describes the code: "every predicate answers from the resolved tree" was written while the shortcut existed. Its scope is now stated too, so it is not read as absolute — the Android foreground-blocker diagnostic still reaches adb on the failure path, where it cannot produce or change a verdict; that edge is pre-existing, co-owned with wait, and recorded as Wave 6 denominator work with R22's appState as its declared replacement. Seven tests lost their subject. Those whose only content was the shortcut's own mechanics are deleted; the outcome-level ones are retargeted and keep asserting what survives. --------- Co-authored-by: agent <agent@local> * fix(contracts): a falsely advertised element read fails as a contract bug An owner whose facts advertised `readTextAtPoint` but whose interactor cannot perform it was reported as `{ status: 'unreadable', reason: 'surface-not-readable' }`. That put a contract violation inside the closed reason set that licenses falling back to the captured tree, so `get text` answered from potentially stale snapshot text precisely because the runtime lied about itself. ADR 0019 §2 requires the mismatch to fail as `runtime-contract-invalid`; it now throws. Removing the only producer of `surface-not-readable` made that reason dead: no path can reach it, since an interactor that HAS the read maps a blank or absent answer to `no-text-at-point` via `elementTextRead`. Dropped from the union, its consumer switch arm, and both test lists. `classifiedFallbackReason`'s `never` arm stays — it is what makes adding a reason a compile error rather than a silent untyped fallback. Deduplication found while auditing the change: - `invalidRuntimeContract` was module-private in `platform-runtime.ts`. It now owns its own module so both runtime modules share one construction. It is deliberately not exported through the platform facade: that facade must stay exhaustive over its sources, which would make this a public symbol with no external consumer. - The 8-field runner execution projection was written out three times (`snapshot-runtime-capture-input.ts`, `interaction-read.ts`, `screenshot-runtime.ts`). One `runtimeExecutionFromContext` now serves all three; `screenshotExecutionFromContext` keeps its name and delegates, since `ScreenshotRuntimeExecution` and `SnapshotRuntimeExecution` are the same type. Dropping a field here silently strips request id, log/trace paths, XCUITest overrides, or runner lease context — an operation that still answers but runs unconfigured, which is exactly the defect the wait unit hit as a P1. Red before green: with the old guard restored the new regression fails with "Missing expected rejection" — the call resolves instead of throwing, which is the silent degradation it exists to forbid. --------- Co-authored-by: agent <agent@local>
1 parent fed7251 commit 494eb52

75 files changed

Lines changed: 2147 additions & 643 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { AppError } from '@agent-device/kernel/errors';
4+
import {
5+
bindElementTextRuntime,
6+
elementTextRead,
7+
type ElementTextReadOutcome,
8+
type ElementTextUnreadableReason,
9+
} from './element-text-runtime.ts';
10+
import type { Interactor } from './interactor-types.ts';
11+
import type { DeviceInfo } from '@agent-device/kernel/device';
12+
13+
/**
14+
* The reasons this suite exercises. Kept local on purpose: exhaustiveness is enforced at the
15+
* CONSUMER by `classifiedFallbackReason`'s `never` arm (a new reason is a compile error there),
16+
* so a second exported runtime list would be an unconsumed parallel source of truth that could
17+
* silently drift. The annotation is what ties this list back to the union.
18+
*/
19+
const UNREADABLE_REASONS: readonly ElementTextUnreadableReason[] = ['no-text-at-point'];
20+
21+
/**
22+
* ADR 0019 §2 contract coverage for the preferred element-text read.
23+
*
24+
* A preferred operation may fall its consumer back to the required path only through a TYPED
25+
* reason. These tests pin that the reason set is closed and exhaustively enumerated, so a new
26+
* reason cannot be added without a consumer having to classify it — which is what keeps the
27+
* retired generic `catch` from creeping back as "some other failure, just fall back".
28+
*/
29+
30+
test('the outcome union is closed: every value is a read or a classified unreadable', () => {
31+
const outcomes: readonly ElementTextReadOutcome[] = [
32+
elementTextRead('live value'),
33+
...UNREADABLE_REASONS.map((reason) => ({ status: 'unreadable', reason }) as const),
34+
];
35+
for (const outcome of outcomes) {
36+
if (outcome.status === 'read') {
37+
assert.equal(typeof outcome.text, 'string');
38+
continue;
39+
}
40+
assert.ok(
41+
(UNREADABLE_REASONS as readonly string[]).includes(outcome.reason),
42+
`unreadable outcome carries an unclassified reason: ${outcome.reason}`,
43+
);
44+
}
45+
});
46+
47+
test('a non-blank owner answer is a read that preserves the exact text', () => {
48+
const outcome = elementTextRead(' padded value ');
49+
assert.deepEqual(outcome, { status: 'read', text: ' padded value ' });
50+
});
51+
52+
// Blank is a classification, not a read: an owner answering with whitespace has said there is
53+
// nothing at this point, and saying so by reason keeps consumers off "empty or failed?" guesswork.
54+
for (const [label, value] of [
55+
['empty string', ''],
56+
['whitespace', ' \n\t '],
57+
['undefined', undefined],
58+
['null', null],
59+
] as const) {
60+
test(`a ${label} owner answer classifies as no-text-at-point`, () => {
61+
assert.deepEqual(elementTextRead(value), {
62+
status: 'unreadable',
63+
reason: 'no-text-at-point',
64+
});
65+
});
66+
}
67+
68+
test('read outcomes are frozen so a consumer cannot mutate a classification', () => {
69+
assert.ok(Object.isFrozen(elementTextRead('value')));
70+
assert.ok(Object.isFrozen(elementTextRead('')));
71+
});
72+
73+
/**
74+
* A runtime owner whose facts advertised `readTextAtPoint` but whose interactor cannot perform
75+
* it is a CONTRACT BUG, not a refusal. Classifying it as an unreadable reason would place it
76+
* inside the closed set that licenses falling back to already-captured text — so the command
77+
* would answer from a stale tree precisely because the runtime lied about itself.
78+
*
79+
* Reverting the guard to `{ status: 'unreadable', reason: … }` makes this test fail: the call
80+
* resolves instead of rejecting, which is the exact silent degradation it exists to forbid.
81+
*/
82+
test('an advertised read with no interactor implementation fails as a contract bug', async () => {
83+
const runtime = bindElementTextRuntime({
84+
device: { platform: 'ios' } as unknown as DeviceInfo,
85+
signal: new AbortController().signal,
86+
// An interactor with NO readTextAtPoint — the mismatch the facts promised away.
87+
resolveInteractor: async () => ({}) as unknown as Interactor,
88+
});
89+
90+
await assert.rejects(
91+
() => runtime.readTextAtPoint({ point: { x: 1, y: 2 } }),
92+
(error: unknown) =>
93+
error instanceof AppError &&
94+
error.details?.reason === 'runtime-contract-invalid' &&
95+
/advertised readTextAtPoint/.test(error.message),
96+
);
97+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import type { DeviceInfo } from '@agent-device/kernel/device';
2+
import type { Point } from '@agent-device/kernel/snapshot';
3+
import type { Interactor, RunnerContext } from './interactor-types.ts';
4+
import { invalidRuntimeContract } from './runtime-contract-error.ts';
5+
import type { RuntimeOperationFact } from './platform-runtime.ts';
6+
import type { SessionSurface } from './session-surface.ts';
7+
import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts';
8+
9+
/**
10+
* Neutral intent for one point-addressed element read. The point is already resolved from the
11+
* node the caller matched, so the operation names no command, request, session, or CLI flag.
12+
*/
13+
export type ReadTextAtPointInput = Readonly<{
14+
point: Point;
15+
options?: Readonly<{ appBundleId?: string; surface?: SessionSurface }>;
16+
/** Same runner metadata a capture needs; reuses that type rather than restating it. */
17+
execution?: SnapshotRuntimeExecution;
18+
}>;
19+
20+
/**
21+
* Why an owner that HAS a live read still produced no text for this point.
22+
*
23+
* Closed on purpose (ADR 0019 §2): a consumer may fall back to the required path only for a
24+
* reason named here. Anything else — a runner transport failure, a helper crash, a bug — is an
25+
* unexpected error and propagates, because silently answering from a stale captured tree after
26+
* an unclassified failure is exactly the "generic catch fallback" the ADR forbids.
27+
*/
28+
export type ElementTextUnreadableReason =
29+
/** The owner queried successfully and there is nothing readable at this point. */
30+
'no-text-at-point';
31+
32+
/** The closed outcome of one live element-text read. */
33+
export type ElementTextReadOutcome =
34+
| Readonly<{ status: 'read'; text: string }>
35+
| Readonly<{ status: 'unreadable'; reason: ElementTextUnreadableReason }>;
36+
37+
/**
38+
* Normalizes a raw owner read into the closed outcome. Blank text is not a read: an owner that
39+
* answers with whitespace has told us there is nothing at this point, and saying so by reason
40+
* keeps every consumer off "did it fail or is it empty?" guesswork.
41+
*/
42+
export function elementTextRead(text: string | undefined | null): ElementTextReadOutcome {
43+
if (typeof text !== 'string' || text.trim().length === 0) {
44+
return Object.freeze({ status: 'unreadable', reason: 'no-text-at-point' } as const);
45+
}
46+
return Object.freeze({ status: 'read', text } as const);
47+
}
48+
49+
export type ElementTextRuntimeOperations = Readonly<{
50+
/**
51+
* The live text an owner reads at a point, which can exceed the readable text carried by an
52+
* already-captured snapshot node (an editable field whose value is longer than its label).
53+
* Declared `preferred`, never `required`: every consumer's required path answers from the
54+
* snapshot tree, so an owner without this operation still executes the command completely.
55+
*
56+
* Returns a closed typed outcome rather than a bare string, so a consumer never has to
57+
* distinguish "no text here" from "the read blew up" by catching.
58+
*/
59+
readTextAtPoint(input: ReadTextAtPointInput): Promise<ElementTextReadOutcome>;
60+
}>;
61+
62+
export type ElementTextRuntimeOperationFacts = Readonly<{
63+
readTextAtPoint: RuntimeOperationFact;
64+
}>;
65+
66+
export function elementTextRuntimeOperationFacts(
67+
input: ElementTextRuntimeOperationFacts,
68+
): ElementTextRuntimeOperationFacts {
69+
return Object.freeze({ readTextAtPoint: input.readTextAtPoint });
70+
}
71+
72+
/** Resolves the selected owner's interactor, exactly as the snapshot runtime does. */
73+
export type ElementTextInteractorResolver = (
74+
device: DeviceInfo,
75+
runner: RunnerContext,
76+
) => Promise<Interactor>;
77+
78+
/**
79+
* Binds the owner's live point read for the lifetime of a request binding.
80+
*
81+
* Rides the same `Interactor` seam `findText` uses rather than a bespoke host port: two
82+
* operations of the same class reaching their mechanics two different ways is duplication of
83+
* mechanism, and Wave 5/6 retires the seam for both together.
84+
*/
85+
export function bindElementTextRuntime(
86+
params: Readonly<{
87+
device: DeviceInfo;
88+
signal: AbortSignal;
89+
resolveInteractor: ElementTextInteractorResolver;
90+
}>,
91+
): ElementTextRuntimeOperations {
92+
return Object.freeze({
93+
readTextAtPoint: async (input: ReadTextAtPointInput) => {
94+
const signal = params.signal;
95+
signal.throwIfAborted();
96+
const interactor = await params.resolveInteractor(params.device, {
97+
...input.execution,
98+
appBundleId: input.options?.appBundleId,
99+
signal,
100+
});
101+
// Facts advertised the read but the owner's interactor cannot perform it. That is a
102+
// contract violation, not a refusal: classifying it as `surface-not-readable` would put
103+
// it inside the closed reason set and license the caller to fall back to already-captured
104+
// text, answering from a stale tree because the runtime lied. Fail as the contract bug it
105+
// is (ADR 0019 §2) so no consumer can silently degrade.
106+
if (typeof interactor.readTextAtPoint !== 'function') {
107+
throw invalidRuntimeContract(
108+
'Runtime owner advertised readTextAtPoint without an interactor implementation',
109+
);
110+
}
111+
return elementTextRead(
112+
await interactor.readTextAtPoint(input.point, {
113+
appBundleId: input.options?.appBundleId,
114+
surface: input.options?.surface,
115+
signal,
116+
}),
117+
);
118+
},
119+
});
120+
}

packages/contracts/src/facades/platform.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,18 @@ export {
214214
captureSnapshotUse,
215215
defineUse,
216216
resolveScreenshotRuntimePlan,
217+
resolveSelectorCaptureRuntimePlan,
217218
resolveSnapshotRuntimePlan,
218219
screenshotRuntimePlanUses,
220+
selectorCaptureRuntimePlanUses,
219221
snapshotRuntimePlanUses,
220222
viewportRuntimeUse,
221223
} from '../platform-runtime-operations.ts';
222-
export type { ScreenshotRuntimePlan, SnapshotRuntimePlan } from '../platform-runtime-operations.ts';
224+
export type {
225+
ScreenshotRuntimePlan,
226+
SelectorCaptureRuntimePlan,
227+
SnapshotRuntimePlan,
228+
} from '../platform-runtime-operations.ts';
223229
export type {
224230
PlatformRuntimeHost,
225231
PlatformRuntimeModule,
@@ -270,6 +276,19 @@ export type {
270276
ViewportRuntimeOperationFacts,
271277
ViewportRuntimeOperations,
272278
} from '../viewport-runtime.ts';
279+
export {
280+
bindElementTextRuntime,
281+
elementTextRead,
282+
elementTextRuntimeOperationFacts,
283+
} from '../element-text-runtime.ts';
284+
export type {
285+
ElementTextReadOutcome,
286+
ElementTextInteractorResolver,
287+
ElementTextRuntimeOperationFacts,
288+
ElementTextRuntimeOperations,
289+
ElementTextUnreadableReason,
290+
ReadTextAtPointInput,
291+
} from '../element-text-runtime.ts';
273292
export type {
274293
AppStateRuntimeCommand,
275294
AppStateRuntimeCommandResult,

packages/contracts/src/interactor-types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,16 @@ export type Interactor = {
222222
screenshot(outPath: string, options?: ScreenshotOptions): Promise<void>;
223223
setViewport?(width: number, height: number): Promise<Record<string, unknown> | void>;
224224
snapshot(options?: SnapshotOptions): Promise<SnapshotResult>;
225+
/**
226+
* Native reading of the live text at a point, when the backend has one. Answers the text the
227+
* owner can see right now, which can exceed what an already-captured node carries (an editable
228+
* field whose value is longer than its label). Optional: a backend without it leaves the
229+
* captured tree as the complete answer.
230+
*/
231+
readTextAtPoint?(
232+
point: Point,
233+
options?: { appBundleId?: string; surface?: SessionSurface; signal?: AbortSignal },
234+
): Promise<string | undefined>;
225235
gestureViewport?(): Promise<Rect>;
226236
back(mode?: BackMode): Promise<void>;
227237
home(): Promise<void>;

0 commit comments

Comments
 (0)