mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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]>
This commit is contained in:
1 parent
bd97d38a99
commit
038d8facc2
12 files changed
+881
-97
No files matched your search
@@ -21,6 +21,8 @@
|
||||
|
||||
- Street Level, second review round: the panel no longer reopens itself on every page load (only a user, voice or tool switch-on or an opening photo opens it, and those automatic opens are never stored); a floating panel is pulled back on-screen when its content grows or the window shrinks, and its header can never be dropped under the command dock or the voice pill; the expanded photo hides in Clean View, recording and the cockpit; closing a photo stops sequence playback; a failed sequence load can be retried and leaves no stale image cones. The tile proxy refuses cross-site requests and has a per-IP limit, runs at most six upstream fetches at once, stops a response past its size cap as it streams, counts empty tiles against its caches and answers other upstream refusals with 502. The panel's collapsed state now travels in share links (`ui` token `t`).
|
||||
|
||||
- Street Level, third review round: closing a photo, switching the layer off or turning FOLLOW on stops the camera flight toward it (only while that flight is still Street Level's); a slow nearest-photo lookup can no longer replace a newer choice; distances, line densification and mesh sampling go the short way round the date line; image cones and the photo marker no longer show through the globe; a selection change recolours the previous zoom's lines still on screen; mesh sampling keeps to its time budget (a persistent priority queue, work only within range) and moves only the affected cones in place; the expanded viewer is a real modal (the rest of the app is inert while it shows); the tile proxy follows redirects only within the Mapillary tile origin; CI checks the real Mapillary routes.
|
||||
|
||||
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
|
||||
list is one keyless GeoJSON request to the agency's OGC API view of its
|
||||
DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"docs/APPLICATION.md",
|
||||
"scripts/qa-application.mjs",
|
||||
"scripts/qa-panel-resize.mjs",
|
||||
"scripts/qa-panelDrag.mjs",
|
||||
"src/tooling/panelResizeQa.test.mjs",
|
||||
"scripts/qa-terrain-429.mjs",
|
||||
"src/app/application.js",
|
||||
@@ -915,6 +916,7 @@
|
||||
"src/layers/streetLevel/cameraFollow.test.mjs",
|
||||
"src/ui/streetLevelControls.test.mjs",
|
||||
"src/ui/panelDock.test.mjs",
|
||||
"src/layers/streetLevel/marker.test.mjs",
|
||||
"src/layers/streetLevel/viewerHost.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.test.mjs",
|
||||
"src/layers/streetLevel/selection.test.mjs",
|
||||
|
||||
+21
-74
@@ -8,6 +8,12 @@
|
||||
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}`;
|
||||
@@ -170,14 +176,7 @@ async function main() {
|
||||
}, 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));
|
||||
};
|
||||
const drag = (from, dx, dy) => dragPointer(page, from, dx, dy);
|
||||
|
||||
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate((key) => {
|
||||
@@ -223,66 +222,23 @@ async function main() {
|
||||
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
|
||||
// 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.
|
||||
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;
|
||||
// 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 (pressed ${grab.x},${grab.y} on ${pressed?.target}, in header: ${pressed?.inHeader}; panel docked at ${docked.top}, at press ${pressed?.top})`,
|
||||
`a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`,
|
||||
);
|
||||
assert.equal(
|
||||
lifted.collapsed,
|
||||
@@ -397,21 +353,12 @@ async function main() {
|
||||
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 dock = await dockPanelByDoubleClick(page, PANEL_ID);
|
||||
const snapped = await readPanel();
|
||||
assert.equal(
|
||||
snapped.floating,
|
||||
false,
|
||||
`double-click snaps back (pointer on ${headerHit})`,
|
||||
`double-click snaps back (${describePress(dock)})`,
|
||||
);
|
||||
assert.equal(snapped.inRail, true);
|
||||
assert.equal(snapped.inlineWidth, '');
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
+79
-15
@@ -13,13 +13,22 @@
|
||||
* is needed. The steps that open a photo need real imagery and are skipped.
|
||||
* Fixture runs add what a live run cannot stage: a key Mapillary rejects, and
|
||||
* a second page whose server has no key (the keyless gate most installs see).
|
||||
* Filter assertions are stricter because the fixture data is known.
|
||||
* Filter assertions are stricter because the fixture data is known. Every run
|
||||
* first asks the server's real status route from Node, before the browser
|
||||
* intercepts anything, so a server without the Mapillary routes fails.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { LAYER_STATE_REGISTRY } from '../src/data/layerState.js';
|
||||
import { tileBounds } from '../src/layers/streetLevel/tileMath.js';
|
||||
import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs';
|
||||
import {
|
||||
describePress,
|
||||
dockPanelByDoubleClick,
|
||||
liftPanelByHeader,
|
||||
} from './qa-panelDrag.mjs';
|
||||
|
||||
const PANEL_ID = 'street-level-panel';
|
||||
|
||||
/** Viewports every layout assertion runs at. */
|
||||
export const VIEWPORTS = Object.freeze([
|
||||
@@ -105,6 +114,49 @@ export function fixtureTile(z, x, y, now = Date.now()) {
|
||||
return encodeCoverageTile(tile, { sequences });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the server's real Mapillary status route, outside the browser's
|
||||
* interception, and require its JSON shape. Fixture runs answer the route in
|
||||
* the page, so without this a server that never registered it would pass.
|
||||
* Node sends no Origin or Sec-Fetch-Site, so the same-site gate admits it.
|
||||
* @param {string} url the application's base URL
|
||||
* @param {typeof fetch} [fetchImpl]
|
||||
* @returns {Promise<{configured: boolean}>}
|
||||
*/
|
||||
export async function assertRealStatusRoute(url, fetchImpl = fetch) {
|
||||
const route = new URL('/api/mapillary/status', url).href;
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(route, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`${route} is unreachable: ${error?.message || error}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
const type = response.headers.get('content-type') || '';
|
||||
const text = await response.text();
|
||||
assert.equal(
|
||||
response.status,
|
||||
200,
|
||||
`${route} must be the server's Mapillary route (HTTP ${response.status}: ${text.slice(0, 120)})`,
|
||||
);
|
||||
assert.match(type, /application\/json/, `${route} answers JSON, not ${type}`);
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
assert.fail(`${route} answered unparseable JSON: ${text.slice(0, 120)}`);
|
||||
}
|
||||
assert.equal(
|
||||
typeof body?.configured,
|
||||
'boolean',
|
||||
`${route} reports a boolean \`configured\`: ${text.slice(0, 120)}`,
|
||||
);
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer the page's Mapillary requests from fixtures; nothing reaches
|
||||
* mapillary.com. `fixture` is live: `configured` is what the status route
|
||||
@@ -277,6 +329,14 @@ async function main() {
|
||||
});
|
||||
};
|
||||
try {
|
||||
// Before any interception: fixture runs answer this route in the page.
|
||||
await step(
|
||||
'the server registers the real Mapillary status route',
|
||||
async () => {
|
||||
const real = await assertRealStatusRoute(url);
|
||||
console.log(` (server status: configured=${real.configured})`);
|
||||
},
|
||||
);
|
||||
const page = await browser.newPage();
|
||||
const fixture = { configured: true, tiles: 'ok', tileRequests: 0 };
|
||||
if (fixtures) await serveFixtures(page, fixture);
|
||||
@@ -865,19 +925,21 @@ async function main() {
|
||||
const r = node.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
});
|
||||
// Lifting and docking re-resolve the header and retry only a press that
|
||||
// provably missed it: right after a dock the rail is still animating, and
|
||||
// a title point read once can be under another panel (scripts/
|
||||
// qa-panelDrag.mjs, shared with the panel-resize gate).
|
||||
const floatPanel = async () => {
|
||||
const title = await center('#street-level-panel .panel-title');
|
||||
await page.mouse.move(title.x, title.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(title.x - 200, title.y + 40, { steps: 8 });
|
||||
await page.mouse.move(title.x - 400, title.y + 80, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await sleep(400);
|
||||
const attempt = await liftPanelByHeader(page, PANEL_ID, {
|
||||
dx: -400,
|
||||
dy: 80,
|
||||
});
|
||||
assert.equal(
|
||||
await floating(),
|
||||
attempt.done,
|
||||
true,
|
||||
'a header drag lifts the panel out of the rail',
|
||||
`a header drag lifts the panel out of the rail (${describePress(attempt)})`,
|
||||
);
|
||||
assert.equal(await floating(), true);
|
||||
};
|
||||
await photoStep(
|
||||
'the panel floats on a header drag, resizes, and the viewer takes the room',
|
||||
@@ -946,11 +1008,13 @@ async function main() {
|
||||
);
|
||||
await step('a header double-click docks a floating window', async () => {
|
||||
await floatPanel();
|
||||
const title = await center('#street-level-panel .panel-title');
|
||||
await page.mouse.click(title.x, title.y, { clickCount: 1 });
|
||||
await page.mouse.click(title.x, title.y, { clickCount: 2 });
|
||||
await sleep(400);
|
||||
assert.equal(await floating(), false, 'docked again');
|
||||
// Waits on the docked state itself, not a fixed sleep.
|
||||
const attempt = await dockPanelByDoubleClick(page, PANEL_ID);
|
||||
assert.equal(
|
||||
attempt.done,
|
||||
true,
|
||||
`docked again (${describePress(attempt)})`,
|
||||
);
|
||||
});
|
||||
await step(
|
||||
'collapsing a floating window docks it as the rail strip',
|
||||
|
||||
@@ -306,6 +306,47 @@ export async function sweepTileDisk({
|
||||
return { removed, bytes };
|
||||
}
|
||||
|
||||
/** The one origin the tile token may be sent to, at every redirect hop. */
|
||||
const TILE_ORIGIN = new URL(MAPILLARY_TILE_HOST).origin;
|
||||
/** Redirect hops followed before the upstream counts as broken. */
|
||||
export const TILE_MAX_REDIRECTS = 3;
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
|
||||
/**
|
||||
* Fetch a tile URL, following redirects by hand: each hop must stay HTTPS on
|
||||
* the Mapillary tile origin (no credentials in the URL), so the access token
|
||||
* in the query is never sent to another host. A refused or endless redirect
|
||||
* is an upstream fault (502).
|
||||
*/
|
||||
async function fetchPinned(url, init) {
|
||||
let next = url;
|
||||
for (let hop = 0; ; hop++) {
|
||||
const response = await fetch(next, { ...init, redirect: 'manual' });
|
||||
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
||||
await response.body?.cancel().catch(() => {});
|
||||
if (hop >= TILE_MAX_REDIRECTS)
|
||||
throw new TileUpstreamError(502, 'Mapillary tile redirected too often');
|
||||
let target = null;
|
||||
try {
|
||||
target = new URL(response.headers.get('location') ?? '', next);
|
||||
} catch {
|
||||
/* unparseable Location */
|
||||
}
|
||||
if (
|
||||
!response.headers.get('location') ||
|
||||
target?.protocol !== 'https:' ||
|
||||
target.origin !== TILE_ORIGIN ||
|
||||
target.username ||
|
||||
target.password
|
||||
)
|
||||
throw new TileUpstreamError(
|
||||
502,
|
||||
'Mapillary tile redirect left the tile origin',
|
||||
);
|
||||
next = target.href;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUpstream(address, signal) {
|
||||
const token = mapillaryToken();
|
||||
if (!token) throw new TileRequestError('Mapillary token not configured', 503);
|
||||
@@ -315,7 +356,7 @@ async function fetchUpstream(address, signal) {
|
||||
const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS);
|
||||
// Aborts the request itself once its body runs past the size cap.
|
||||
const oversize = new AbortController();
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchPinned(url, {
|
||||
signal: AbortSignal.any([signal, timeout, oversize.signal].filter(Boolean)),
|
||||
headers: { Accept: 'application/x-protobuf' },
|
||||
});
|
||||
|
||||
@@ -115,6 +115,9 @@ test('the expanded viewer is a modal dialog that restores focus', () => {
|
||||
assert.match(controls, /setAttribute\('role', 'dialog'\)/);
|
||||
assert.match(controls, /setAttribute\('aria-modal', 'true'\)/);
|
||||
assert.match(controls, /_expandReturnFocus/);
|
||||
// A real modal: the rest of the application is inert while it is shown.
|
||||
assert.match(controls, /node\.inert = true/);
|
||||
assert.match(controls, /node\.inert = false/);
|
||||
});
|
||||
|
||||
test('labels say what the buttons do', () => {
|
||||
|
||||
@@ -185,9 +185,9 @@ test('tile route validates the path and refuses to proxy without a token', async
|
||||
});
|
||||
|
||||
/**
|
||||
* Drive the tile route against a scripted upstream: `answer(call)` returns the
|
||||
* Response for each upstream fetch. Refusals are never cached, so no disk tile
|
||||
* is written.
|
||||
* Drive the tile route against a scripted upstream: `answer(call, url)`
|
||||
* returns the Response for each upstream fetch; `inits` holds each fetch's
|
||||
* options. Refusals are never cached, so no disk tile is written.
|
||||
*/
|
||||
async function withUpstream(answer, run) {
|
||||
const savedFetch = globalThis.fetch;
|
||||
@@ -195,13 +195,15 @@ async function withUpstream(answer, run) {
|
||||
process.env.MAPILLARY_CLIENT_TOKEN = 'MLY|test|token';
|
||||
_resetTileMemoryForTest();
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
const inits = [];
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push(String(url));
|
||||
return answer(calls.length);
|
||||
inits.push(init);
|
||||
return answer(calls.length, String(url));
|
||||
};
|
||||
try {
|
||||
const { call, start } = install(mapillaryProxy());
|
||||
await run({ calls, call, start });
|
||||
await run({ calls, inits, call, start });
|
||||
} finally {
|
||||
// No background write or sweep (dated by a mocked clock) outlives the test.
|
||||
await tiles._settleTileWritesForTest();
|
||||
@@ -688,6 +690,77 @@ for (const status of [400, 410, 422])
|
||||
);
|
||||
});
|
||||
|
||||
const redirect = (location) =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: location === null ? {} : { location },
|
||||
});
|
||||
|
||||
test('a redirect within the Mapillary tile origin is followed by hand (IC8 P2)', async () => {
|
||||
await fsp.rm(tileFile({ z: 14, x: 13, y: 13 }), { force: true });
|
||||
await withUpstream(
|
||||
(n) =>
|
||||
n === 1
|
||||
? redirect('/maps/vtp/mly1_public/2/14/13/13?moved=1')
|
||||
: new Response(tile([{ name: 'sequence' }]), { status: 200 }),
|
||||
async ({ calls, inits, call }) => {
|
||||
const res = await call('/api/mapillary/tiles', '/coverage/14/13/13');
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.deepEqual(listTileLayers(res.body), ['sequence']);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(
|
||||
calls[1],
|
||||
'https://tiles.mapillary.com/maps/vtp/mly1_public/2/14/13/13?moved=1',
|
||||
);
|
||||
for (const init of inits)
|
||||
assert.equal(init.redirect, 'manual', 'fetch never follows itself');
|
||||
assert.doesNotMatch(leakText(res), /MLY\|/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
for (const [why, location] of [
|
||||
['another host', 'https://evil.example/collect'],
|
||||
['a look-alike host', 'https://tiles.mapillary.com.evil.example/t'],
|
||||
['another port', 'https://tiles.mapillary.com:8443/t'],
|
||||
['plain HTTP', 'http://tiles.mapillary.com/maps/vtp/t'],
|
||||
['credentials in the URL', 'https://user:[email protected]/t'],
|
||||
['a scheme-relative host', '//evil.example/t'],
|
||||
['no Location', null],
|
||||
])
|
||||
test(`a redirect off the tile origin (${why}) is a 502, and the token stays home (IC8 P2)`, async () => {
|
||||
await withUpstream(
|
||||
(n) =>
|
||||
n === 1
|
||||
? redirect(location)
|
||||
: new Response(tile([{ name: 'sequence' }]), { status: 200 }),
|
||||
async ({ calls, inits, call }) => {
|
||||
const res = await call('/api/mapillary/tiles', '/coverage/14/14/14');
|
||||
assert.equal(res.statusCode, 502);
|
||||
assert.deepEqual(json(res), {
|
||||
error: 'Mapillary tile redirect left the tile origin',
|
||||
});
|
||||
assert.equal(calls.length, 1, 'the redirect was not followed');
|
||||
assert.equal(inits[0].redirect, 'manual');
|
||||
assert.doesNotMatch(leakText(res), /MLY\|/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('an endless same-origin redirect stops after a few hops (IC8 P2)', async () => {
|
||||
await withUpstream(
|
||||
(n) => redirect(`/maps/vtp/mly1_public/2/14/15/15?hop=${n}`),
|
||||
async ({ calls, call }) => {
|
||||
const res = await call('/api/mapillary/tiles', '/coverage/14/15/15');
|
||||
assert.equal(res.statusCode, 502);
|
||||
assert.deepEqual(json(res), {
|
||||
error: 'Mapillary tile redirected too often',
|
||||
});
|
||||
assert.equal(calls.length, tiles.TILE_MAX_REDIRECTS + 1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('cross-site requests are refused on both routes; the app itself passes (review gekh)', async () => {
|
||||
await withUpstream(
|
||||
() => new Response(tile([{ name: 'sequence' }]), { status: 200 }),
|
||||
|
||||
@@ -44,6 +44,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
|
||||
TOMTOM_API_KEY: '',
|
||||
GOOGLE_MAPS_API_KEY: '',
|
||||
GOOGLE_MAPS_SERVER_API_KEY: '',
|
||||
MAPILLARY_CLIENT_TOKEN: '',
|
||||
OPENAI_API_KEY: '',
|
||||
OPENSKY_AUTH_MODE: 'anon',
|
||||
OPENSKY_CLIENT_ID: '',
|
||||
@@ -108,6 +109,10 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
|
||||
['/api/cctv/sources', 200],
|
||||
['/api/gbfs/', 400],
|
||||
['/api/tomtom/status', 200],
|
||||
// The Street Level QA gate intercepts these in the browser; this is
|
||||
// the check that the real servers register them (review IC8 P2).
|
||||
['/api/mapillary/status', 200],
|
||||
['/api/mapillary/tiles/coverage/14/1/2', 503],
|
||||
['/api/radio/unknown', 404],
|
||||
['/api/setup/status', isPreview ? 404 : 200],
|
||||
['/api/setup/update', 404],
|
||||
@@ -128,6 +133,10 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
|
||||
const body = await response.json();
|
||||
if (route === '/api/cctv/sources')
|
||||
assert.equal(body.sources[0].id, 'fixture');
|
||||
if (route === '/api/mapillary/status')
|
||||
assert.deepEqual(body, { configured: false }, route);
|
||||
if (route.startsWith('/api/mapillary/tiles'))
|
||||
assert.deepEqual(body, { error: 'no_key', keyRequired: true }, route);
|
||||
if (
|
||||
route === '/api/does-not-exist' ||
|
||||
(isPreview && route.startsWith('/api/setup'))
|
||||
|
||||
@@ -59,3 +59,222 @@ test('fixture tiles give the hermetic gate something real to filter', async () =
|
||||
assert.ok(orbit.overview.length > 0, 'overview points from orbit');
|
||||
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nothing in between');
|
||||
});
|
||||
|
||||
/* ── The real status route probe (review IC8 P2) ───────────────────────── */
|
||||
|
||||
test('the gate requires the server’s real Mapillary status route', async () => {
|
||||
const { assertRealStatusRoute } =
|
||||
await import('../../scripts/qa-street-level.mjs');
|
||||
const asked = [];
|
||||
const answer = (body, init) => async (url) => {
|
||||
asked.push(url);
|
||||
return typeof body === 'string'
|
||||
? new Response(body, init)
|
||||
: Response.json(body, init);
|
||||
};
|
||||
assert.deepEqual(
|
||||
await assertRealStatusRoute(
|
||||
'http://localhost:4173',
|
||||
answer({ configured: false }),
|
||||
),
|
||||
{ configured: false },
|
||||
);
|
||||
assert.deepEqual(asked, ['http://localhost:4173/api/mapillary/status']);
|
||||
for (const [why, fetchImpl] of [
|
||||
[
|
||||
'an unregistered route (the API 404)',
|
||||
answer({ error: 'Unknown API route' }, { status: 404 }),
|
||||
],
|
||||
[
|
||||
'the SPA fallback',
|
||||
answer('<!doctype html><title>GEV</title>', {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
}),
|
||||
],
|
||||
['a malformed status', answer({ configured: 'yes' })],
|
||||
[
|
||||
'an unreachable server',
|
||||
async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
],
|
||||
])
|
||||
await assert.rejects(
|
||||
assertRealStatusRoute('http://localhost:4173', fetchImpl),
|
||||
/api\/mapillary\/status/,
|
||||
why,
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Header press-and-verify shared with the panel gate (review IC8 P2) ── */
|
||||
|
||||
/**
|
||||
* A page stand-in whose `evaluate` runs the page function in Node against a
|
||||
* fake document. The hit check always sees the header; `presses` says where
|
||||
* each real pointerdown lands ('header', or 'other' when the rail moved the
|
||||
* panel between the check and the press). A drag from the header lifts the
|
||||
* 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 listeners = [];
|
||||
const node = (tagName, id, classes, inHeader) => ({
|
||||
tagName,
|
||||
id,
|
||||
classList: classes,
|
||||
closest: (selector) =>
|
||||
inHeader && selector.includes('.panel-header') ? {} : null,
|
||||
});
|
||||
const header = node('DIV', '', ['panel-title'], true);
|
||||
const other = node('SECTION', 'weather-panel', ['panel'], false);
|
||||
const rect = (left, top, width, height) => () => ({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
const document = {
|
||||
querySelector: () => ({ getBoundingClientRect: rect(120, 210, 100, 20) }),
|
||||
getElementById: () => ({
|
||||
getBoundingClientRect: rect(100, 200, 300, 400),
|
||||
classList: {
|
||||
contains: (name) => name === 'panel-floating' && state.floating,
|
||||
},
|
||||
}),
|
||||
elementFromPoint: () => header,
|
||||
};
|
||||
const window = {
|
||||
addEventListener: (type, listener) => listeners.push(listener),
|
||||
};
|
||||
/** Run page code (an evaluated function or a page listener) in the page. */
|
||||
const inPage = (fn, ...args) => {
|
||||
const saved = { document: globalThis.document, window: globalThis.window };
|
||||
Object.assign(globalThis, { document, window });
|
||||
try {
|
||||
return fn(...args);
|
||||
} finally {
|
||||
Object.assign(globalThis, saved);
|
||||
}
|
||||
};
|
||||
const evaluate = async (fn, ...args) => inPage(fn, ...args);
|
||||
const press = () => {
|
||||
state.last = presses[state.downs++] ?? 'header';
|
||||
const target = state.last === 'header' ? header : other;
|
||||
for (const listener of listeners.splice(0)) inPage(listener, { target });
|
||||
};
|
||||
return {
|
||||
state,
|
||||
evaluate,
|
||||
async waitForFunction(fn, options, ...args) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (await evaluate(fn, ...args)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
throw Object.assign(new Error('timed out'), { name: 'TimeoutError' });
|
||||
},
|
||||
mouse: {
|
||||
async move() {},
|
||||
async down() {
|
||||
press();
|
||||
state.dragging = true;
|
||||
},
|
||||
async up() {
|
||||
if (state.dragging && state.last === 'header' && !stuck)
|
||||
state.floating = true;
|
||||
state.dragging = false;
|
||||
},
|
||||
async click(x, y, { clickCount }) {
|
||||
if (clickCount === 1) press();
|
||||
else if (state.last === 'header' && !stuck) state.floating = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a header press is retried only when it provably missed the header', async () => {
|
||||
const { pressMissed } = await import('../../scripts/qa-panelDrag.mjs');
|
||||
assert.equal(pressMissed({ done: true, pressed: null }), false);
|
||||
assert.equal(
|
||||
pressMissed({ done: false, pressed: { inHeader: true } }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
pressMissed({ done: false, pressed: { inHeader: false } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(pressMissed({ done: false, pressed: null }), true);
|
||||
});
|
||||
|
||||
test('a lift whose press the rail moved off the header is retried, then lifts', async () => {
|
||||
const { liftPanelByHeader, describePress } =
|
||||
await import('../../scripts/qa-panelDrag.mjs');
|
||||
const page = fakePanelPage({ presses: ['other', 'header'] });
|
||||
const notes = [];
|
||||
const attempt = await liftPanelByHeader(page, 'street-level-panel', {
|
||||
dx: -400,
|
||||
dy: 80,
|
||||
log: (line) => notes.push(line),
|
||||
pauseMs: 0,
|
||||
});
|
||||
assert.equal(attempt.done, true);
|
||||
assert.equal(page.state.downs, 2);
|
||||
assert.equal(notes.length, 1);
|
||||
assert.match(notes[0], /missed the street-level-panel header/);
|
||||
assert.match(notes[0], /section#weather-panel\.panel/, 'names what it hit');
|
||||
assert.match(describePress(attempt), /in header: true/);
|
||||
});
|
||||
|
||||
test('a press in the header that does not lift fails at once, naming the target', async () => {
|
||||
const { liftPanelByHeader, describePress } =
|
||||
await import('../../scripts/qa-panelDrag.mjs');
|
||||
const page = fakePanelPage({ stuck: true });
|
||||
const attempt = await liftPanelByHeader(page, 'street-level-panel', {
|
||||
dx: -400,
|
||||
dy: 80,
|
||||
log: () => assert.fail('no retry'),
|
||||
pauseMs: 0,
|
||||
});
|
||||
assert.equal(attempt.done, false);
|
||||
assert.equal(page.state.downs, 1);
|
||||
assert.match(describePress(attempt), /on div\.panel-title, in header: true/);
|
||||
});
|
||||
|
||||
test('presses that keep missing stop after the retries and report the miss', async () => {
|
||||
const { liftPanelByHeader, PRESS_RETRIES } =
|
||||
await import('../../scripts/qa-panelDrag.mjs');
|
||||
const page = fakePanelPage({ presses: ['other', 'other', 'other', 'other'] });
|
||||
const attempt = await liftPanelByHeader(page, 'street-level-panel', {
|
||||
dx: -400,
|
||||
dy: 80,
|
||||
log: () => {},
|
||||
pauseMs: 0,
|
||||
});
|
||||
assert.equal(attempt.done, false);
|
||||
assert.equal(page.state.downs, PRESS_RETRIES + 1);
|
||||
assert.equal(attempt.pressed.inHeader, false);
|
||||
});
|
||||
|
||||
test('a header double-click docks, waiting on the docked state', async () => {
|
||||
const { dockPanelByDoubleClick } =
|
||||
await import('../../scripts/qa-panelDrag.mjs');
|
||||
const page = fakePanelPage({ floating: true, presses: ['other', 'header'] });
|
||||
const attempt = await dockPanelByDoubleClick(page, 'street-level-panel', {
|
||||
log: () => {},
|
||||
pauseMs: 0,
|
||||
});
|
||||
assert.equal(attempt.done, true);
|
||||
assert.equal(page.state.floating, false);
|
||||
assert.equal(page.state.downs, 2, 'the missed first press was retried');
|
||||
});
|
||||
|
||||
test('both panel gates press the header through the shared helper', () => {
|
||||
for (const script of ['qa-street-level.mjs', 'qa-panel-resize.mjs']) {
|
||||
const source = fs.readFileSync(
|
||||
new URL(`../../scripts/${script}`, import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /from '\.\/qa-panelDrag\.mjs'/, script);
|
||||
assert.match(source, /liftPanelByHeader\(page, PANEL_ID/, script);
|
||||
assert.match(source, /dockPanelByDoubleClick\(page, PANEL_ID/, script);
|
||||
}
|
||||
});
|
||||
@@ -57,6 +57,13 @@ export class StreetLevelControls {
|
||||
this._resizeQueued = false;
|
||||
this._wrapHome = null;
|
||||
this._expandReturnFocus = null;
|
||||
/**
|
||||
* The <body> children this class made inert while the expanded viewer is
|
||||
* a modal on screen; only these are released again.
|
||||
* @type {Set<Element>}
|
||||
*/
|
||||
this._inerted = new Set();
|
||||
this._modalObserver = null;
|
||||
this._elements = this._collect();
|
||||
this._bind();
|
||||
}
|
||||
@@ -332,7 +339,10 @@ export class StreetLevelControls {
|
||||
wrap.setAttribute('aria-label', 'Street-level image');
|
||||
wrap.tabIndex = -1;
|
||||
wrap.focus({ preventScroll: true });
|
||||
this._watchModal(true);
|
||||
} else {
|
||||
// Release the application first: the focus returned below may be in it.
|
||||
this._watchModal(false);
|
||||
wrap.classList.remove('sl-viewer-wrap-expanded');
|
||||
wrap.removeAttribute('role');
|
||||
wrap.removeAttribute('aria-modal');
|
||||
@@ -391,9 +401,56 @@ export class StreetLevelControls {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the application modal-inert while the expanded viewer is on screen:
|
||||
* every other <body> child is `inert` (one already inert is left alone), so
|
||||
* no control around the viewer takes a click or focus. A viewer that CSS
|
||||
* hides (Clean View, recording, cockpit) releases them, since a hidden
|
||||
* modal must not leave the app dead. Returns whether the dialog is shown.
|
||||
*/
|
||||
_syncModal() {
|
||||
if (this.destroyed || !this._isDialogShown()) {
|
||||
this._releaseModal();
|
||||
return false;
|
||||
}
|
||||
const wrap = this._elements.viewerWrap;
|
||||
for (const node of [...(document.body?.children || [])]) {
|
||||
if (node === wrap || node.inert) continue;
|
||||
node.inert = true;
|
||||
this._inerted.add(node);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Give the application back every node `_syncModal` made inert. */
|
||||
_releaseModal() {
|
||||
for (const node of this._inerted) node.inert = false;
|
||||
this._inerted.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* While expanded, re-check the modal whenever <body> changes class (the
|
||||
* modes that hide the viewer) or gains a child (which must be inert too).
|
||||
* Shrinking stops watching and releases the application.
|
||||
*/
|
||||
_watchModal(on) {
|
||||
this._modalObserver?.disconnect();
|
||||
this._modalObserver = null;
|
||||
if (!on) return this._releaseModal();
|
||||
if (typeof MutationObserver === 'function' && document.body) {
|
||||
this._modalObserver = new MutationObserver(() => this._syncModal());
|
||||
this._modalObserver.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
childList: true,
|
||||
});
|
||||
}
|
||||
this._syncModal();
|
||||
}
|
||||
|
||||
_onDialogKey(event) {
|
||||
if (event.key !== 'Escape' && event.key !== 'Tab') return;
|
||||
if (!this._isDialogShown()) return;
|
||||
if (!this._syncModal()) return;
|
||||
// Only keys meant for the viewer: from inside it, from nowhere (<body>)
|
||||
// or from the globe. A field elsewhere (search) keeps its Esc and Tab.
|
||||
const target = event.target;
|
||||
@@ -607,6 +664,7 @@ export class StreetLevelControls {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.setViewerExpanded(false);
|
||||
this._watchModal(false);
|
||||
this.listeners.abort();
|
||||
this._resizeObserver?.disconnect();
|
||||
this._resizeObserver = null;
|
||||
|
||||
@@ -742,6 +742,134 @@ test('a hidden expanded viewer (Clean View, recording, cockpit) holds neither Es
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
/** A MutationObserver stand-in the test fires by hand. */
|
||||
function fakeMutationObserver() {
|
||||
const observers = [];
|
||||
class FakeMutationObserver {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.target = null;
|
||||
this.options = null;
|
||||
observers.push(this);
|
||||
}
|
||||
observe(target, options) {
|
||||
this.target = target;
|
||||
this.options = options;
|
||||
}
|
||||
disconnect() {
|
||||
this.target = null;
|
||||
}
|
||||
}
|
||||
const fire = () => {
|
||||
for (const observer of observers)
|
||||
if (observer.target) observer.callback([], observer);
|
||||
};
|
||||
const watching = () => observers.some((observer) => observer.target);
|
||||
return { FakeMutationObserver, fire, watching };
|
||||
}
|
||||
|
||||
/** Run `fn` with a fake MutationObserver installed. */
|
||||
async function withObserver(fn) {
|
||||
const saved = globalThis.MutationObserver;
|
||||
const fake = fakeMutationObserver();
|
||||
globalThis.MutationObserver = fake.FakeMutationObserver;
|
||||
try {
|
||||
return await fn(fake);
|
||||
} finally {
|
||||
if (saved === undefined) delete globalThis.MutationObserver;
|
||||
else globalThis.MutationObserver = saved;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every <body> child but the viewer, by id. */
|
||||
const inertOutside = (dom) =>
|
||||
dom.document.body.children
|
||||
.filter((node) => node.id !== 'sl-viewer-wrap')
|
||||
.map((node) => [node.id, node.inert === true]);
|
||||
|
||||
test('the expanded viewer makes the rest of the app inert and gives it back on shrink (IC8 P2)', () =>
|
||||
withObserver(() =>
|
||||
withDom(async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
// Another dialog's own inert backdrop: it is not ours to release.
|
||||
const owned = dom.document.body.appendChild(
|
||||
new dom.globe.constructor(dom.document, 'div', { id: 'owned' }),
|
||||
);
|
||||
owned.inert = true;
|
||||
controls.setViewerExpanded(true);
|
||||
assert.deepEqual(inertOutside(dom), [
|
||||
['street-level-panel', true],
|
||||
['cesiumContainer', true],
|
||||
['location-search', true],
|
||||
['owned', true],
|
||||
]);
|
||||
const wrap = dom.document.body.querySelector('#sl-viewer-wrap');
|
||||
assert.notEqual(wrap.inert, true, 'the viewer itself stays live');
|
||||
// Shrinking returns focus into the panel: it must be live again first.
|
||||
dom.root.querySelector('#sl-status').focus();
|
||||
controls.setViewerExpanded(false);
|
||||
assert.deepEqual(inertOutside(dom), [
|
||||
['street-level-panel', false],
|
||||
['cesiumContainer', false],
|
||||
['location-search', false],
|
||||
['owned', true],
|
||||
]);
|
||||
controls.destroy();
|
||||
}),
|
||||
));
|
||||
|
||||
test('a CSS-hidden expanded viewer releases the app; showing it again, and new children, go inert (IC8 P2)', () =>
|
||||
withObserver((observer) =>
|
||||
withDom(async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
controls.setViewerExpanded(true);
|
||||
assert.equal(observer.watching(), true);
|
||||
const wrap = dom.document.body.querySelector('#sl-viewer-wrap');
|
||||
let visible = true;
|
||||
wrap.checkVisibility = () => visible;
|
||||
// Clean View (or recording, or the cockpit) hides the viewer by CSS.
|
||||
visible = false;
|
||||
dom.document.body.classList.add('ui-clean-view');
|
||||
observer.fire();
|
||||
assert.ok(
|
||||
inertOutside(dom).every(([, inert]) => !inert),
|
||||
'a hidden modal leaves nothing inert',
|
||||
);
|
||||
// Leaving the mode shows the viewer again: the app is inert once more,
|
||||
// and so is a child added to <body> meanwhile.
|
||||
visible = true;
|
||||
const toast = dom.document.body.appendChild(
|
||||
new dom.globe.constructor(dom.document, 'div', { id: 'toast' }),
|
||||
);
|
||||
observer.fire();
|
||||
assert.ok(inertOutside(dom).every(([, inert]) => inert));
|
||||
assert.equal(toast.inert, true);
|
||||
controls.destroy();
|
||||
assert.ok(
|
||||
inertOutside(dom).every(([, inert]) => !inert),
|
||||
'destroy releases the app',
|
||||
);
|
||||
assert.equal(observer.watching(), false, 'and stops watching');
|
||||
}),
|
||||
));
|
||||
|
||||
test('an image closed while expanded releases the app (IC8 P2)', () =>
|
||||
withObserver(() =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||
controls.setViewerExpanded(true);
|
||||
assert.ok(inertOutside(dom).every(([, inert]) => inert));
|
||||
layer.publish(uiState({ open: false }));
|
||||
assert.equal(controls.isViewerExpanded(), false);
|
||||
assert.ok(inertOutside(dom).every(([, inert]) => !inert));
|
||||
assert.equal(
|
||||
dom.document.activeElement,
|
||||
dom.root.querySelector('#sl-status'),
|
||||
);
|
||||
controls.destroy();
|
||||
}),
|
||||
));
|
||||
|
||||
test('FIT / FILL show the selection while the viewer is expanded (P3)', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||
|
||||
Reference in new issue
Block a user