Skip to content

Commit 20b2c33

Browse files
committed
refactor: migrate wait to request-bound runtime
1 parent f3d5b3d commit 20b2c33

28 files changed

Lines changed: 681 additions & 611 deletions

packages/contracts/src/facades/platform.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,8 @@ export {
218218
viewportRuntimeUse,
219219
} from '../platform-runtime-operations.ts';
220220
export type { SnapshotRuntimePlan } from '../platform-runtime-operations.ts';
221+
export { resolveWaitRuntimePlan, waitRuntimePlanUses } from '../wait-runtime-plan.ts';
222+
export type { WaitRuntimePlan, WaitRuntimeTarget } from '../wait-runtime-plan.ts';
221223
export type {
222224
PlatformRuntimeHost,
223225
PlatformRuntimeModule,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { captureSnapshotUse } from './platform-runtime-operations.ts';
2+
3+
/** The normalized wait target, independent of the positional grammar that produced it. */
4+
export type WaitRuntimeTarget = 'sleep' | 'text' | 'ref' | 'selector' | 'stable';
5+
6+
export const waitRuntimePlanUses = Object.freeze([captureSnapshotUse] as const);
7+
8+
/**
9+
* A duration wait observes nothing, so it carries **no** `use`: the absence of platform execution
10+
* for that shape is a type fact, not a convention, and no admission-only bind can be introduced
11+
* for it (ADR 0019 §9). Every other shape polls one capture — text and `@ref` scan the tree for a
12+
* label, selector runs the selector pipeline over it, `stable` compares successive captures — so
13+
* one required operation covers all four plus the timeout-surface decoration.
14+
*/
15+
export type WaitRuntimePlan =
16+
| Readonly<{ kind: 'sleep' }>
17+
| Readonly<{
18+
kind: 'poll-capture';
19+
operation: 'captureSnapshot';
20+
use: typeof captureSnapshotUse;
21+
}>;
22+
23+
const sleepPlan = Object.freeze({ kind: 'sleep' } as const satisfies WaitRuntimePlan);
24+
25+
const pollCapturePlan = Object.freeze({
26+
kind: 'poll-capture',
27+
operation: 'captureSnapshot',
28+
use: captureSnapshotUse,
29+
} as const satisfies WaitRuntimePlan);
30+
31+
/** Selects the owner-fact-backed plan for one normalized wait target. */
32+
export function resolveWaitRuntimePlan(
33+
input: Readonly<{ target: WaitRuntimeTarget }>,
34+
): WaitRuntimePlan {
35+
return input.target === 'sleep' ? sleepPlan : pollCapturePlan;
36+
}

scripts/layering/runtime-command-cutover-table.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,33 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [
523523
operationOwners: { setViewport: ['resolveBoundViewportRuntime'] },
524524
},
525525
},
526+
{
527+
rule: 'R38 wait-runtime-cutover',
528+
command: 'wait',
529+
subject: 'wait polling capture',
530+
tier: 'request-scoped',
531+
execution: 'device-runtime',
532+
legacyRetirement: {
533+
// The two Apple fast paths wait used to select by family and by provider, plus the
534+
// backend seam that existed only to carry the second of them.
535+
routeNames: [
536+
'dispatchDirectIosSelectorWait',
537+
'findTextWithAppleRunner',
538+
'findTextInMacosNonAppSurface',
539+
'readAppleRunnerFindTextTarget',
540+
'buildAppleRunnerFindTextOptions',
541+
'captureWaitSnapshot',
542+
'BackendFindTextResult',
543+
],
544+
},
545+
runtimeTypeNames: ['SnapshotRuntimeOperations'],
546+
operations: { names: ['captureSnapshot'] },
547+
singularExecution: {
548+
routes: ['handleSnapshotCommands'],
549+
operations: ['captureSnapshot'],
550+
operationOwners: { captureSnapshot: ['resolveBoundWaitCaptureRuntime'] },
551+
},
552+
},
526553
];
527554

