Skip to content
Draft
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
96 changes: 96 additions & 0 deletions src/daemon/__tests__/post-gesture-stabilization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 20 additions & 3 deletions src/daemon/post-gesture-stabilization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,13 @@ export async function capturePostGestureStabilizedResult<T>(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',
Expand All @@ -239,7 +245,7 @@ export async function capturePostGestureStabilizedResult<T>(params: {
}
clearPostGestureStabilization(session);
emitPostGestureSettleDiagnostic(verdict, pending.action, attempts, elapsedMs);
return buildAcceptedStabilizedResult(verdict, pending, current);
return buildAcceptedStabilizedResult(verdict, pending, current, baselineSignature);
}
previous = current;
}
Expand Down Expand Up @@ -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<T>(
verdict: 'trust' | 'accept-stale',
pending: NonNullable<SessionState['postGestureStabilization']>,
current: CapturedSurface<T>,
baselineSignature: InteractionSurfaceSignature | undefined,
): PostGestureStabilizedResult<T> {
const corroborated =
verdict === 'accept-stale' &&
haveIdenticalDiscriminatingSurfaces(pending.baselineSignature ?? [], current.signature);
haveIdenticalDiscriminatingSurfaces(baselineSignature ?? [], current.signature);
if (!corroborated) return { value: current.value };
return {
value: current.value,
Expand Down
Loading