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 src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ Snapshots and refs:
Anti-pattern: snapshot -i followed by snapshot -i | grep ..., or adding 2>/dev/null | jq ... before reading the raw command output.
Refs from the first snapshot remain valid until you press, click, fill, type, scroll, go back, wait for async UI, or otherwise change app state.
Pinned refs (@e12~s4, generation from refsGeneration or settle.refsGeneration) get exact staleness warnings instead of the coarse tree-changed one; plain refs stay valid input.
After a mutation, prefer a known selector/label directly (for example press 'label="Send"') because interaction commands refresh interactive state internally. If you need to discover a new control not shown by settle, use snapshot -i, or snapshot -i -s "Composer" when a stable container label/id can scope the refresh.
After a mutation, prefer a known selector/label directly (for example press 'label="Send"') because interaction commands refresh interactive state internally. A settled diff with no added refs (for example a modal dismiss) also lists an "unchanged interactive" tail of still-present refs, so check that before falling back. If you need to discover a new control not shown by settle or its tail, use snapshot -i, or snapshot -i -s "Composer" when a stable container label/id can scope the refresh.
If typing/fill opened the keyboard or changed layout and the next target has no stable selector, run snapshot -i, use the fresh ref, then verify with wait/find or diff snapshot -i.
For a targeted query, use find/get/is. If you truly need the full tree again, pass --force-full.
Off-screen summaries are scroll hints; use scroll, not swipe, then snapshot -i.
Expand Down
64 changes: 64 additions & 0 deletions src/commands/interaction/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,70 @@ describe('press CLI output', () => {
);
});

test('appends the unchanged interactive tail after a removals-only diff', () => {
const output = formatPress({
message: 'Tapped @e4 (100, 200)',
x: 100,
y: 200,
settle: {
settled: true,
waitedMs: 500,
diff: {
summary: { additions: 0, removals: 2, unchanged: 3 },
lines: [
{ kind: 'removed', text: '@e4 [button] "OK"' },
{ kind: 'removed', text: '@e5 [text] "Are you sure?"' },
],
},
tail: [
{ ref: 'e9', role: 'button', label: 'Add to cart' },
{ ref: 'e10', role: 'button' },
],
},
});

expect(output.text).toBe(
[
'Tapped @e4 (100, 200)',
'settled after 500ms: +0 -2 (~3 unchanged)',
'- @e4 [button] "OK"',
'- @e5 [text] "Are you sure?"',
'unchanged interactive (2):',
'= @e9 [button] "Add to cart"',
'= @e10 [button]',
].join('\n'),
);
});

test('marks the unchanged interactive tail as truncated', () => {
const output = formatPress({
message: 'Tapped @e4 (100, 200)',
x: 100,
y: 200,
settle: {
settled: true,
waitedMs: 500,
diff: {
summary: { additions: 0, removals: 1, unchanged: 20 },
lines: [{ kind: 'removed', text: '@e4 [button] "OK"' }],
},
tail: [{ ref: 'e9', role: 'button', label: 'Add to cart' }],
tailTruncated: true,
},
});

expect(output.text).toBe(
[
'Tapped @e4 (100, 200)',
'settled after 500ms: +0 -1 (~20 unchanged)',
'- @e4 [button] "OK"',
'unchanged interactive (1):',
'= @e9 [button] "Add to cart"',
'… more interactive elements not shown, use snapshot -i',
].join('\n'),
);
});

