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
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { AppError } from '@agent-device/kernel/errors';
import { runScrollEdgePasses, runScrollPasses } from '../scroll-edge-state.ts';

type Frame = { readable: boolean; done: boolean; fingerprint?: string };

const REFUSED = new AppError('COMMAND_FAILED', 'refused', { reason: 'refused' });
const PASS_LIMIT = new AppError('COMMAND_FAILED', 'limit', { reason: 'limit' });
const NO_PROGRESS = new AppError('COMMAND_FAILED', 'stuck', { reason: 'stuck' });

/** Drives the shared loop over scripted frames, counting what it asked for. */
async function run(frames: Frame[], passLimit = 10) {
const calls = { observe: 0, shouldStop: 0, scroll: 0, settle: 0 };
const outcome = runScrollPasses({
observe: async () => frames[Math.min(calls.observe++, frames.length - 1)]!,
refuse: (frame) => (frame.readable ? undefined : REFUSED),
shouldStop: async (frame) => {
calls.shouldStop += 1;
return frame.done;
},
passLimit,
passLimitError: () => PASS_LIMIT,
fingerprint: async (frame) => frame.fingerprint,
noProgressError: () => NO_PROGRESS,
scroll: async () => ({ pass: ++calls.scroll }),
settleAfterPass: async () => {
calls.settle += 1;
},
});
return { outcome, calls };
}

test('runScrollPasses: a stop on the first observation costs no gesture', async () => {
const { outcome, calls } = await run([{ readable: true, done: true }]);
assert.deepEqual(await outcome, { passes: 0, result: undefined });
assert.equal(calls.scroll, 0);
});

test('runScrollPasses: scrolls and settles once per pass and reports the last gesture', async () => {
const { outcome, calls } = await run([
{ readable: true, done: false, fingerprint: 'a' },
{ readable: true, done: false, fingerprint: 'b' },
{ readable: true, done: true },
]);
assert.deepEqual(await outcome, { passes: 2, result: { pass: 2 } });
assert.equal(calls.settle, 2);
});

test('runScrollPasses: refuses an unreadable observation before asking the stop condition', async () => {
const { outcome, calls } = await run([{ readable: false, done: true }]);
await assert.rejects(outcome, (error) => error === REFUSED);
assert.equal(calls.shouldStop, 0);
assert.equal(calls.scroll, 0);
});

test('runScrollPasses: refuses an observation that goes unreadable after a pass', async () => {
const { outcome, calls } = await run([
{ readable: true, done: false, fingerprint: 'a' },
{ readable: false, done: true },
]);
await assert.rejects(outcome, (error) => error === REFUSED);
assert.equal(calls.scroll, 1);
});

test('runScrollPasses: spends exactly its pass budget, then raises the caller error', async () => {
const budget = 3;
const frames = Array.from({ length: budget + 1 }, (_, index) => ({
readable: true,
done: false,
fingerprint: `f${index}`,
}));
const { outcome, calls } = await run(frames, budget);
await assert.rejects(outcome, (error) => error === PASS_LIMIT);
assert.equal(calls.scroll, budget);
});

/**
* The budget is checked before the stuck window, so an explicit small budget reports the budget
* rather than no-progress (the order `scroll --until` documented before the loops were shared).
*/
test('runScrollPasses: a surface that never shifts stops as no-progress, the budget winning ties', async () => {
const stuck = { readable: true, done: false, fingerprint: 'same' };
const unbounded = await run([stuck], 10);
await assert.rejects(unbounded.outcome, (error) => error === NO_PROGRESS);
assert.equal(unbounded.calls.scroll, 3);

const bounded = await run([stuck], 3);
await assert.rejects(bounded.outcome, (error) => error === PASS_LIMIT);
});

test('runScrollPasses: an observation without a fingerprint never trips no-progress', async () => {
const { outcome, calls } = await run([{ readable: true, done: false }], 5);
await assert.rejects(outcome, (error) => error === PASS_LIMIT);
assert.equal(calls.scroll, 5);
});

