mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
From the IC8 review of #768, each with tests that fail before: - P2: the expanded viewer is a real modal: while it is on screen every other <body> child is inert (only the ones it set are restored, on shrink, close and destroy, and when Clean View/recording/cockpit hide it). The voice pill and toasts are not clickable while it is expanded. - P2: the tile proxy follows redirects by hand, at most 3 hops, each one HTTPS on exactly https://tiles.mapillary.com without credentials; anything else is a 502 and the token never leaves that origin. - P2: the real dev and preview server smoke tests request /api/mapillary/status and a tile, and the browser gate first checks the server's real status route before any interception, so CI fails if the Mapillary plugin is not registered. - P2: both gates press panel headers through one shared helper (scripts/qa-panelDrag.mjs): re-resolve the header until it is under the pointer, record what the press hit, wait on the real floating/docked state, and retry only a press that provably missed the header. Verified: unit 5910 pass; fixture gate 17/17; panel gate 5/5; live gate with a real Mapillary token 22/22. Co-Authored-By: Claude Opus 5.5 <[email protected]>
239 lines
7.7 KiB
JavaScript
239 lines
7.7 KiB
JavaScript
/**
|
|
* Header press-and-verify for the browser QA gates that lift a rail panel
|
|
* into a floating window (header drag) and dock it again (header
|
|
* double-click). The rail animates panel heights, so a point read once can
|
|
* be under another panel by the time the pointer goes down. Each attempt
|
|
* re-resolves the header, presses only where the header really is, records
|
|
* what the press landed on, and waits for the panel's actual state. Only a
|
|
* provable miss (a press that landed outside the header) is retried; a press
|
|
* in the header that did not lift or dock is a real failure.
|
|
*/
|
|
|
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
/** Retries after the first attempt, and the pause before each. */
|
|
export const PRESS_RETRIES = 2;
|
|
const RETRY_PAUSE_MS = 1000;
|
|
|
|
/**
|
|
* Whether an attempt proves nothing about the panel: it did not reach the
|
|
* wanted state and the press demonstrably landed outside the header.
|
|
* @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt
|
|
*/
|
|
export function pressMissed(attempt) {
|
|
return !attempt.done && attempt.pressed?.inHeader !== true;
|
|
}
|
|
|
|
/** The centre of the panel's header title, read now. */
|
|
export function headerPoint(page, panelId) {
|
|
return page.evaluate((id) => {
|
|
const title = document.querySelector(`#${id} .panel-header .panel-title`);
|
|
const r = title.getBoundingClientRect();
|
|
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
}, panelId);
|
|
}
|
|
|
|
/** Name the element at a viewport point, and whether it is the panel's header. */
|
|
export function hitAt(page, point, panelId) {
|
|
return page.evaluate(
|
|
({ x, y, id }) => {
|
|
const node = document.elementFromPoint(x, y);
|
|
if (!node) return { target: 'nothing', inHeader: false };
|
|
return {
|
|
target: `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`,
|
|
inHeader: Boolean(node.closest(`#${id} .panel-header`)),
|
|
};
|
|
},
|
|
{ ...point, id: panelId },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The header point to press, re-resolved until it lands on the header (or the
|
|
* bounded wait runs out; the press then records the miss).
|
|
*/
|
|
export async function headerTarget(
|
|
page,
|
|
panelId,
|
|
{ tries = 20, intervalMs = 500 } = {},
|
|
) {
|
|
let point = await headerPoint(page, panelId);
|
|
let hit = await hitAt(page, point, panelId);
|
|
for (let i = 0; i < tries && !hit.inHeader; i++) {
|
|
await delay(intervalMs);
|
|
point = await headerPoint(page, panelId);
|
|
hit = await hitAt(page, point, panelId);
|
|
}
|
|
return { point, hit };
|
|
}
|
|
|
|
/**
|
|
* Best effort: wait until the panel's box (to the whole pixel) has held still
|
|
* for `stillMs`, so a rail still animating heights does not move the header
|
|
* between the hit check and the press. Never fails the run.
|
|
*/
|
|
export async function waitForStill(
|
|
page,
|
|
panelId,
|
|
{ stillMs = 300, timeout = 5000 } = {},
|
|
) {
|
|
try {
|
|
await page.waitForFunction(
|
|
(id, ms) => {
|
|
const r = document.getElementById(id).getBoundingClientRect();
|
|
const box = [r.left, r.top, r.width, r.height].map(Math.round).join();
|
|
const now = performance.now();
|
|
if (window.__qaStillBox !== box) {
|
|
window.__qaStillBox = box;
|
|
window.__qaStillSince = now;
|
|
}
|
|
return now - window.__qaStillSince >= ms;
|
|
},
|
|
{ polling: 100, timeout },
|
|
panelId,
|
|
stillMs,
|
|
);
|
|
} catch (error) {
|
|
if (error?.name !== 'TimeoutError') throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record what the next pointerdown lands on, with the panel's box at that
|
|
* moment, in `window.__qaPressBox` (null until a press arrives).
|
|
*/
|
|
export function recordNextPress(page, panelId) {
|
|
return page.evaluate((id) => {
|
|
window.__qaPressBox = null;
|
|
window.addEventListener(
|
|
'pointerdown',
|
|
(event) => {
|
|
const r = document.getElementById(id).getBoundingClientRect();
|
|
const target = event.target;
|
|
window.__qaPressBox = {
|
|
left: r.left,
|
|
top: r.top,
|
|
width: r.width,
|
|
// What the press landed on, so a failed press says why.
|
|
target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`,
|
|
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
|
|
};
|
|
},
|
|
{ capture: true, once: true },
|
|
);
|
|
}, panelId);
|
|
}
|
|
|
|
/** The press recorded by `recordNextPress`, or null. */
|
|
function readPress(page) {
|
|
return page.evaluate(() => window.__qaPressBox ?? null);
|
|
}
|
|
|
|
/** Wait (bounded) until the panel is, or is not, a floating window. */
|
|
export async function waitForFloating(
|
|
page,
|
|
panelId,
|
|
floating,
|
|
{ timeout = 3000 } = {},
|
|
) {
|
|
try {
|
|
await page.waitForFunction(
|
|
(id, want) =>
|
|
document.getElementById(id).classList.contains('panel-floating') ===
|
|
want,
|
|
{ polling: 50, timeout },
|
|
panelId,
|
|
floating,
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
if (error?.name !== 'TimeoutError') throw error;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Press at `from`, move by (dx, dy) in two legs, release. */
|
|
export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
|
|
await page.mouse.move(from.x, from.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps });
|
|
await page.mouse.move(from.x + dx, from.y + dy, { steps });
|
|
await page.mouse.up();
|
|
await delay(120);
|
|
}
|
|
|
|
/**
|
|
* Run `press` (one attempt at the header) until it reaches the wanted state,
|
|
* a press in the header fails to, or the retries run out.
|
|
*/
|
|
async function pressUntilDecided(
|
|
page,
|
|
panelId,
|
|
press,
|
|
{ retries, log, note, pauseMs = RETRY_PAUSE_MS },
|
|
) {
|
|
for (let round = 0; ; round++) {
|
|
await waitForStill(page, panelId);
|
|
await recordNextPress(page, panelId);
|
|
const { point, hit } = await headerTarget(page, panelId);
|
|
const done = await press(point);
|
|
const attempt = { point, hit, done, pressed: await readPress(page) };
|
|
if (round >= retries || !pressMissed(attempt)) return attempt;
|
|
const extra = note ? `; ${await note()}` : '';
|
|
log(
|
|
`note: the press missed the ${panelId} header (on ${attempt.pressed?.target ?? hit.target}${extra}); retrying`,
|
|
);
|
|
await delay(pauseMs);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lift a docked panel into a floating window with a header drag.
|
|
* @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>}
|
|
* `done` is whether the panel is floating; `pressed` names what the press
|
|
* landed on and the panel's box at that moment.
|
|
*/
|
|
export function liftPanelByHeader(
|
|
page,
|
|
panelId,
|
|
{ dx, dy, retries = PRESS_RETRIES, log = console.log, note, pauseMs } = {},
|
|
) {
|
|
return pressUntilDecided(
|
|
page,
|
|
panelId,
|
|
async (point) => {
|
|
await dragPointer(page, point, dx, dy);
|
|
return waitForFloating(page, panelId, true);
|
|
},
|
|
{ retries, log, note, pauseMs },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Dock a floating panel with a header double-click, waiting on the docked
|
|
* state rather than a fixed sleep. Same result shape as `liftPanelByHeader`;
|
|
* `done` is whether the panel is docked.
|
|
*/
|
|
export function dockPanelByDoubleClick(
|
|
page,
|
|
panelId,
|
|
{ retries = PRESS_RETRIES, log = console.log, note, pauseMs } = {},
|
|
) {
|
|
return pressUntilDecided(
|
|
page,
|
|
panelId,
|
|
async (point) => {
|
|
await page.mouse.click(point.x, point.y, { clickCount: 1 });
|
|
await page.mouse.click(point.x, point.y, { clickCount: 2 });
|
|
return waitForFloating(page, panelId, false);
|
|
},
|
|
{ retries, log, note, pauseMs },
|
|
);
|
|
}
|
|
|
|
/** One line naming where an attempt pressed, for an assertion message. */
|
|
export function describePress(attempt) {
|
|
const { point, hit, pressed } = attempt;
|
|
return `pressed ${Math.round(point.x)},${Math.round(point.y)} on ${pressed?.target ?? `${hit.target} (no press recorded)`}, in header: ${pressed?.inHeader ?? hit.inHeader}`;
|
|
}
|