From db790279989297fc83fc61c4ff7fb649d84c0203 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 6 Oct 2026 17:07:45 +0100 Subject: [PATCH] test(street-level): fail only unexplained panel-press misses in CI CI on fb024d5 failed step 24 with QA_FAIL_ON_RETRY=1: the press after a double-click dock landed on the globe because the panel, showing a photo that was still settling, moved 116 px up and lost 46 px of height between the pre-press hit check and the press (box 1058,382 330x471 -> 1058,266 330x425, from the new diagnostics and the uploaded screenshot). That is the panel's own content changing its layout, not the rail race fixed in 9dde7b4. - Fail-on-retry now fails a miss only when the panel's box was the same at the hit check and at the press (the pointer missed a header that stood still); a miss the panel moving explains is retried with a ::warning:: naming the move. New harness test with a moving panel. - Step 24 waits for the open photo and its sequence to finish loading before pressing the header again. Verified: strict fixture gate on a production build 28 ok + 2 "no Google 3D" skips and the panel gate 6/6, both with QA_FAIL_ON_RETRY=1. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 +++-- scripts/qa-panelDrag.mjs | 16 +++++++++++++-- scripts/qa-street-level.mjs | 3 +++ src/tooling/streetLevelQa.test.mjs | 31 ++++++++++++++++++++++++++++-- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0707c8d20..4bcd159bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,8 +73,9 @@ jobs: timeout-minutes: 30 env: GEV_REQUIRE_ALLOCATION_GATE: '0' - # A header press that misses is a real layout shift under the pointer - # (the right rail now aligns to where the left rail settles), not noise. + # A header press that misses while the panel stood still is a failure + # (the right rail now aligns to where the left rail settles); a miss the + # panel's own layout change explains is retried with a ::warning::. QA_FAIL_ON_RETRY: '1' steps: - name: Check out source diff --git a/scripts/qa-panelDrag.mjs b/scripts/qa-panelDrag.mjs index 20ea40f3b..37a9c6d6f 100644 --- a/scripts/qa-panelDrag.mjs +++ b/scripts/qa-panelDrag.mjs @@ -12,7 +12,11 @@ * Every retry is logged with what led to it (the pre-press hit check, the * stillness wait, the panel box at the hit check and at the press) and as a * GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 (or `--fail-on-retry`) a - * retry fails the run instead. + * miss the panel's own movement cannot explain fails the run instead: if its + * box was the same at the hit check and at the press, the pointer missed a + * header that stood still. A panel whose content changed its layout between + * the two (a photo finishing loading, a live count wrapping a line) is + * retried with a warning that names the move. */ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -41,6 +45,14 @@ export function pressMissed(attempt) { } /** The panel's box, rounded, as `left,top widthxheight`. */ +/** Whether the panel's box changed (by more than 2 px) between two reads. */ +export function panelMoved(before, after) { + if (!before || !after) return false; + return ['left', 'top', 'width', 'height'].some( + (key) => Math.abs((after[key] ?? NaN) - (before[key] ?? NaN)) > 2, + ); +} + export function formatBox(box) { if (!box) return 'unknown'; const r = (value) => Math.round(value ?? NaN); @@ -268,7 +280,7 @@ async function pressUntilDecided( retries, extra: note ? await note() : '', }); - if (failOnRetry) + if (failOnRetry && !panelMoved(box, attempt.pressed)) throw new Error( `QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`, ); diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 953094167..df63a72ec 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -1660,6 +1660,9 @@ async function main() { await step( 'collapsing a floating window docks it as the rail strip', async () => { + // The double-click just docked a panel showing a photo: let the photo + // and its sequence finish loading before pressing the header again. + await uiUntil((u) => !u.street.loading && !u.sequence.loading); await floatPanel(); await page.click( '.panel-collapse-btn[data-collapse-target="street-level-panel"]', diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index 0053f08f5..863b6f019 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -134,7 +134,7 @@ test('the gate requires the server’s real Mapillary status route', async () => * panel and a header double-click docks it, unless `stuck`. */ function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) { - const state = { floating, downs: 0, last: null, dragging: false }; + const state = { floating, downs: 0, last: null, dragging: false, shift: 0 }; const listeners = []; const node = (tagName, id, classes, inHeader) => ({ tagName, @@ -154,7 +154,7 @@ function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) { const document = { querySelector: () => ({ getBoundingClientRect: rect(120, 210, 100, 20) }), getElementById: () => ({ - getBoundingClientRect: rect(100, 200, 300, 400), + getBoundingClientRect: () => rect(100, 200 + state.shift, 300, 400)(), classList: { contains: (name) => name === 'panel-floating' && state.floating, }, @@ -180,7 +180,10 @@ function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) { // 'none': the pointerdown never reaches the page. if (state.last === 'none') return; const target = state.last === 'header' ? header : other; + // 'moved': the panel's layout shifted just before the press landed. + if (state.last === 'moved') state.shift = -116; for (const listener of listeners.splice(0)) inPage(listener, { target }); + state.shift = 0; }; return { state, @@ -563,6 +566,30 @@ test('QA_FAIL_ON_RETRY turns a retry into a failure that says why', async () => assert.equal(page.state.downs, 1); }); +test('QA_FAIL_ON_RETRY retries, with a warning, a miss the panel moving explains', async () => { + const { liftPanelByHeader, panelMoved } = + await import('../../scripts/qa-panelDrag.mjs'); + assert.equal(panelMoved({ left: 0, top: 382 }, { left: 0, top: 266 }), true); + assert.equal(panelMoved({ left: 0, top: 382 }, { left: 1, top: 383 }), false); + const page = fakePanelPage({ presses: ['moved', 'header'] }); + const lines = []; + const attempt = await liftPanelByHeader(page, 'street-level-panel', { + dx: -400, + dy: 80, + log: (line) => lines.push(line), + pauseMs: 0, + failOnRetry: true, + }); + assert.equal(attempt.done, true, 'the second press lifted it'); + assert.equal(page.state.downs, 2); + assert.ok( + lines.some((line) => + /::warning title=Panel press retried::.*-> at press 100,84/.test(line), + ), + lines.join('\n'), + ); +}); + test('both gates save failure evidence and fail on render-loop errors', () => { for (const script of ['qa-street-level.mjs', 'qa-panel-resize.mjs']) { const source = fs.readFileSync(