Repository navigation
fix(settle): arm the dismissal confirmation on the producer's presented-surface fact #3570
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+587
−38
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; | ||
|
|
||
| /** | ||
| * One presented iOS `pageSheet`, in the two flavours a settle baseline can arrive as, plus the app | ||
| * screen a dismissal returns to. An interactive capture carries only the surface — the content | ||
| * underneath is not hittable, so it drops out of the projection (4 nodes on the iPhone 18 Pro sim, | ||
| * October 2026) — while a full read of the very same screen also reports the covered content (6 | ||
| * nodes). Arming the broad-transition confirmation must not depend on which flavour produced the | ||
| * baseline, which is what the caller that reads these asserts. | ||
| */ | ||
| export const PRESENTED_SHEET_INTERACTIVE_NODES: RawSnapshotNode[] = [ | ||
| { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, | ||
| { | ||
| index: 1, | ||
| parentIndex: 0, | ||
| type: 'StaticText', | ||
| label: 'Allow notifications?', | ||
| rect: { x: 10, y: 450, width: 370, height: 22 }, | ||
| }, | ||
| { | ||
| index: 2, | ||
| parentIndex: 0, | ||
| type: 'Button', | ||
| label: 'Continue', | ||
| rect: { x: 10, y: 600, width: 370, height: 44 }, | ||
| hittable: true, | ||
| }, | ||
| { | ||
| index: 3, | ||
| parentIndex: 0, | ||
| type: 'Button', | ||
| label: 'Cancel', | ||
| rect: { x: 10, y: 660, width: 370, height: 44 }, | ||
| hittable: true, | ||
| }, | ||
| ]; | ||
|
|
||
| export const PRESENTED_SHEET_FULL_NODES: RawSnapshotNode[] = [ | ||
| ...PRESENTED_SHEET_INTERACTIVE_NODES, | ||
| { | ||
| index: 4, | ||
| parentIndex: 0, | ||
| type: 'Other', | ||
| label: 'CoveredContent', | ||
| rect: { x: 0, y: 0, width: 390, height: 440 }, | ||
| }, | ||
| { | ||
| index: 5, | ||
| parentIndex: 0, | ||
| type: 'Button', | ||
| label: 'Open settings', | ||
| rect: { x: 10, y: 120, width: 370, height: 44 }, | ||
| }, | ||
| ]; | ||
|
|
||
| /** The app screen the sheet is dismissed from, which is what every settle capture then reads. */ | ||
| export const SHEET_DISMISSED_NODES: RawSnapshotNode[] = [ | ||
| { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, | ||
| { | ||
| index: 1, | ||
| parentIndex: 0, | ||
| type: 'StaticText', | ||
| label: 'Signed in as demo', | ||
| rect: { x: 10, y: 80, width: 200, height: 22 }, | ||
| }, | ||
| { | ||
| index: 2, | ||
| parentIndex: 0, | ||
| type: 'Button', | ||
| label: 'Home', | ||
| rect: { x: 10, y: 120, width: 370, height: 44 }, | ||
| hittable: true, | ||
| }, | ||
| { | ||
| index: 3, | ||
| parentIndex: 0, | ||
| type: 'TabBar', | ||
| label: 'Tab Bar', | ||
| rect: { x: 0, y: 800, width: 390, height: 44 }, | ||
| }, | ||
| ]; |
84 changes: 84 additions & 0 deletions
84
src/commands/interaction/runtime/settle-presented-surface.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { test } from 'vitest'; | ||
| import type { PresentedSurface, SnapshotState } from '@agent-device/kernel/snapshot'; | ||
| import { ref, selector } from './selector-read-utils.ts'; | ||
| import { createSettleDevice } from './__tests__/settle-device-fixtures.ts'; | ||
| import { | ||
| elementCatalogTabSnapshot, | ||
| elementSettingsTabSnapshot, | ||
| } from './stable-capture.fixtures.ts'; | ||
|
|
||
| // Each settle route hands the loop its own baseline, and the producer's presented-surface fact has | ||
| // to arrive with it (#3565): a tab switch from a screen read `absent` keeps the plain window, while | ||
| // the same broad replacement without that reading still pays the confirmation floor. The tab | ||
| // switch is the transition #3470 measured; budgets run on the injected clock. | ||
|
|
||
| function stamped(snapshot: SnapshotState, presentedSurface: PresentedSurface): SnapshotState { | ||
| return { ...snapshot, presentedSurface }; | ||
| } | ||
|
|
||
| /** A press that switches from the catalog tab to the settings tab, with the given baseline fact. */ | ||
| function tabSwitchDevice(params: { stored: PresentedSurface; preActionCaptureFails?: boolean }) { | ||
| const catalog = elementCatalogTabSnapshot(); | ||
| let captures = 0; | ||
| return createSettleDevice({ | ||
| stored: stamped(catalog, params.stored), | ||
| captureSnapshot: () => { | ||
| captures += 1; | ||
| if (captures === 1 && params.preActionCaptureFails) throw new Error('capture unavailable'); | ||
| return { | ||
| snapshot: | ||
| captures === 1 | ||
| ? stamped(catalog, params.stored) | ||
| : stamped(elementSettingsTabSnapshot(), 'absent'), | ||
| }; | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| function settingsTabRef(): string { | ||
| const node = elementCatalogTabSnapshot().nodes.find( | ||
| (candidate) => candidate.label === 'Settings', | ||
| ); | ||
| assert.ok(node?.ref); | ||
| return `@${node.ref}`; | ||
| } | ||
|
|
||
| const ROUTES = [ | ||
| ['a selector press', () => selector('label=Settings')], | ||
| ['an @ref press', () => ref(settingsTabRef())], | ||
| ] as const; | ||
|
|
||
| for (const [label, target] of ROUTES) { | ||
| test(`${label} carries an absent baseline into the loop: a tab switch keeps the plain window`, async () => { | ||
| const device = tabSwitchDevice({ stored: 'absent' }); | ||
|
|
||
| const result = await device.interactions.press(target(), { session: 'default', settle: {} }); | ||
|
|
||
| assert.equal(result.settle?.settled, true); | ||
| assert.ok(result.settle.waitedMs < 1_500, `paid the floor: ${result.settle.waitedMs}ms`); | ||
| }); | ||
|
|
||
| test(`${label} keeps the confirmation when the baseline's producer could not answer`, async () => { | ||
| const device = tabSwitchDevice({ stored: 'unknown' }); | ||
|
|
||
| const result = await device.interactions.press(target(), { session: 'default', settle: {} }); | ||
|
|
||
| assert.equal(result.settle?.settled, true); | ||
| assert.ok(result.settle.waitedMs >= 1_500, `lost the floor: ${result.settle.waitedMs}ms`); | ||
| }); | ||
| } | ||
|
|
||
| test('a point press whose evidence capture failed does not trust the stored fact', async () => { | ||
| // With no capture of its own, the baseline is the stored tree, which may predate a side effect | ||
| // this layer cannot see: its `absent` must not rule the confirmation out. | ||
| const device = tabSwitchDevice({ stored: 'absent', preActionCaptureFails: true }); | ||
|
|
||
| const result = await device.interactions.press( | ||
| { kind: 'point', x: 300, y: 820 }, | ||
| { session: 'default', settle: {} }, | ||
| ); | ||
|
|
||
| assert.equal(result.settle?.settled, true); | ||
| assert.ok(result.settle.waitedMs >= 1_500, `trusted a stored fact: ${result.settle.waitedMs}ms`); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.