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
+211 -17

No files matched your search

+45
View File
@@ -59,6 +59,51 @@ jobs:
- name: Build production bundle
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:
name: Windows onboarding
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
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
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
+1
View File
@@ -48,6 +48,7 @@
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"qa:panel-resize": "node scripts/qa-panel-resize.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",
"qa:transit": "node scripts/qa-transit.mjs",
"mcp": "node server/mcp/stdio.js"
+130 -17
View File
@@ -6,10 +6,19 @@
* 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`.
* 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 { 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';
/** Viewports every layout assertion runs at. */
export const VIEWPORTS = Object.freeze([
@@ -41,11 +50,100 @@ export function isCollapsed(classList) {
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() {
const { default: puppeteer } = await import('puppeteer');
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
const fixtures = args.includes('--fixtures');
const browser = await puppeteer.launch({
headless: true,
executablePath:
@@ -62,8 +160,13 @@ async function main() {
console.log(`ok ${passed} ${label}`);
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 {
const page = await browser.newPage();
if (fixtures) await serveFixtures(page);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
// The layer swallows listener exceptions into a console warning; surface them.
@@ -342,6 +445,8 @@ async function main() {
await sleep(600);
const after = (await ui()).coverage.count;
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');
// The click is a user params request, so the share link records it
// under Street Level's share token, whatever the ledger assigned.
@@ -376,6 +481,11 @@ async function main() {
await sleep(600);
const narrowed = await ui();
assert.equal(narrowed.filter.sinceDays, 365);
if (fixtures)
assert.ok(
narrowed.coverage.count < before,
`fixtures hold older sequences: ${narrowed.coverage.count} < ${before}`,
);
assert.ok(
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',
async () => {
// Fire and forget: the open can outlive one CDP call, so poll instead.
@@ -604,7 +714,7 @@ async function main() {
await setStack(original);
},
);
await step(
await photoStep(
'EXPAND opens a modal dialog and Esc returns focus to the button',
async () => {
await page.click('#sl-viewer-expand');
@@ -637,18 +747,21 @@ async function main() {
);
},
);
await step('× closes the image and deselects it on the globe', async () => {
await page.click('#sl-viewer-close');
await sleep(500);
const after = await page.evaluate(() => {
const u = window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState();
return { open: u.street.open, sequence: u.sequence.selectedId };
});
assert.equal(after.open, false);
assert.equal(after.sequence, null);
});
await photoStep(
'× closes the image and deselects it on the globe',
async () => {
await page.click('#sl-viewer-close');
await sleep(500);
const after = await page.evaluate(() => {
const u = window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState();
return { open: u.street.open, sequence: u.sequence.selectedId };
});
assert.equal(after.open, false);
assert.equal(after.sequence, null);
},
);
const floating = () =>
page.$eval('#street-level-panel', (node) =>
node.classList.contains('panel-floating'),
@@ -672,7 +785,7 @@ async function main() {
'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',
async () => {
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',
async () => {
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',
async () => {
// 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 test from 'node:test';
import {
fixtureTile,
EXPECTED_PROVIDERS,
isCollapsed,
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, /--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');
});