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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/contracts/src/interaction-guarantees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ const SHARED_RESPONSE_CONSTRUCTION: GuaranteeEnforcement = {
const TAP_OUTCOME_NOT_OBSERVED_GAP: GuaranteeEnforcement = {
kind: 'waived',
reason:
'gap: the response reports the dispatch only; only opt-in --verify/--settle capture post-action evidence into it. The deferred marks set after dispatch (Android snapshot freshness after press/click, post-gesture stabilization when the request sets postGestureStabilization) are judged by the next capture, never in this response. An iOS Simulator open whose launch watch ended unobservable marks the next capture in that session to wait for a quiet surface (markPostOpenStabilization); that steadies where a target is resolved, not whether the tap landed. The iOS ambiguous-failure corroboration reconsiders only a thrown runner error.',
'gap: the response reports the dispatch only; only opt-in --verify/--settle capture post-action evidence into it. The deferred marks set after dispatch (Android snapshot freshness after press/click, post-gesture stabilization when the request sets postGestureStabilization) are judged by the next capture, never in this response. An iOS Simulator open that did not read the launched app (any postOpenObservation other than observable) marks the next capture in that session to wait for a quiet surface (markPostOpenStabilization); that steadies where a target is resolved, not whether the tap landed. The iOS ambiguous-failure corroboration reconsiders only a thrown runner error.',
trackingIssue: TAP_OUTCOME_NOT_OBSERVED_GAP_ISSUE,
};

Expand Down
17 changes: 12 additions & 5 deletions src/daemon/__tests__/deferred-interaction-outcome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,25 @@ test('an Apple open that could not observe its app marks stabilization without a
assert.equal(session.postGestureStabilization?.baselineSignature, undefined);
});

