ci(street-level): run the browser gate hermetically with Mapillary fixtures

The Street Level and panel gates never ran in CI, and the live gate needs a
Mapillary token, the Mapillary network and real coverage at its test site,
so everything users see was guarded only by manual runs (which a bad token
and expired tile caches blocked twice this week).

`qa-street-level.mjs --fixtures` (npm run qa:street-level:fixtures) answers
the page's /api/mapillary/status and coverage-tile requests from generated
tiles (a street grid with flat and 360 deg sequences, half captured three
years ago; overview points from orbit) and blocks mapillary.com. Only the
six steps that open a photo are skipped. Because the data is known, the 360
deg and SINCE steps also assert that the filters remove sequences, not just
that counts do not grow. A unit test pins the fixture contents.

New CI job "Street Level browser gate": install with headless Chrome, start
a key-less dev server, run the fixture gate and qa:panel-resize. Locally,
against a dev server with every key blanked: 14/14 on three consecutive
runs, panel gate all pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-04 23:50:21 +01:00
1 parent 7ae5f52c89
commit 284cd0a93d
5 files changed
+201 -7

No files matched your search

+45
View File
@@ -59,6 +59,51 @@ jobs:
- name: Build production bundle - name: Build production bundle
run: npm run build run: npm run build
street-level-browser:
# The Street Level gate in its hermetic mode: Mapillary status and coverage
# tiles are answered from generated fixtures, so no token or Mapillary
# network is needed. The steps that open a photo need live imagery and
# are skipped here; run `npm run qa:street-level` with a token for them.
name: Street Level browser gate
runs-on: ubuntu-latest
timeout-minutes: 20
env:
GEV_REQUIRE_ALLOCATION_GATE: '0'
steps:
- name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.14.0
cache: npm
- name: Install locked dependencies and headless Chrome
run: npm ci
- name: Start the dev server
run: |
npx vite --port 4173 --strictPort > vite.log 2>&1 &
for attempt in $(seq 1 90); do
curl -sf http://localhost:4173/ > /dev/null && exit 0
sleep 2
done
cat vite.log
exit 1
- name: Street Level gate with Mapillary fixtures
run: npm run qa:street-level:fixtures -- --url http://localhost:4173
- name: Portable panel gate
run: npm run qa:panel-resize -- --url http://localhost:4173
- name: Dev server log
if: failure()
run: cat vite.log
windows-onboarding: windows-onboarding:
name: Windows onboarding name: Windows onboarding
runs-on: windows-latest runs-on: windows-latest
+10
View File
@@ -3025,6 +3025,16 @@ group in `src/data/layerState.js` and its modules to
keyless provider registers, `requiresKeyId` on the layer becomes null and the keyless provider registers, `requiresKeyId` on the layer becomes null and the
key gate moves to the chips, by design. key gate moves to the chips, by design.
Street Level has two browser gates. `npm run qa:street-level -- --url <dev
server>` is the live run: with `MAPILLARY_CLIENT_TOKEN` it covers everything
up to opening, expanding and closing a photo. `npm run
qa:street-level:fixtures -- --url <dev server>` is hermetic: the page's
Mapillary status and coverage-tile requests are answered from generated
fixtures (`providers/mapillary/coverageFixture.mjs`), so it needs no token or
Mapillary network, skips only the photo steps, and asserts that the 360° and
SINCE filters actually remove sequences. CI runs the fixture gate and
`qa:panel-resize` against a key-less dev server.
Directions is a keyless front end to the routing the voice agent already Directions is a keyless front end to the routing the voice agent already
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
profile; SET A and SET B arm the next globe click (Escape or a second press profile; SET A and SET B arm the next globe click (Escape or a second press
+1
View File
@@ -48,6 +48,7 @@
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"qa:panel-resize": "node scripts/qa-panel-resize.mjs", "qa:panel-resize": "node scripts/qa-panel-resize.mjs",
"qa:street-level": "node scripts/qa-street-level.mjs", "qa:street-level": "node scripts/qa-street-level.mjs",
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
"qa:transit": "node scripts/qa-transit.mjs", "qa:transit": "node scripts/qa-transit.mjs",
"mcp": "node server/mcp/stdio.js" "mcp": "node server/mcp/stdio.js"
+120 -7
View File
@@ -6,10 +6,19 @@
* filter and SINCE slider, the viewer (visible without scrolling, expanded * filter and SINCE slider, the viewer (visible without scrolling, expanded
* dialog, close) and the panel as a floating, resizable window. Run with `npm run qa:street-level -- --url http://localhost:4173`. * dialog, close) and the panel as a floating, resizable window. Run with `npm run qa:street-level -- --url http://localhost:4173`.
* Without MAPILLARY_CLIENT_TOKEN on the server only the keyless steps run. * Without MAPILLARY_CLIENT_TOKEN on the server only the keyless steps run.
*
* `--fixtures` makes the run hermetic for Mapillary (the CI mode): the page's
* Mapillary status and coverage-tile requests are answered with generated
* fixtures (see fixtureTile) and nothing reaches mapillary.com, so no token
* is needed. The steps that open a photo need MapillaryJS and real imagery
* and are skipped; everything else runs, with stricter filter assertions
* because the fixture data is known.
*/ */
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { LAYER_STATE_REGISTRY } from '../src/data/layerState.js'; 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';
/** Viewports every layout assertion runs at. */ /** Viewports every layout assertion runs at. */
export const VIEWPORTS = Object.freeze([ export const VIEWPORTS = Object.freeze([
@@ -41,11 +50,100 @@ export function isCollapsed(classList) {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const DAY_MS = 86_400_000;
/**
* Synthetic coverage for any tile, so fixture runs are deterministic: at
* street zooms a grid of four east-west and four north-south sequences (half
* 360°, half captured three years ago), at overview zooms a few points.
* @returns {Uint8Array} empty for zooms that carry nothing
*/
export function fixtureTile(z, x, y, now = Date.now()) {
const tile = { x, y, z };
const { west, east, south, north } = tileBounds(x, y, z);
const at = (t, lo, hi) => lo + (hi - lo) * t;
if (z <= 5)
return encodeCoverageTile(tile, {
overview: [0.25, 0.5, 0.75].map((t, i) => ({
id: `fx-${z}-${x}-${y}-o${i}`,
lon: at(t, west, east),
lat: at(t, south, north),
isPano: i === 1,
capturedAt: now - 30 * DAY_MS,
})),
});
if (z < 11) return new Uint8Array(0);
const margin = (east - west) * 0.05;
const sequences = [];
for (let i = 1; i <= 4; i++) {
const t = i / 5;
const old = i > 2;
sequences.push({
id: `fx-${z}-${x}-${y}-h${i}`,
isPano: i % 2 === 0,
capturedAt: now - (old ? 1100 : 30) * DAY_MS,
parts: [
[
[west + margin, at(t, south, north)],
[east - margin, at(t, south, north)],
],
],
});
sequences.push({
id: `fx-${z}-${x}-${y}-v${i}`,
isPano: i % 2 === 1,
capturedAt: now - (old ? 30 : 1100) * DAY_MS,
parts: [
[
[at(t, west, east), south + margin],
[at(t, west, east), north - margin],
],
],
});
}
return encodeCoverageTile(tile, { sequences });
}
/** Answer the page's Mapillary requests from fixtures; nothing reaches mapillary.com. */
async function serveFixtures(page) {
await page.setRequestInterception(true);
page.on('request', (request) => {
const address = new URL(request.url());
if (address.pathname === '/api/mapillary/status')
return request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ configured: true }),
});
const tile = address.pathname.match(
/^\/api\/mapillary\/tiles\/coverage\/(\d+)\/(\d+)\/(\d+)$/,
);
if (tile) {
const bytes = fixtureTile(
Number(tile[1]),
Number(tile[2]),
Number(tile[3]),
);
return bytes.length
? request.respond({
status: 200,
contentType: 'application/x-protobuf',
body: Buffer.from(bytes),
})
: request.respond({ status: 204, body: '' });
}
if (/(^|\.)mapillary\.com$/.test(address.hostname))
return request.respond({ status: 503, body: '' });
return request.continue();
});
}
async function main() { async function main() {
const { default: puppeteer } = await import('puppeteer'); const { default: puppeteer } = await import('puppeteer');
const args = process.argv.slice(2); const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url'); const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
const fixtures = args.includes('--fixtures');
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: true, headless: true,
executablePath: executablePath:
@@ -62,8 +160,13 @@ async function main() {
console.log(`ok ${passed} ${label}`); console.log(`ok ${passed} ${label}`);
return result; return result;
}; };
// Opening a photo needs MapillaryJS and real imagery: live runs only.
const photoStep = fixtures
? async (label) => console.log(`skip (fixtures) ${label}`)
: step;
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
if (fixtures) await serveFixtures(page);
const errors = []; const errors = [];
page.on('pageerror', (error) => errors.push(error.message)); page.on('pageerror', (error) => errors.push(error.message));
// The layer swallows listener exceptions into a console warning; surface them. // The layer swallows listener exceptions into a console warning; surface them.
@@ -342,6 +445,8 @@ async function main() {
await sleep(600); await sleep(600);
const after = (await ui()).coverage.count; const after = (await ui()).coverage.count;
assert.ok(after <= before, `${after} ≤ ${before}`); assert.ok(after <= before, `${after} ≤ ${before}`);
// The fixtures hold flat sequences too, so the filter must drop some.
if (fixtures) assert.ok(after < before, `${after} < ${before}`);
assert.equal((await ui()).filter.pano, 'pano'); assert.equal((await ui()).filter.pano, 'pano');
// The click is a user params request, so the share link records it // The click is a user params request, so the share link records it
// under Street Level's share token, whatever the ledger assigned. // under Street Level's share token, whatever the ledger assigned.
@@ -376,6 +481,11 @@ async function main() {
await sleep(600); await sleep(600);
const narrowed = await ui(); const narrowed = await ui();
assert.equal(narrowed.filter.sinceDays, 365); assert.equal(narrowed.filter.sinceDays, 365);
if (fixtures)
assert.ok(
narrowed.coverage.count < before,
`fixtures hold older sequences: ${narrowed.coverage.count} < ${before}`,
);
assert.ok( assert.ok(
narrowed.coverage.count <= before, narrowed.coverage.count <= before,
`${narrowed.coverage.count} ≤ ${before}`, `${narrowed.coverage.count} ≤ ${before}`,
@@ -393,7 +503,7 @@ async function main() {
); );
}, },
); );
await step( await photoStep(
'opening the nearest image shows the viewer with a caption', 'opening the nearest image shows the viewer with a caption',
async () => { async () => {
// Fire and forget: the open can outlive one CDP call, so poll instead. // Fire and forget: the open can outlive one CDP call, so poll instead.
@@ -604,7 +714,7 @@ async function main() {
await setStack(original); await setStack(original);
}, },
); );
await step( await photoStep(
'EXPAND opens a modal dialog and Esc returns focus to the button', 'EXPAND opens a modal dialog and Esc returns focus to the button',
async () => { async () => {
await page.click('#sl-viewer-expand'); await page.click('#sl-viewer-expand');
@@ -637,7 +747,9 @@ async function main() {
); );
}, },
); );
await step('× closes the image and deselects it on the globe', async () => { await photoStep(
'× closes the image and deselects it on the globe',
async () => {
await page.click('#sl-viewer-close'); await page.click('#sl-viewer-close');
await sleep(500); await sleep(500);
const after = await page.evaluate(() => { const after = await page.evaluate(() => {
@@ -648,7 +760,8 @@ async function main() {
}); });
assert.equal(after.open, false); assert.equal(after.open, false);
assert.equal(after.sequence, null); assert.equal(after.sequence, null);
}); },
);
const floating = () => const floating = () =>
page.$eval('#street-level-panel', (node) => page.$eval('#street-level-panel', (node) =>
node.classList.contains('panel-floating'), node.classList.contains('panel-floating'),
@@ -672,7 +785,7 @@ async function main() {
'a header drag lifts the panel out of the rail', 'a header drag lifts the panel out of the rail',
); );
}; };
await step( await photoStep(
'the panel floats on a header drag, resizes, and the viewer takes the room', 'the panel floats on a header drag, resizes, and the viewer takes the room',
async () => { async () => {
await page.evaluate((id) => { await page.evaluate((id) => {
@@ -717,7 +830,7 @@ async function main() {
); );
}, },
); );
await step( await photoStep(
'SHRINK after EXPAND docks the window back in the rail at its default size', 'SHRINK after EXPAND docks the window back in the rail at its default size',
async () => { async () => {
await page.click('#sl-viewer-expand'); await page.click('#sl-viewer-expand');
@@ -765,7 +878,7 @@ async function main() {
}); });
}, },
); );
await step( await photoStep(
'on a phone the whole photo fits in the docked panel', 'on a phone the whole photo fits in the docked panel',
async () => { async () => {
// Width alone drives the phone layout; toggling isMobile would reload. // Width alone drives the phone layout; toggling isMobile would reload.
+25
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import test from 'node:test'; import test from 'node:test';
import { import {
fixtureTile,
EXPECTED_PROVIDERS, EXPECTED_PROVIDERS,
isCollapsed, isCollapsed,
RAIL_ORDER, RAIL_ORDER,
@@ -44,3 +45,27 @@ test('the harness only runs its browser flow when executed directly', () => {
assert.match(source, /PUPPETEER_EXECUTABLE_PATH/); assert.match(source, /PUPPETEER_EXECUTABLE_PATH/);
assert.match(source, /--url/); assert.match(source, /--url/);
}); });
test('fixture tiles give the hermetic gate something real to filter', async () => {
const { decodeCoverageTile } =
await import('../layers/streetLevel/providers/mapillary/decode.js');
const now = Date.UTC(2026, 9, 1);
const street = decodeCoverageTile(fixtureTile(14, 2662, 6286, now), {
x: 2662,
y: 6286,
z: 14,
});
assert.equal(street.sequences.length, 8);
const pano = street.sequences.filter((s) => s.isPano).length;
assert.ok(pano > 0 && pano < 8, '360° and flat both present');
const year = 365 * 86_400_000;
const old = street.sequences.filter((s) => now - s.capturedAt > year).length;
assert.ok(old > 0 && old < 8, 'recent and older both present');
const orbit = decodeCoverageTile(fixtureTile(3, 1, 3, now), {
x: 1,
y: 3,
z: 3,
});
assert.ok(orbit.overview.length > 0, 'overview points from orbit');
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nothing in between');
});