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 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-06 17:07:45 +01:00
1 parent fb024d582b
commit db79027998
4 files changed
+49 -6

No files matched your search

+3 -2
View File
@@ -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
+14 -2
View File
@@ -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}`,
);
+3
View File
@@ -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"]',
+29 -2
View File
@@ -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(