test('runScrollEdgePasses: a refused capture raises the typed edge refusal before any gesture', async () => {
let scrolls = 0;
await assert.rejects(
runScrollEdgePasses({
edge: 'bottom',
captureState: async () => ({
refused: { reason: 'sparse-tree', detail: 'AX bridge unavailable' },
}),
scroll: async () => {
scrolls += 1;
},
}),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.details?.reason, 'scroll_edge_capture_unreadable');
assert.equal(error.details?.edge, 'bottom');
assert.equal(error.details?.captureRefusal, 'sparse-tree');
assert.match(error.message, /AX bridge unavailable/);
return true;
},
);
assert.equal(scrolls, 0);
});
143 changes: 112 additions & 31 deletions packages/capture-kit/src/snapshot/scroll-edge-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ export type ScrollEdgeState = {
fingerprint?: string;
};

/**
* Why a capture cannot answer a scroll loop's question at all. Never an outcome about the content:
* a failed or sparse read coerced to an empty tree is what the edge analyzer reads as "no hidden
* content", so it has to be refused before anything decides on it.
*/
export type ScrollCaptureRefusal = { reason: 'no-capture' | 'sparse-tree'; detail: string };

/** What one edge capture yields: an analyzed state, or the refusal that kept it from being read. */
export type ScrollEdgeObservation = ScrollEdgeState | { refused: ScrollCaptureRefusal };

function isScrollEdgeRefused(
observation: ScrollEdgeObservation,
): observation is { refused: ScrollCaptureRefusal } {
return 'refused' in observation;
}

