mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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
|
- 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+130
-17
@@ -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,18 +747,21 @@ async function main() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await step('× closes the image and deselects it on the globe', async () => {
|
await photoStep(
|
||||||
await page.click('#sl-viewer-close');
|
'× closes the image and deselects it on the globe',
|
||||||
await sleep(500);
|
async () => {
|
||||||
const after = await page.evaluate(() => {
|
await page.click('#sl-viewer-close');
|
||||||
const u = window.__godsEyeView.dataManager.layers
|
await sleep(500);
|
||||||
.get('street-level')
|
const after = await page.evaluate(() => {
|
||||||
.module.getUIState();
|
const u = window.__godsEyeView.dataManager.layers
|
||||||
return { open: u.street.open, sequence: u.sequence.selectedId };
|
.get('street-level')
|
||||||
});
|
.module.getUIState();
|
||||||
assert.equal(after.open, false);
|
return { open: u.street.open, sequence: u.sequence.selectedId };
|
||||||
assert.equal(after.sequence, null);
|
});
|
||||||
});
|
assert.equal(after.open, false);
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user