From 038d8facc24f1000837583826a04d1457a562eee Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 6 Oct 2026 15:16:57 +0100 Subject: [PATCH] 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 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 --- CHANGELOG.md | 2 + scripts/format-scope.json | 2 + scripts/qa-panel-resize.mjs | 95 +++------- scripts/qa-panelDrag.mjs | 238 +++++++++++++++++++++++++ scripts/qa-street-level.mjs | 94 ++++++++-- server/providers/mapillary/tiles.js | 43 ++++- src/streetLevelMarkup.test.mjs | 3 + src/tooling/mapillaryProvider.test.mjs | 85 ++++++++- src/tooling/previewServing.test.mjs | 9 + src/tooling/streetLevelQa.test.mjs | 219 +++++++++++++++++++++++ src/ui/streetLevelControls.js | 60 ++++++- src/ui/streetLevelControls.test.mjs | 128 +++++++++++++ 12 files changed, 881 insertions(+), 97 deletions(-) create mode 100644 scripts/qa-panelDrag.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f9dc35bd..685cfc996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b77301f29..fc0d8d47c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs index f2164c557..7892d06a5 100644 --- a/scripts/qa-panel-resize.mjs +++ b/scripts/qa-panel-resize.mjs @@ -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, ''); diff --git a/scripts/qa-panelDrag.mjs b/scripts/qa-panelDrag.mjs new file mode 100644 index 000000000..5bbd71e58 --- /dev/null +++ b/scripts/qa-panelDrag.mjs @@ -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}`; +} diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 7cad52139..570fe9281 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -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', diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js index 47d273a4e..a2dccfa55 100644 --- a/server/providers/mapillary/tiles.js +++ b/server/providers/mapillary/tiles.js @@ -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' }, }); diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index 3ace37ca2..1bfb9e631 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -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', () => { diff --git a/src/tooling/mapillaryProvider.test.mjs b/src/tooling/mapillaryProvider.test.mjs index 470639707..816dee764 100644 --- a/src/tooling/mapillaryProvider.test.mjs +++ b/src/tooling/mapillaryProvider.test.mjs @@ -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:pw@tiles.mapillary.com/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 }), diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index 51c143164..c2e8d4603 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -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')) diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index a8aceab5e..bffd247c2 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -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('GEV', { + 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); + } +}); diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 4033835f4..da069dc80 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -57,6 +57,13 @@ export class StreetLevelControls { this._resizeQueued = false; this._wrapHome = null; this._expandReturnFocus = null; + /** + * The children this class made inert while the expanded viewer is + * a modal on screen; only these are released again. + * @type {Set} + */ + 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 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 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 () // 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; diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index 56a81556a..bbce18442 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -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 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 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 }));