export type ScrollEdgeTarget = {
point?: Point;
nodeIndex?: number;
Expand Down Expand Up @@ -130,10 +146,10 @@ export function scrollSurfaceIsStuck(recentSignatures: readonly string[]): boole
/**
* Records one pass's surface signature, capped to the trailing window the stuck detector reads. An
* empty signature is skipped so a container with no positioned descendants stays on its edge/pass
* signals instead of stacking blanks. Both scroll loops push through here so "one window" has one
* definition rather than a copy that can drift.
* signals instead of stacking blanks. `runScrollPasses` is its one caller, so every scroll loop
* reads the same window.
*/
export function pushScrollSurfaceSignature(
function pushScrollSurfaceSignature(
recentSignatures: string[],
fingerprint: string | undefined,
window: number,
Expand All @@ -143,9 +159,24 @@ export function pushScrollSurfaceSignature(
if (recentSignatures.length > window) recentSignatures.shift();
}

export async function runScrollEdgePasses<TResult>(params: {
edge: ScrollEdge;
captureState: (scope?: string) => Promise<ScrollEdgeState>;
/**
* The one scroll pass loop: observe, refuse an unreadable observation, ask the caller's stop
* condition, then spend a pass. `scroll top`/`scroll bottom` and `scroll --until` differ only in
* what stops them and what they call a failure, so every other rule lives here once: the refusal
* lands before the stop condition reads anything, the budget is checked before the stuck window so
* an explicit small budget still reports itself, and the first observation precedes the first
* gesture so an already-satisfied stop costs no scroll.
*/
export async function runScrollPasses<TObservation, TResult>(params: {
observe: () => Promise<TObservation>;
refuse: (observation: TObservation) => AppError | undefined;
/** True ends the loop with this pass count; a terminal outcome of its own throws instead. */
shouldStop: (observation: TObservation, passes: number) => Promise<boolean>;
passLimit: number;
passLimitError: (passes: number) => AppError;
/** The surface signature the stuck window tracks; `undefined` keeps a pass out of it. */
fingerprint: (observation: TObservation) => Promise<string | undefined>;
noProgressError: (passes: number) => AppError;
scroll: () => Promise<TResult>;
/**
* Waits for the previous fling to come to rest before the next capture. A rubber-banded scroll
Expand All @@ -155,19 +186,61 @@ export async function runScrollEdgePasses<TResult>(params: {
*/
settleAfterPass?: () => Promise<void>;
}): Promise<{ passes: number; result?: TResult }> {
const { edge, captureState, scroll, settleAfterPass = async () => {} } = params;
let state = await captureState();
if (state.scope) {
state = await captureState(state.scope);
}

const { observe, refuse, shouldStop, passLimit, scroll, settleAfterPass } = params;
let passes = 0;
let result: TResult | undefined;
const recentSignatures: string[] = [];
pushScrollSurfaceSignature(recentSignatures, state.fingerprint, SCROLL_EDGE_STUCK_WINDOW);
while (state.canScroll) {
if (passes >= SCROLL_EDGE_PASS_LIMIT) {
throw new AppError(
while (true) {
const observation = await observe();
const refusal = refuse(observation);
if (refusal) throw refusal;
if (await shouldStop(observation, passes)) return { passes, result };
if (passes >= passLimit) throw params.passLimitError(passes);
pushScrollSurfaceSignature(
recentSignatures,
await params.fingerprint(observation),
SCROLL_EDGE_STUCK_WINDOW,
);
if (scrollSurfaceIsStuck(recentSignatures)) throw params.noProgressError(passes);
result = await scroll();
passes += 1;
await settleAfterPass?.();
}
}

export async function runScrollEdgePasses<TResult>(params: {
edge: ScrollEdge;
captureState: (scope?: string) => Promise<ScrollEdgeObservation>;
scroll: () => Promise<TResult>;
/** The rest-wait, handed the scope the loop is deciding on so it watches the same container. */
settleAfterPass?: (scope: string | undefined) => Promise<void>;
}): Promise<{ passes: number; result?: TResult }> {
const { edge, captureState, scroll, settleAfterPass } = params;
// The first capture discovers the container; every later one reads the scope the previous state
// named, so the loop verifies against the scroller it decided on rather than the whole tree.
let scope: string | undefined;
let discovered = false;
const observe = async (): Promise<ScrollEdgeObservation> => {
let observation = await captureState(scope);
if (!discovered) {
discovered = true;
if (!isScrollEdgeRefused(observation) && observation.scope) {
observation = await captureState(observation.scope);
}
}
if (!isScrollEdgeRefused(observation)) scope = observation.scope;
return observation;
};
return await runScrollPasses({
observe,
refuse: (observation) =>
isScrollEdgeRefused(observation)
? buildScrollEdgeCaptureError(edge, observation.refused)
: undefined,
shouldStop: async (observation) => !isScrollEdgeRefused(observation) && !observation.canScroll,
passLimit: SCROLL_EDGE_PASS_LIMIT,
passLimitError: (passes) =>
new AppError(
'COMMAND_FAILED',
`scroll ${edge} reached the safety limit before the snapshot showed the edge`,
{
Expand All @@ -176,21 +249,13 @@ export async function runScrollEdgePasses<TResult>(params: {
passes,
hint: 'The scoped scroll container still reports hidden content. Run scroll <dir> --until <selector> to stop on the element you are after, or snapshot -i to inspect the current state.',
},
);
}

result = await scroll();
passes += 1;
await settleAfterPass();
state = await captureState(state.scope);

pushScrollSurfaceSignature(recentSignatures, state.fingerprint, SCROLL_EDGE_STUCK_WINDOW);
if (state.canScroll && scrollSurfaceIsStuck(recentSignatures)) {
throw buildScrollEdgeNoProgressError(edge, passes);
}
}

return { passes, result };
),
fingerprint: async (observation) =>
isScrollEdgeRefused(observation) ? undefined : observation.fingerprint,
noProgressError: (passes) => buildScrollEdgeNoProgressError(edge, passes),
scroll,
...(settleAfterPass ? { settleAfterPass: async () => await settleAfterPass(scope) } : {}),
});
}

/**
Expand Down Expand Up @@ -278,6 +343,22 @@ export function scrollNoProgressHint(
);
}

function buildScrollEdgeCaptureError(edge: ScrollEdge, refusal: ScrollCaptureRefusal): AppError {
return new AppError(
'COMMAND_FAILED',
`scroll ${edge} could not read the screen: ${refusal.detail}`,
{
reason: 'scroll_edge_capture_unreadable',
edge,
captureRefusal: refusal.reason,
hint:
refusal.reason === 'no-capture'
? 'Run snapshot -i to see whether the app is producing an accessibility tree at all, and retry once it does.'
: 'The accessibility tree came back sparse, so it cannot show whether content remains. Run screenshot, inspect the image, and navigate by coordinates until snapshot -i reports a full tree.',
},
);
}

function buildScrollEdgeVerificationError(
edge: ScrollEdge,
scope: string | undefined,
Expand Down
Loading
Loading