test('prints not-settled verdict without a dangling diff summary', () => {
const output = formatPress({
message: 'Tapped (278, 817)',
Expand Down
25 changes: 24 additions & 1 deletion src/commands/interaction/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ type SettleTextView = {
lines?: Array<{ kind?: string; text?: string }>;
truncated?: boolean;
};
tail?: Array<{ ref?: string; role?: string; label?: string }>;
tailTruncated?: boolean;
};

/**
Expand All @@ -72,7 +74,11 @@ type SettleTextView = {
function formatSettleText(settle: unknown): string {
if (!settle || typeof settle !== 'object') return '';
const view = settle as SettleTextView;
const parts = [formatSettleVerdict(view), ...formatSettleDiffLines(view.diff)];
const parts = [
formatSettleVerdict(view),
...formatSettleDiffLines(view.diff),
...formatSettleTailLines(view),
];
if (view.hint) parts.push(`hint: ${view.hint}`);
return `\n${parts.join('\n')}`;
}
Expand All @@ -85,6 +91,23 @@ function formatSettleDiffLines(diff: SettleTextView['diff']): string[] {
return lines;
}

// Unchanged interactive tail: only present when the diff's added lines
// carried zero refs (modal-dismiss/toast-only diff), so the settled tree's
// remaining actionable elements would otherwise be invisible.
function formatSettleTailLines(view: SettleTextView): string[] {
const tail = view.tail ?? [];
if (tail.length === 0) return [];
const lines = [`unchanged interactive (${tail.length}):`];
for (const entry of tail) {
const label = entry.label ? ` "${entry.label}"` : '';
lines.push(`= @${entry.ref ?? ''} [${entry.role ?? ''}]${label}`);
}
if (view.tailTruncated) {
lines.push('… more interactive elements not shown, use snapshot -i');
}
return lines;
}

function formatSettleVerdict(view: SettleTextView): string {
const verdict = view.settled === true ? 'settled' : 'not settled';
const summary = view.diff?.summary;
Expand Down
241 changes: 240 additions & 1 deletion src/commands/interaction/runtime/settle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from '../../../runtime.ts';
import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts';
import { ref, selector } from './selector-read.ts';
import { NEVER_SETTLED_HINT } from './settle.ts';
import { buildSettleTailEntries, NEVER_SETTLED_HINT } from './settle.ts';

// #1101 --settle: quiet-window settle loop composition on the interaction
// commands. Budgets are injected (fake clock) — no real waiting.
Expand Down Expand Up @@ -120,6 +120,9 @@ test('press --settle returns the settled diff and stores the settled tree', asyn
// The settled tree became the session snapshot: the diff's refs resolve.
const stored = (await device.sessions.get('default')) as { snapshot?: SnapshotState };
assert.equal(stored.snapshot?.nodes[0]?.label, 'Welcome!');
// Added lines already hand back a fresh target: the tail would be pure cost.
assert.equal(settle.tail, undefined);
assert.equal(settle.tailTruncated, undefined);
});

test('never-settling content returns settled: false without an actionable diff', async () => {
Expand Down Expand Up @@ -418,3 +421,239 @@ test('added lines win diff-budget slots over removals under truncation', async (
assert.equal(added.length, 10);
assert.ok(added.every((line) => line.ref !== undefined));
});

// Unchanged interactive refs tail: benchmarks showed a removals-only settled
// diff (modal dismiss, toast dismiss) leaves the next actionable target
// invisible — the diff has nothing added to hand back. The tail fills that
// gap from the settled tree itself.

function modalBeforeSnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Button',
label: 'Add to cart',
rect: { x: 10, y: 20, width: 100, height: 40 },
hittable: true,
},
{
index: 1,
depth: 0,
type: 'StaticText',
label: 'Price: $12',
rect: { x: 10, y: 80, width: 100, height: 20 },
},
{
index: 2,
depth: 0,
type: 'Button',
label: 'OK',
rect: { x: 10, y: 140, width: 100, height: 40 },
hittable: true,
},
{
index: 3,
depth: 0,
type: 'Button',
label: 'Cancel',
rect: { x: 10, y: 200, width: 100, height: 40 },
hittable: true,
},
{
index: 4,
depth: 0,
type: 'StaticText',
label: 'Are you sure?',
rect: { x: 10, y: 260, width: 100, height: 20 },
},
]);
}

// Modal dismissed: only the background elements survive, and every one of
// them matches a `modalBeforeSnapshot` line exactly (unchanged), so the diff
// carries zero additions.
function modalDismissedSnapshot(): SnapshotState {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Button',
label: 'Add to cart',
rect: { x: 10, y: 20, width: 100, height: 40 },
hittable: true,
},
{
index: 1,
depth: 0,
type: 'StaticText',
label: 'Price: $12',
rect: { x: 10, y: 80, width: 100, height: 20 },
},
]);
}

test('a removals-only settled diff (modal dismiss) attaches an unchanged interactive tail', async () => {
const before = modalBeforeSnapshot();
const after = modalDismissedSnapshot();
let captures = 0;
const device = createSettleDevice({
stored: before,
captureSnapshot: () => {
captures += 1;
return { snapshot: captures === 1 ? before : after };
},
});

const result = await device.interactions.press(selector('label=OK'), {
session: 'default',
settle: {},
});

const settle = result.settle;
assert.ok(settle);
assert.deepEqual(settle.diff?.summary, { additions: 0, removals: 3, unchanged: 2 });
assert.ok(!settle.diff?.lines.some((line) => line.kind === 'added'));
// The StaticText survives but is not interactive; only the hittable button
// makes the tail.
assert.deepEqual(settle.tail, [{ ref: 'e1', role: 'button', label: 'Add to cart' }]);
assert.equal(settle.tailTruncated, undefined);
});

test('the unchanged interactive tail excludes non-hittable and covered candidates', async () => {
// Every surviving node's attributes (hittable/blocked-relevant fields) must
// match a `before` line exactly, or it would read as an added line and
// suppress the tail entirely (see the trigger-condition test above).
const survivors = [
{
index: 0,
depth: 0,
type: 'Button',
label: 'Add to cart',
rect: { x: 10, y: 20, width: 100, height: 40 },
hittable: false,
},
{
index: 1,
depth: 0,
type: 'Button',
label: 'Wishlist',
rect: { x: 10, y: 80, width: 100, height: 40 },
hittable: true,
},
{
index: 2,
depth: 0,
type: 'Button',
label: 'Share',
rect: { x: 10, y: 140, width: 100, height: 40 },
hittable: true,
},
];
const before = makeSnapshotState([
...survivors,
{
index: 3,
depth: 0,
type: 'Button',
label: 'OK',
rect: { x: 10, y: 200, width: 100, height: 40 },
hittable: true,
},
{
index: 4,
depth: 0,
type: 'Button',
label: 'Cancel',
rect: { x: 10, y: 260, width: 100, height: 40 },
hittable: true,
},
]);
// Same attributes as `survivors`; `interactionBlocked` is not part of the
// comparable key, so marking Wishlist covered here does not flip it to
// "added".
const after = makeSnapshotState([
survivors[0]!,
{ ...survivors[1]!, interactionBlocked: 'covered' as const },
survivors[2]!,
]);
let captures = 0;
const device = createSettleDevice({
stored: before,
captureSnapshot: () => {
captures += 1;
return { snapshot: captures === 1 ? before : after };
},
});

const result = await device.interactions.press(selector('label=OK'), {
session: 'default',
settle: {},
});

const settle = result.settle;
assert.ok(settle);
assert.equal(settle.diff?.summary.additions, 0);
// Not hittable, then covered, are both excluded; only the plain hittable
// button remains.
assert.deepEqual(settle.tail, [{ ref: 'e3', role: 'button', label: 'Share' }]);
});

test('the unchanged interactive tail is capped with a truncation marker', async () => {
const buttons = (labelPrefix: string, count: number, offset = 0) =>
Array.from({ length: count }, (_, index) => ({
index: index + offset,
depth: 0,
type: 'Button',
label: `${labelPrefix} ${index}`,
rect: { x: 0, y: index * 40, width: 100, height: 40 },
hittable: true,
}));
// 25 surviving buttons plus 2 modal-only buttons that get removed.
const before = makeSnapshotState([...buttons('Row', 25), ...buttons('Modal', 2, 25)]);
const after = makeSnapshotState(buttons('Row', 25));
let captures = 0;
const device = createSettleDevice({
stored: before,
captureSnapshot: () => {
captures += 1;
return { snapshot: captures === 1 ? before : after };
},
});

const result = await device.interactions.press(selector('label="Modal 0"'), {
session: 'default',
settle: {},
});

const settle = result.settle;
assert.ok(settle);
assert.equal(settle.diff?.summary.additions, 0);
assert.equal(settle.tail?.length, 20);
assert.equal(settle.tailTruncated, true);
});

test('buildSettleTailEntries dedups candidates already carrying an excluded ref', () => {
const settledNodes = makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Button',
label: 'Add to cart',
rect: { x: 10, y: 20, width: 100, height: 40 },
hittable: true,
},
{
index: 1,
depth: 0,
type: 'Button',
label: 'Share',
rect: { x: 10, y: 80, width: 100, height: 40 },
hittable: true,
},
]).nodes;

const result = buildSettleTailEntries(settledNodes, new Set(['e1']));

assert.deepEqual(result.tail, [{ ref: 'e2', role: 'button', label: 'Share' }]);
});
Loading
Loading