528555
function snapshotRetiredDispatchProjectionProof(

src/__tests__/test-file-size-ratchet.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const TRIPWIRE_LINES = 1_000;
3434
// Exact current lengths. Lower a pin when its file shrinks; never raise one — extract instead.
3535
const PINNED_TEST_FILE_LINES: Readonly<Record<string, number>> = Object.freeze({
3636
'src/__tests__/remote-connection.test.ts': 2973,
37-
'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2654,
37+
'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2326,
3838
'src/commands/interaction/runtime/settle.test.ts': 2361,
3939
'src/platforms/apple/core/__tests__/runner-session.test.ts': 2083,
4040
'src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts': 2031,

src/backend.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,6 @@ export type BackendReadTextResult = {
6666
text: string;
6767
};
6868

69-
export type BackendFindTextResult = {
70-
found: boolean;
71-
};
72-
7369
export type BackendScreenshotOptions = {
7470
fullscreen?: boolean;
7571
overlayRefs?: boolean;
@@ -434,7 +430,6 @@ export type AgentDeviceBackend = {
434430
options?: BackendScreenshotOptions,
435431
): Promise<BackendScreenshotResult | void>;
436432
readText?(context: BackendCommandContext, node: SnapshotNode): Promise<BackendReadTextResult>;
437-
findText?(context: BackendCommandContext, text: string): Promise<BackendFindTextResult>;
438433
/**
439434
* #1542 off-screen refusal double-check: called ONLY at the moment the
440435
* shared off-screen interaction guard is about to REFUSE a click/tap/

src/commands/interaction/runtime/__tests__/test-utils/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,6 @@ export function createSelectorDevice(
559559
snapshot: SnapshotState,
560560
options: {
561561
readText?: string;
562-
findText?: boolean;
563562
now?: number;
564563
captureSnapshot?: () => BackendSnapshotResult | Promise<BackendSnapshotResult>;
565564
/**
@@ -584,7 +583,6 @@ export function createSelectorDevice(
584583
captureSnapshot: async () =>
585584
options.captureSnapshot ? await options.captureSnapshot() : { snapshot },
586585
readText: async () => ({ text: options.readText ?? '' }),
587-
findText: async () => ({ found: options.findText ?? false }),
588586
} satisfies AgentDeviceBackend,
589587
artifacts: createLocalArtifactAdapter(),
590588
sessions,

src/commands/interaction/runtime/selector-read.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -476,23 +476,22 @@ test('runtime find wait cancels and joins a capture that consumes its full deadl
476476
test('runtime selector convenience methods use explicit target helpers', async () => {
477477
const device = createSelectorDevice(selectorReadSnapshot(), {
478478
readText: 'Continue',
479-
findText: true,
480479
});
481480

482481
const text = await device.selectors.getText(selector('label=Continue'), { session: 'default' });
483482
const attrs = await device.selectors.getAttrs(ref('@e1'), { session: 'default' });
484483
const visible = await device.selectors.isVisible(selector('label=Continue'), {
485484
session: 'default',
486485
});
487-
const waited = await device.selectors.waitForText('Ready', {
486+
const waited = await device.selectors.waitForText('Continue', {
488487
session: 'default',
489488
timeoutMs: 100,
490489
});
491490

492491
assert.equal(text.kind, 'text');
493492
assert.equal(attrs.kind, 'attrs');
494493
assert.equal(visible.pass, true);
495-
assert.deepEqual(waited, { kind: 'text', text: 'Ready', waitedMs: 0 });
494+
assert.deepEqual(waited, { kind: 'text', text: 'Continue', waitedMs: 0 });
496495
});
497496

498497
// ---------------------------------------------------------------------------

src/commands/interaction/runtime/selector-wait.test.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,31 @@ test('runtime focused selector waits against a full snapshot', async () => {
5252
assert.equal(captureOptions?.interactiveOnly, false);
5353
});
5454

55-
test('runtime wait can use backend text search', async () => {
56-
const device = createSelectorDevice(selectorReadSnapshot(), {
57-
findText: true,
58-
now: 10,
59-
});
55+
// A text wait has exactly one source of truth: the polled capture. The backend `findText` seam
56+
// that short-circuited it on Apple was wait's second platform-execution path and retired with
57+
// wait's ADR 0019 cutover, so the tree answer is the only answer — in both directions.
58+
test('runtime wait resolves text from the polled snapshot', async () => {
59+
const device = createSelectorDevice(selectorReadSnapshot(), { now: 10 });
6060

6161
const result = await device.selectors.wait({
6262
session: 'default',
63-
target: { kind: 'text', text: 'Ready', timeoutMs: 100 },
63+
target: { kind: 'text', text: 'Continue', timeoutMs: 100 },
6464
});
6565

66-
assert.deepEqual(result, { kind: 'text', text: 'Ready', waitedMs: 0 });
66+
assert.deepEqual(result, { kind: 'text', text: 'Continue', waitedMs: 0 });
67+
});
68+
69+
test('runtime wait times out on text the polled snapshot does not carry', async () => {
70+
const device = createSelectorDevice(selectorReadSnapshot(), { clock: createFakeClock() });
71+
72+
await assert.rejects(
73+
async () =>
74+
await device.selectors.wait({
75+
session: 'default',
76+
target: { kind: 'text', text: 'Ready', timeoutMs: 100 },
77+
}),
78+
(error: Error) => error.message.includes('wait timed out for text: Ready'),
79+
);
6780
});
6881

6982
// ---------------------------------------------------------------------------

src/commands/interaction/runtime/selector-wait.ts

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ export type WaitForTextCommandOptions = WaitCommandContext &
118118
type SelectorWaitRuntime = {
119119
backend: {
120120
platform: PublicPlatform;
121-
findText?: (context: WaitCommandContext, text: string) => Promise<{ found: boolean }>;
122121
};
123122
clock?: {
124123
now(): number;
@@ -364,11 +363,9 @@ async function waitForText<Runtime extends SelectorWaitRuntime>(
364363
const polling = createWaitPolling(runtime, options, timeoutMs, SELECTOR_PIPELINE_POLICIES.wait);
365364
let deadline: WaitPollDeadline | undefined;
366365
while (polling.hasTimeRemaining()) {
367-
const poll = await polling.capture(async (signal) =>
368-
runtime.backend.findText
369-
? (await runtime.backend.findText(backendContext(runtime, { ...options, signal }), text))
370-
.found
371-
: await snapshotContainsText(operations, runtime, { ...options, signal }, text),
366+
const poll = await polling.capture(
367+
async (signal) =>
368+
await snapshotContainsText(operations, runtime, { ...options, signal }, text),
372369
);
373370
if (poll.timedOut) {
374371
deadline = poll.deadline;
@@ -436,15 +433,3 @@ async function waitForStable<Runtime extends SelectorWaitRuntime>(
436433
: {}),
437434
};
438435
}
439-
440-
function backendContext(
441-
runtime: SelectorWaitRuntime,
442-
options: WaitCommandContext,
443-
): WaitCommandContext {
444-
return {
445-
session: options.session,
446-
requestId: options.requestId,
447-
signal: options.signal ?? runtime.signal,
448-
metadata: options.metadata,
449-
};
450-
}

src/core/__tests__/capability-plugin-routing-parity.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,6 @@ test('HarmonyOS static capabilities omit runtime-backed command admissions', ()
282282
'settings',
283283
'swipe',
284284
'type',
285-
'wait',
286285
]);
287286
});
288287

0 commit comments

Comments
 (0)