mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
The diagnostics from d9b13c7 named the CI failure: the press landed on the
globe canvas, not the header, because the CCTV panel had moved 32 px down
between the hit check and the press (docked at 234, 266 at the press) -
something above it in the rail grows on the runner after load. A press that
provably missed the header (the page records the pointerdown target) says
nothing about dragging, so it is retried up to twice, logging what sat
above CCTV at dock and at the retry; a press that landed in the header and
did not lift still fails at once.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
437 lines
16 KiB
JavaScript
437 lines
16 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';
|
|
|
|
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 = async (from, dx, dy) => {
|
|
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: 6 });
|
|
await page.mouse.move(from.x + dx, from.y + dy, { steps: 6 });
|
|
await page.mouse.up();
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
};
|
|
|
|
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();
|
|
/** One header drag; reports what the press landed on. */
|
|
const tryLift = async () => {
|
|
await page.evaluate((id) => {
|
|
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 lift says why.
|
|
target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`,
|
|
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
|
|
};
|
|
},
|
|
{ capture: true, once: true },
|
|
);
|
|
}, PANEL_ID);
|
|
// Press only where the header really is: if the rail is still shifting,
|
|
// wait (bounded) for the title point to land on the CCTV header.
|
|
let grab = await headerPoint();
|
|
for (let tries = 0; tries < 20; tries++) {
|
|
const onHeader = await page.evaluate(
|
|
({ x, y, id }) =>
|
|
Boolean(
|
|
document.elementFromPoint(x, y)?.closest(`#${id} .panel-header`),
|
|
),
|
|
{ ...grab, id: PANEL_ID },
|
|
);
|
|
if (onHeader) break;
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
grab = await headerPoint();
|
|
}
|
|
await drag(grab, -200, -100);
|
|
return {
|
|
grab,
|
|
lifted: await readPanel(),
|
|
pressed: await page.evaluate(() => window.__qaPressBox),
|
|
};
|
|
};
|
|
// 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.
|
|
let attempt = await tryLift();
|
|
for (let retry = 0; retry < 2; retry++) {
|
|
if (attempt.lifted.floating || attempt.pressed?.inHeader) break;
|
|
console.log(
|
|
`note: the press missed the header (on ${attempt.pressed?.target}; rail above CCTV ${aboveAtDock} -> ${await railAbove()}); retrying`,
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
attempt = await tryLift();
|
|
}
|
|
const { grab, lifted, pressed } = attempt;
|
|
assert.equal(
|
|
lifted.floating,
|
|
true,
|
|
`a header drag lifts the panel out (pressed ${grab.x},${grab.y} on ${pressed?.target}, in header: ${pressed?.inHeader}; 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 point = await headerPoint();
|
|
const headerHit = await page.evaluate(({ x, y }) => {
|
|
const node = document.elementFromPoint(x, y);
|
|
return node
|
|
? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`
|
|
: 'nothing';
|
|
}, point);
|
|
await page.mouse.click(point.x, point.y, { clickCount: 1 });
|
|
await page.mouse.click(point.x, point.y, { clickCount: 2 });
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
const snapped = await readPanel();
|
|
assert.equal(
|
|
snapped.floating,
|
|
false,
|
|
`double-click snaps back (pointer on ${headerHit})`,
|
|
);
|
|
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);
|
|
});
|
|
}
|