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:
Said TurkseverandClaude Opus 5.5 committed 2026-10-06 15:16:57 +01:00
1 parent bd97d38a99
commit 038d8facc2
12 files changed
+881 -97

No files matched your search

+3
View File
@@ -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', () => {
+79 -6
View File
@@ -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 }),
+9
View File
@@ -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'))
+219
View File
@@ -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);
}
});
+59 -1
View File
@@ -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;
+128
View File
@@ -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 }));