mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
On the CI runner the header drag stopped lifting CCTV (it did not float), right after a run where its box never held still: the rail is shifting there, so the press can land beside the header. It does not reproduce locally (both gates pass on a fresh key-less dev server). The gate now waits, bounded at 10 s, until the title point is on the CCTV header before pressing, and a failed lift reports the element pressed, whether it was in the header, and where the panel was when docked and at the press. Co-Authored-By: Claude Opus 5.5 <[email protected]>
403 lines
15 KiB
JavaScript
403 lines
15 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.
|
|
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);
|
|
const lifted = await readPanel();
|
|
const pressed = await page.evaluate(() => window.__qaPressBox);
|
|
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);
|
|
});
|
|
}
|