Files
gods-eye-view/scripts/qa-panel-resize.mjs
T
Said TurkseverandClaude Opus 5.5 038d8facc2 fix(street-level): modal viewer, pinned redirects, real-route CI checks
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]>
2026-10-06 15:16:57 +01:00

384 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* Prove the portable CCTV panel in a real browser: a header drag lifts it out
* of the right rail, every resize handle moves only its own edges, minimum
* sizes hold, the window survives a reload and a header double-click snaps
* it back and forgets the stored position.
*/
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js';
import {
describePress,
dockPanelByDoubleClick,
dragPointer,
liftPanelByHeader,
} from './qa-panelDrag.mjs';
export const PANEL_ID = 'cctv-panel';
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
export const MIN_SIZE = { width: 300, height: 160 };
const VIEWPORT = { width: 1400, height: 900 };
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
/** Box edges a resize in `dir` must leave exactly where they were. */
export function pinnedEdges(dir) {
return Object.entries(EDGE_BY_SIDE)
.filter(([side]) => !dir.includes(side))
.map(([, edge]) => edge);
}
/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */
export function growthDelta(dir, amount) {
return {
dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0,
dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0,
};
}
/**
* Viewport point inside the handle for `dir`, given the panel's client rect.
* Edge strips straddle the border by 3px; corners and the grip sit inside.
*/
export function handlePoint(rect, dir) {
const inset = dir.length === 2 ? 4 : 1;
const x = dir.includes('w')
? rect.left + inset
: dir.includes('e')
? rect.right - inset
: rect.left + rect.width / 2;
const y = dir.includes('n')
? rect.top + inset
: dir.includes('s')
? rect.bottom - inset
: rect.top + rect.height / 2;
return { x, y };
}
/** Edges of `after` that moved although a resize in `dir` should have pinned them. */
export function driftedEdges(before, after, dir, tolerance = 1.5) {
const edges = (box) => ({
left: box.left,
top: box.top,
right: box.left + box.width,
bottom: box.top + box.height,
});
const a = edges(before);
const b = edges(after);
return pinnedEdges(dir).filter(
(edge) => Math.abs(a[edge] - b[edge]) > tolerance,
);
}
function near(actual, expected, tolerance, label) {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, got ${actual}`,
);
}
async function main() {
const { default: puppeteer } = await import('puppeteer');
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath()),
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
try {
const page = await browser.newPage();
await page.setViewport(VIEWPORT);
const errors = [];
page.on('pageerror', (error) => errors.push(error.stack || error.message));
const boot = async () => {
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.viewer, {
timeout: 90_000,
});
await page.waitForSelector(`#${PANEL_ID} .panel-header`);
// The first-run dialog sits over the globe until a choice is made.
await page.evaluate(() => {
document.querySelector('.first-run-explore')?.click();
});
await page.keyboard.press('Escape');
await page.evaluate(() => {
document.getElementById('first-run-launcher')?.remove();
for (const node of document.querySelectorAll('[class*=first-run]'))
node.remove();
});
await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2);
await new Promise((resolve) => setTimeout(resolve, 400));
};
const readPanel = () =>
page.evaluate((id) => {
const panel = document.getElementById(id);
const rect = panel.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
// handlePoint() reads the far edges for s/e handles.
right: rect.right,
bottom: rect.bottom,
floating: panel.classList.contains('panel-floating'),
collapsed: panel.classList.contains('collapsed'),
inRail: panel.parentElement?.id === 'right-context-rail',
allocated: panel.style.getPropertyValue(
'--right-panel-allocated-height',
),
inlineWidth: panel.style.width,
inlineHeight: panel.style.height,
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`),
};
}, PANEL_ID);
/** Wait until the CCTV box has held still for ten animation frames. */
/**
* Best effort: wait until the CCTV box (to the whole pixel) has held still
* for half a second. The lift is checked against the box at pointerdown,
* so a rail that keeps nudging the panel only costs the wait, not the run.
*/
const settle = async () => {
try {
await page.waitForFunction(
(id) => {
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.__qaLastBox !== box) {
window.__qaLastBox = box;
window.__qaSince = now;
}
return now - window.__qaSince >= 500;
},
{ polling: 100, timeout: 20_000 },
PANEL_ID,
);
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
console.log('note: the CCTV panel was still moving; lifting anyway');
}
};
const headerPoint = async () => {
const rect = await 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 };
}, PANEL_ID);
return rect;
};
const drag = (from, dx, dy) => dragPointer(page, from, dx, dy);
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.evaluate((key) => {
localStorage.removeItem(key);
localStorage.removeItem('godsEyeView.v8.panelFloatHintShown');
}, STORAGE_KEY);
await boot();
// Expand CCTV so the panel has a body worth resizing.
await page.evaluate((id) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed'))
document
.querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`)
.click();
}, PANEL_ID);
await page.waitForFunction(
(id) => !document.getElementById(id).classList.contains('collapsed'),
{},
PANEL_ID,
);
// The rail animates panel heights, and on a slow software renderer the
// panels above CCTV can still be moving after a fixed sleep.
await settle();
const docked = await readPanel();
assert.equal(docked.floating, false);
assert.equal(docked.inRail, true);
// Lift out: drag the header 200px left and 100px up. The window must lift
// from wherever the panel sits when the pointer goes down, so record that
// box in the page instead of trusting the earlier read.
// What sits above CCTV in the rail, to name whatever shifted it.
const railAbove = () =>
page.evaluate((id) => {
const out = [];
for (const node of document.getElementById('right-context-rail')
?.children || []) {
if (node.id === id) break;
out.push(
`${node.id || node.className}:${Math.round(node.offsetHeight)}`,
);
}
return out.join(' ');
}, PANEL_ID);
const aboveAtDock = await railAbove();
// Press only where the header really is, recording what the press landed
// on. A press the rail shifted out from under the header (seen on the CI
// runner: something above CCTV grows ~32 px after load) proves nothing
// about the drag, so it is retried; a press that landed in the header and
// did not lift fails at once (scripts/qa-panelDrag.mjs).
const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -200,
dy: -100,
note: async () =>
`rail above CCTV ${aboveAtDock} -> ${await railAbove()}`,
});
const { pressed } = attempt;
const lifted = await readPanel();
assert.equal(
lifted.floating,
true,
`a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`,
);
assert.equal(
lifted.collapsed,
false,
'the drag-ending click must not collapse',
);
const moved = `docked at ${docked.left},${docked.top}; pressed at ${pressed.left},${pressed.top}`;
near(
lifted.left,
Math.max(6, pressed.left - 200),
2,
`lifted left (${moved})`,
);
near(
lifted.top,
Math.max(6, pressed.top - 100),
2,
`lifted top (${moved})`,
);
near(lifted.width, pressed.width, 2, 'lifted width');
await new Promise((resolve) => setTimeout(resolve, 700));
const afterLayout = await readPanel();
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
const railExcludes = await page.evaluate((id) => {
const rail = document.getElementById('right-context-rail');
// Same rule as the rail: hidden panels (a layer that is off) take no room.
const counted = [...rail.children].filter((panel) =>
panel.matches(
'[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])',
),
);
return {
expandedCount: rail.dataset.expandedCount,
counted: counted.length,
includesPanel: counted.some((panel) => panel.id === id),
};
}, PANEL_ID);
assert.equal(railExcludes.includesPanel, false);
if (railExcludes.expandedCount !== undefined)
assert.equal(Number(railExcludes.expandedCount), railExcludes.counted);
const storedAfterLift = JSON.parse(afterLayout.stored);
assert.equal(storedAfterLift.floating, true);
assert.equal(
'height' in storedAfterLift,
false,
'a drag alone must not freeze the measured height',
);
console.log('PASS: header drag lifts CCTV out of the rail');
// Park the window near the top: the voice dock sits above every panel at
// the bottom of the screen and would otherwise cover the south corners
// once the loop below has grown the window.
{
const header = await headerPoint();
await drag(header, 0, 120 - header.y);
await new Promise((resolve) => setTimeout(resolve, 200));
}
// Every handle moves only its own edges.
const limits = {
minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height,
viewportWidth: VIEWPORT.width,
viewportHeight: VIEWPORT.height,
};
for (const dir of RESIZE_DIRECTIONS) {
const before = await readPanel();
const { dx, dy } = growthDelta(dir, 40);
const point = handlePoint(before, dir);
// Name what the pointer lands on, so a covered handle is obvious.
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
if (!node) return 'nothing';
const dirAttr = node.dataset?.dir
? `[data-dir=${node.dataset.dir}]`
: '';
return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`;
}, point);
await drag(point, dx, dy);
const after = await readPanel();
const expected = resizeBox(before, dir, dx, dy, limits);
for (const key of ['left', 'top', 'width', 'height'])
near(after[key], expected[key], 2, `${dir} ${key} (pointer on ${hit})`);
assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`);
}
console.log('PASS: eight resize handles keep the opposite edge pinned');
// Minimum size stops the moving edge.
let before = await readPanel();
await drag(handlePoint(before, 'se'), -2000, -2000);
let after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (se)');
near(after.height, MIN_SIZE.height, 2, 'min height (se)');
assert.deepEqual(driftedEdges(before, after, 'se', 2), []);
before = after;
await drag(handlePoint(before, 'nw'), 2000, 2000);
after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (nw)');
near(after.height, MIN_SIZE.height, 2, 'min height (nw)');
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
console.log('PASS: minimum size holds from both corners');
// Reload restores the window.
const saved = await readPanel();
const record = JSON.parse(saved.stored);
assert.equal(record.floating, true);
await boot();
const restored = await readPanel();
assert.equal(restored.floating, true, 'floating survives a reload');
for (const key of ['left', 'top', 'width', 'height'])
near(restored[key], record[key], 2, `restored ${key}`);
console.log('PASS: reload restores position and size');
// Double-click the header to snap back.
const dock = await dockPanelByDoubleClick(page, PANEL_ID);
const snapped = await readPanel();
assert.equal(
snapped.floating,
false,
`double-click snaps back (${describePress(dock)})`,
);
assert.equal(snapped.inRail, true);
assert.equal(snapped.inlineWidth, '');
assert.equal(snapped.inlineHeight, '');
assert.equal(snapped.stored, null, 'the storage key is gone');
console.log('PASS: header double-click returns the panel to the rail');
assert.deepEqual(errors, []);
} finally {
await browser.close();
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});
}