test('only an open that saw the launch still in flight marks stabilization', () => {
const quiet = (
['observable', 'probe-failed', 'app-unidentified', 'not-eligible', undefined] as const
test('every open verdict other than observable marks stabilization', () => {
const pending = (
[
'unobservable',
'probe-failed',
'app-unidentified',
'not-eligible',
'observable',
undefined,
] as const
).map((observation) => {
const session = makeSession('ios');
markPostOpenStabilization(session, observation);
return isPostGestureStabilizationPending(session);
});
const android = makeSession('android');
markPostOpenStabilization(android, 'unobservable');
markPostOpenStabilization(android, 'probe-failed');

assert.deepEqual(quiet, [false, false, false, false, false]);
assert.deepEqual(pending, [true, true, true, true, false, false]);
assert.equal(isPostGestureStabilizationPending(android), false);
});

Expand Down
12 changes: 7 additions & 5 deletions src/daemon/deferred-interaction-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,20 @@ function markPostGestureStabilization(
}

/**
* Called once an app open has published its session. `unobservable` means the launch was still in
* flight or no app process was found when the open returned, so the next capture runs the
* quiet-window loop before a selector resolves against a layout that may still be moving (#3354). No baseline: the pre-open tree is a
* different surface, so the open is never reported as having no effect.
* Called once an app open has published its session. A verdict other than `observable` means the
* open never read the launched app: the probe failed or could not identify it, the device has no
* observation path, or the app stayed unreadable. Nothing proved its layout at rest, so the next
* capture runs the quiet-window loop (#3354, #3367). An open that reports no verdict does not mark.
* No baseline: the pre-open tree is a different surface, so the open is never reported as having no
* effect.
*/
export function markPostOpenStabilization(
session: SessionState,
observation: PostOpenObservation | undefined,
): void {
if (!isApplePlatform(session.device.platform)) return;
if (!supportsPostGestureStabilization(session.device)) return;
if (observation !== 'unobservable') return;
if (observation === undefined || observation === 'observable') return;
session.postGestureStabilization = { action: 'open', positionals: [], markedAt: Date.now() };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,12 @@ test('a fresh iOS open that could not observe its app leaves the next capture to
expect(session?.postGestureStabilization?.action).toBe('open');
});

test('a fresh iOS open whose observation probe failed leaves the next capture to settle', async () => {
const session = await openReportingObservation('probe-failed-open', 'probe-failed');

expect(session?.postGestureStabilization?.action).toBe('open');
});

test('a fresh iOS open that observed its app leaves nothing pending', async () => {
const session = await openReportingObservation('observed-open', 'observable');

Expand Down
33 changes: 31 additions & 2 deletions test/integration/interaction-contract/daemon-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ import {
export const CONTRACT_APP = 'com.example.app';
const CONTRACT_DEVICE_ID = PROVIDER_SCENARIO_IOS_SIMULATOR.id;

const SETTLING_CAPTURE_READS = 2;
const SETTLING_CAPTURE_NODES = [
{
index: 0,
type: 'Application',
label: 'Example',
rect: { x: 0, y: 0, width: 400, height: 800 },
},
];

/**
* Provider-transcript harness for contract scenarios whose path involves the
* iOS runner (maestro-non-hittable-fallback) or that
Expand All @@ -32,7 +42,25 @@ export async function withIosContractDaemon(
run: (daemon: ProviderScenarioHarness, transcript: ProviderScenarioTranscript) => Promise<void>,
options: { saveScript?: boolean | string } = {},
): Promise<void> {
const transcript = createProviderTranscript(entries);
// The scripted open is not read back, so the first capture after it settles over a quiet pair of
// reads and pauses the direct selector path. A snapshot consumes that pair here, keeping each
// scenario's own conversation with the runner exactly as scripted.
const transcript = createProviderTranscript([
...Array.from({ length: SETTLING_CAPTURE_READS }, () =>
runnerSnapshotEntry(SETTLING_CAPTURE_NODES),
),
...entries,
]);
const scenarioTranscript: ProviderScenarioTranscript = {
get calls() {
return transcript.calls.slice(SETTLING_CAPTURE_READS);
},
get remaining() {
return transcript.remaining;
},
assertComplete: () => transcript.assertComplete(),
next: (command, request, scope) => transcript.next(command, request, scope),
};
const appleRunnerProvider = createAppleRunnerProviderFromTranscript(transcript, 'ios.runner');
const appleTool = createRecordingAppleToolProvider({
simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [
Expand All @@ -54,7 +82,8 @@ export async function withIosContractDaemon(
...(options.saveScript !== undefined ? { saveScript: options.saveScript } : {}),
});
assertRpcOk(open);
await run(daemon, transcript);
assertRpcOk(await daemon.callCommand('snapshot'));
await run(daemon, scenarioTranscript);
transcript.assertComplete();
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ function snapshotEntry(nodes: unknown[]): ProviderScenarioProviderEntry {
};
}

// The scripted open is not read back, so the first capture after it settles over a quiet pair of
// reads and the direct tap path stays paused until that capture has run.
const SETTLING_CAPTURE_ENTRIES = [
snapshotEntry([APPLICATION_NODE]),
snapshotEntry([APPLICATION_NODE]),
];

async function settleAfterOpen(daemon: ProviderScenarioHarness): Promise<void> {
assertRpcOk(await daemon.callCommand('snapshot'));
}

async function withDirectSelectorScenario(
transcript: ProviderScenarioTranscript,
run: (daemon: ProviderScenarioHarness) => Promise<void>,
Expand Down Expand Up @@ -77,19 +88,18 @@ async function withDirectSelectorScenario(
}

test('Provider-backed iOS selector wait captures directly and strips selectorChain', async () => {
const transcript = createProviderTranscript([
snapshotEntry([
APPLICATION_NODE,
{
index: 1,
parentIndex: 0,
type: 'Button',
label: 'Continue',
hittable: true,
rect: { x: 100, y: 300, width: 200, height: 44 },
},
]),
const continueSnapshot = snapshotEntry([
APPLICATION_NODE,
{
index: 1,
parentIndex: 0,
type: 'Button',
label: 'Continue',
hittable: true,
rect: { x: 100, y: 300, width: 200, height: 44 },
},
]);
const transcript = createProviderTranscript([continueSnapshot, continueSnapshot]);

await withDirectSelectorScenario(transcript, async (daemon) => {
const wait = await daemon.callCommand('wait', ['label="Continue"']);
Expand All @@ -100,18 +110,20 @@ test('Provider-backed iOS selector wait captures directly and strips selectorCha
});

test('Provider-backed iOS selector wait polls a fresh capture after a selector miss', async () => {
const loadingSnapshot = snapshotEntry([
APPLICATION_NODE,
{
index: 1,
parentIndex: 0,
type: 'Button',
label: 'Loading',
hittable: true,
rect: { x: 100, y: 300, width: 200, height: 44 },
},
]);
const transcript = createProviderTranscript([
snapshotEntry([
APPLICATION_NODE,
{
index: 1,
parentIndex: 0,
type: 'Button',
label: 'Loading',
hittable: true,
rect: { x: 100, y: 300, width: 200, height: 44 },
},
]),
loadingSnapshot,
loadingSnapshot,
snapshotEntry([
APPLICATION_NODE,
{
Expand All @@ -135,6 +147,7 @@ test('Provider-backed iOS selector wait polls a fresh capture after a selector m

test('Provider-backed integration maestro replay dispatch keeps runner AMBIGUOUS_MATCH without fallback', async () => {
const transcript = createProviderTranscript([
...SETTLING_CAPTURE_ENTRIES,
{
command: 'ios.runner.tap',
deviceId: DEVICE_ID,
Expand All @@ -154,6 +167,7 @@ test('Provider-backed integration maestro replay dispatch keeps runner AMBIGUOUS
]);

await withDirectSelectorScenario(transcript, async (daemon) => {
await settleAfterOpen(daemon);
const click = await daemon.callCommand('click', ['label="Continue"'], {
maestro: { allowNonHittableCoordinateFallback: true },
});
Expand All @@ -165,6 +179,7 @@ test(
'Provider-backed integration maestro replay dispatch keeps runner ELEMENT_OFFSCREEN without fallback',
async () => {
const transcript = createProviderTranscript([
...SETTLING_CAPTURE_ENTRIES,
{
command: 'ios.runner.tap',
deviceId: DEVICE_ID,
Expand All @@ -182,6 +197,7 @@ test(
]);

await withDirectSelectorScenario(transcript, async (daemon) => {
await settleAfterOpen(daemon);
const click = await daemon.callCommand('click', ['label="Continue"'], {
maestro: { allowNonHittableCoordinateFallback: true },
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ function snapshotEntry(nodes: unknown[]): ProviderScenarioProviderEntry {

test('Provider-backed integration press --verify returns post-action evidence digest', async () => {
const runnerTranscript = createProviderTranscript([
// snapshot -i to obtain refs
// snapshot -i to obtain refs; the first capture after open settles: one extra read
snapshotEntry(BEFORE_NODES),
snapshotEntry(BEFORE_NODES),
{
command: 'ios.runner.tap',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,13 @@ async function withPressReadinessDaemon(
transcript: ReturnType<typeof createProviderTranscript>,
) => Promise<void>,
): Promise<void> {
const runnerTranscript = createProviderTranscript(entries);
// The scripted open is not read back, so the first capture after it settles over a quiet pair of
// reads. A snapshot consumes that pair here, keeping the scenario's own captures one-for-one.
const runnerTranscript = createProviderTranscript([
snapshotEntry(APPLICATION_ONLY_NODES),
snapshotEntry(APPLICATION_ONLY_NODES),
...entries,
]);
const appleRunnerProvider = createAppleRunnerProviderFromTranscript(
runnerTranscript,
'ios.runner',
Expand All @@ -94,6 +100,7 @@ async function withPressReadinessDaemon(
async (daemon) => {
const open = await daemon.callCommand('open', [APP], { platform: 'ios', udid: DEVICE_ID });
assertRpcOk(open);
assertRpcOk(await daemon.callCommand('snapshot'));
await run(daemon, runnerTranscript);
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ function iosSimulatorTool(): { provider: AppleToolProvider } {
test('Provider-backed integration scroll down answers moved on the first post-gesture capture', async () => {
const runnerTranscript = createProviderTranscript([
snapshotEntry(screen(0, true)), // pre-scroll baseline
snapshotEntry(screen(0, true)), // the first capture after open settles: one extra read
scrollEntry(),
snapshotEntry(screen(-100, true)), // post-gesture: rows shifted into view on the first read
]);
Expand Down
14 changes: 9 additions & 5 deletions test/integration/provider-scenarios/settle-observation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ function tapEntry(x: number, y: number): ProviderScenarioProviderEntry {

test('Provider-backed integration press --settle returns the settled diff and fresh refs', async () => {
const runnerTranscript = createProviderTranscript([
// snapshot -i: issues refs
// snapshot -i: issues refs; the first capture after open settles: one extra read
snapshotEntry(BEFORE_NODES),
snapshotEntry(BEFORE_NODES),
// press label=Continue --settle: resolution capture, tap, settle captures
snapshotEntry(BEFORE_NODES),
Expand Down Expand Up @@ -296,8 +297,9 @@ test('Provider-backed integration never-settled press --settle does not issue di
},
];
const runnerTranscript = createProviderTranscript([
// press label=Continue --settle: resolution capture, tap, then a settle
// stream that never repeats itself, so the loop can only ever time out.
// press label=Continue --settle: resolution capture (the first capture after open settles: one extra read),
// tap, then a settle stream that never repeats itself, so the loop can only ever time out.
snapshotEntry(BEFORE_NODES),
snapshotEntry(BEFORE_NODES),
tapEntry(200, 322),
changingSnapshotEntry(loadingNodes),
Expand Down Expand Up @@ -356,7 +358,8 @@ test('Provider-backed integration never-settled press --settle does not issue di

test('Provider-backed integration modal-dismiss press --settle attaches the unchanged interactive tail', async () => {
const runnerTranscript = createProviderTranscript([
// snapshot -i: issues refs
// snapshot -i: issues refs; the first capture after open settles: one extra read
snapshotEntry(MODAL_BEFORE_NODES),
snapshotEntry(MODAL_BEFORE_NODES),
// press label=Cancel --settle: resolution capture, tap, settle captures.
// The dialog closes leaving Continue in place — a removals-only diff with
Expand Down Expand Up @@ -691,7 +694,8 @@ const FILL_SETTLED_NODES = [

test('Provider-backed integration fill --settle summoning the keyboard still attaches the unchanged interactive tail', async () => {
const runnerTranscript = createProviderTranscript([
// snapshot -i: issues refs
// snapshot -i: issues refs; the first capture after open settles: one extra read
snapshotEntry(FILL_BEFORE_NODES),
snapshotEntry(FILL_BEFORE_NODES),
// fill @e6 'hello' --settle: the ref resolves on the stored tree (no
// fresh capture), the runner types, then the settle loop captures. The
Expand Down
7 changes: 5 additions & 2 deletions test/integration/provider-scenarios/stale-ref-warning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ function tapEntry(x: number, y: number): ProviderScenarioProviderEntry {

test('Provider-backed integration iOS @refs reject after a selector press replaces the tree', async () => {
const runnerTranscript = createProviderTranscript([
// snapshot -i: issues refs to the client
// snapshot -i: issues refs to the client; the first capture after open settles: one extra read
snapshotEntry(),
snapshotEntry(),
// press label=Continue: selector resolution capture replaces the stored tree
snapshotEntry(),
Expand Down Expand Up @@ -149,7 +150,9 @@ test('Provider-backed integration iOS @refs reject after a selector press replac

test('Provider-backed iOS press rejects a stale ref after navigation', async () => {
const runnerTranscript = createProviderTranscript([
// snapshot -i: @e3 is Verify on the magic-code screen.
// snapshot -i: @e3 is Verify on the magic-code screen; the first capture after open settles:
// one extra read.
snapshotEntry(MAGIC_CODE_NODES),
snapshotEntry(MAGIC_CODE_NODES),
// press label=Back: the pre-action capture still sees that screen.
snapshotEntry(MAGIC_CODE_NODES),
Expand Down
4 changes: 3 additions & 1 deletion test/integration/provider-scenarios/versioned-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ function tapEntry(x: number, y: number): ProviderScenarioProviderEntry {

test('Provider-backed integration rejects stale pinned @refs and accepts current pins', async () => {
const runnerTranscript = createProviderTranscript([
// snapshot -i: issues the complete frame at the seeded generation g1
// snapshot -i: issues the complete frame at the seeded generation g1; the first capture after
// open settles: one extra read
snapshotEntry(),
snapshotEntry(),
// press @e2~s{g1}: pinned to the CURRENT frame — admitted, taps Cancel,
// and crosses the seam so the frame expires
Expand Down
2 changes: 1 addition & 1 deletion website/docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ agent-device fold open
- Android: `shutdown --platform android --device <avd-name>` stops a running emulator.
- `open [app|url] [url]` boots and activates the selected target when needed.
- `open <app> --wait <ms>` waits up to that budget for a device another session is holding instead of failing at once, then fails with `DEVICE_IN_USE` if the budget runs out. Only session contention is waited for: a device claim held by another workspace's daemon is never retriable and returns its recovery command immediately. The wait extends the command's timeout envelope, so a long budget does not need a longer `--timeout`. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device) for queuing behavior.
- On an iOS Simulator, `open` watches the launch for a bounded time. When the app was still unreadable when that watch ended (`timing.postOpenObservation: "unobservable"` in the open response), the next command that reads the screen keeps reading until two reads in a row agree, within a short bounded wait, before it acts. A `click` right after such an open then targets where the element comes to rest, not where it was mid-launch. If the screen is still moving after that wait, the command proceeds and its response warns that the surface was still changing after `open`. Any other `postOpenObservation` value, including `probe-failed`, leaves the next command reading the screen once.
- On an iOS Simulator, an `open` that launches an app tries to read it before returning, and reports the result as `timing.postOpenObservation`. When that value is present and is not `"observable"`, the next command that reads the screen keeps reading until two reads in a row agree, within a short bounded wait, before it acts. A `click` right after such an open then targets where the element comes to rest, not where it was mid-launch. If the screen is still moving after that wait, the command proceeds and its response warns that the surface was still changing after `open`. After an `observable` open, or an open that reports no `postOpenObservation`, the next command reads the screen once.
- `open <url>` deep links are supported on Android and iOS.
- On an iOS Simulator, handing the URL to the device is one bounded `simctl openurl`: when CoreSimulator never answers it, the open fails within 20 seconds with `details.reason: ios-simulator-openurl-timeout` and kills the child, instead of the request being cancelled around a still-running `openurl`. The Simulator may still have received the URL, so retry the open or answer the prompt with `alert accept`.
- `open <app> <url>` opens a deep link on iOS.
Expand Down
Loading