mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
7ae5f52c89
commit
284cd0a93d
5 files changed
+211
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user