diff --git a/src/daemon/__tests__/post-gesture-stabilization.test.ts b/src/daemon/__tests__/post-gesture-stabilization.test.ts index 5b65f79af..f60417ee5 100644 --- a/src/daemon/__tests__/post-gesture-stabilization.test.ts +++ b/src/daemon/__tests__/post-gesture-stabilization.test.ts @@ -158,6 +158,102 @@ test('capturePostGestureStabilizedResult keeps polling past the normal deadline assert.ok(captureCount > 8, `expected sustained polling, saw ${captureCount} captures`); }); +test('a backend flip mid-poll still yields the no-effect claim (#1620)', async () => { + // The screens this warning exists for are the hostile ones — #1600's + // element-18 was a Bluesky feed — and those are exactly the screens whose + // capture plan falls back mid-sequence. The loop already handles that: on a + // backend change it REBASES (#1569) and keeps polling against a comparable + // pair. The corroboration then read `pending.baselineSignature` instead, i.e. + // the pre-gesture signature from the OTHER backend, so set equality could + // never hold and the claim was vetoed on every fallback. + vi.useFakeTimers(); + const session = makeSession('ios'); + // Pre-gesture baseline captured by the TREE backend. The two backends do not + // agree on which nodes exist — that disagreement is the entire premise of + // #1569 — so private-ax additionally reports a scrolled-away row the tree + // backend prunes. Same screen, different view of it. + session.snapshot = makeSnapshotState(pickupSnapshot(500).nodes, { + snapshotQuality: { state: 'healthy', backend: 'tree' }, + }); + markPostGestureStabilization(session, 'scroll', ['up']); + + // Every post-gesture capture comes from private-ax: the penalty armed during + // the gesture. They are byte-identical to EACH OTHER — the gesture genuinely + // moved nothing — while differing from the tree baseline by that extra row. + const privateAxNodes = [ + ...pickupSnapshot(500).nodes, + { + index: 2, + parentIndex: 0, + type: 'StaticText', + identifier: 'scrolled-away-row', + label: 'Above the fold', + rect: { x: 20, y: -80, width: 200, height: 44 }, + }, + ]; + const capture = vi.fn(async () => + makeSnapshotState(privateAxNodes, { + snapshotQuality: { state: 'recovered', backend: 'private-ax' }, + }), + ); + + const resultPromise = withDiagnosticsScope( + {}, + async () => + await capturePostGestureStabilizedResult({ + session, + capture, + readSnapshot: (snapshot) => snapshot, + }), + ); + await vi.advanceTimersByTimeAsync(10_000); + const result = await resultPromise; + + assert.equal( + result.gestureNoEffect?.action, + 'scroll', + 'a proven-inert gesture must still be reported after the capture backend falls back', + ); +}); + +test('no pre-gesture snapshot means no no-effect claim, even after a backend flip (#1622 P1)', async () => { + // `markPostGestureStabilization` records an EMPTY baseline when the session + // has no pre-gesture snapshot, and `[]` is truthy. Rebasing it on a backend + // flip would swap "no before-state" for a post-gesture capture, inventing the + // very evidence the claim is supposed to rest on — the loop would then agree + // with itself and report a gesture as inert with nothing to compare against. + vi.useFakeTimers(); + const session = makeSession('ios'); + session.snapshot = undefined; // nothing captured before the gesture + markPostGestureStabilization(session, 'scroll', ['up']); + + // Steady private-AX captures: quiet, self-consistent, and a different backend + // from the (absent) baseline, so the rebase branch is reached. + const capture = vi.fn(async () => + makeSnapshotState(pickupSnapshot(500).nodes, { + snapshotQuality: { state: 'recovered', backend: 'private-ax' }, + }), + ); + + const resultPromise = withDiagnosticsScope( + {}, + async () => + await capturePostGestureStabilizedResult({ + session, + capture, + readSnapshot: (snapshot) => snapshot, + }), + ); + await vi.advanceTimersByTimeAsync(10_000); + const result = await resultPromise; + + assert.equal( + result.gestureNoEffect, + undefined, + 'a no-effect claim needs a real pre-gesture baseline, never one the loop invented for itself', + ); +}); + test('a replaced list under fixed chrome now settles outright, and still claims no no-effect (#1601 P1, #1569)', async () => { // The reviewer's counterexample: a SUCCESSFUL scroll swapped every list cell // while the tab-bar chrome (discriminating, shared, unmoved) kept the diff --git a/src/daemon/post-gesture-stabilization.ts b/src/daemon/post-gesture-stabilization.ts index a740a1ab7..a7bfba445 100644 --- a/src/daemon/post-gesture-stabilization.ts +++ b/src/daemon/post-gesture-stabilization.ts @@ -214,7 +214,13 @@ export async function capturePostGestureStabilizedResult(params: { // not agree on which nodes exist, so this pair says nothing about the // gesture: adopt it as the baseline and keep going rather than concluding // from it (#1569). - if (baselineSignature && baselineBackend !== current.backend) { + // `?.length`, not truthiness: `markPostGestureStabilization` records an + // EMPTY signature when there was no pre-gesture snapshot, and `[]` is + // truthy. Rebasing that would replace "no before-state" with a + // post-gesture capture — inventing a baseline the gesture is then judged + // against, which `decidePostGestureStabilityVerdict` (guarding on + // `?.length`) had deliberately refused to do (#1622 review P1). + if (baselineSignature?.length && baselineBackend !== current.backend) { emitDiagnostic({ level: 'debug', phase: 'post_gesture_snapshot_baseline_rebased', @@ -239,7 +245,7 @@ export async function capturePostGestureStabilizedResult(params: { } clearPostGestureStabilization(session); emitPostGestureSettleDiagnostic(verdict, pending.action, attempts, elapsedMs); - return buildAcceptedStabilizedResult(verdict, pending, current); + return buildAcceptedStabilizedResult(verdict, pending, current, baselineSignature); } previous = current; } @@ -280,15 +286,26 @@ export function formatGestureNoEffectWarning(action: string, positionals: string * corroboration (`haveIdenticalDiscriminatingSurfaces`): the verdict alone is * subset-tolerant, and a successful scroll that replaced every list cell * under fixed chrome still reads accept-stale (#1601 review P1). + * + * Corroborates against the loop's CURRENT baseline, not `pending`'s original + * one (#1620). When the capture backend flips mid-poll the loop rebases — + * `pending`'s pre-gesture signature came from a backend that does not agree + * with this one about which nodes exist, so #1569 already ruled it out for the + * verdict. Reading it back here re-introduced exactly that comparison, and set + * equality across two backends never holds: the corroboration was guaranteed to + * veto on any screen whose capture plan fell back — which is every hostile + * screen, i.e. the ones the warning exists for (#1600's element-18 was a + * Bluesky feed). */ function buildAcceptedStabilizedResult( verdict: 'trust' | 'accept-stale', pending: NonNullable, current: CapturedSurface, + baselineSignature: InteractionSurfaceSignature | undefined, ): PostGestureStabilizedResult { const corroborated = verdict === 'accept-stale' && - haveIdenticalDiscriminatingSurfaces(pending.baselineSignature ?? [], current.signature); + haveIdenticalDiscriminatingSurfaces(baselineSignature ?? [], current.signature); if (!corroborated) return { value: current.value }; return { value: current.value,