docs(street-level): shorten docs and comments before merge

Trim the doc text this PR adds (README, CHANGELOG, CURRENT-STATE,
KNOWN-ISSUES, SECURITY, DATA_SOURCES, .env.example, the CI job comment),
drop code comments that restate the code, cut design-note JSDoc to a line
or two, and drop review tags from test names. Comments only; no code
changes.

Also: README key count (seven keys, two metered), CURRENT-STATE's gate
paragraph (CI runs a production build with a dummy token), the stacked
doc above meshBoxInRange and the orphaned inMeshRange line in
coverage.js, and a duplicate ALPR row in DATA_SOURCES that a merge had
brought back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-06 21:59:12 +01:00
1 parent c65feda4b1
commit 4ea6631425
85 files changed
+701 -1520

No files matched your search

@@ -1,32 +1,14 @@
import { readFileSync } from 'node:fs';
/**
* Mapillary Graph API and image CDN, answered from fixtures, so the Street
* Level gate's photo flow runs with no network and no real token (the CI
* mode). It covers every request the app's provider and the embedded
* MapillaryJS 4 viewer make while opening, stepping and closing a photo:
* Mapillary Graph API and image CDN answered from fixtures, covering every
* request the provider and MapillaryJS make while opening, stepping and
* closing a photo, so the gate needs no network and no real token.
*
* provider (src/layers/streetLevel/providers/mapillary/source.js)
* GET graph /images?lat&lng&radius&limit&fields nearest lookup
* GET graph /images?sequence_ids&fields&limit a sequence's cones
* MapillaryJS GraphDataProvider (node_modules/mapillary-js)
* GET graph /images?image_ids&fields image ents (core + spatial)
* GET graph /images?s2&fields core images of an S2 cell
* GET graph /image_ids?sequence_id a sequence's image ids
* GET graph /{imageId}/tiles?z&fields image tiles (imageTiling)
* GET thumb_1024_url / thumb_2048_url the photo itself
* and the CORS preflight of each Graph call (MapillaryJS sends an
* Authorization header).
*
* MapillaryJS never asks for a mesh or an SfM cluster here: the fixtures have
* no `merge_cc` (an unmerged image gets an empty mesh without a request) and
* no `sfm_cluster` url, and the spatial component that reads clusters is off.
* S2 cells answer empty: the viewer then builds no spatial edges, which the
* gate does not use (it steps along the sequence through the cones).
*
* The photo (street-640x320.jpg) is generated for this repository (a sky and
* ground gradient with posts; scripts/fixtures/street-level), so it carries
* no third-party licence.
* No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
* cluster, and empty S2 cells mean no spatial edges, which the gate does not
* use. The photo is generated for this repository, so it has no third-party
* licence.
*/
const DAY_MS = 86_400_000;
@@ -40,12 +22,12 @@ export const PHOTO_LINE = Object.freeze({
});
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
export const PHOTO_SPACING_M = 30;
/** Host the fixture thumbnails are served from (answered, never reached). */
/** Fixture thumbnail host (answered, never reached). */
export const THUMB_HOST = 'qa-fixture.mapillary.com';
const METRES_PER_DEG_LAT = 110_540;
/** Approximate ground distance in metres, as the app's metresBetween. */
/** Ground distance in metres, as the app's metresBetween. */
export function metresApart(a, b) {
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
return Math.hypot(
@@ -55,8 +37,7 @@ export function metresApart(a, b) {
}
/**
* The fixture photos along PHOTO_LINE, south to north: every third one a
* 360° panorama, the rest flat, all captured a month before `now`.
* Photos along PHOTO_LINE, south to north; every third one is a panorama.
* @returns {Array<{id: string, lon: number, lat: number, isPano: boolean, capturedAt: number, compassAngle: number}>}
*/
export function photoImages(now = Date.now()) {
@@ -112,7 +93,7 @@ function imageRecord(image) {
};
}
/** Pick the requested fields (plus id), as the Graph API answers. */
/** The requested fields plus id, as the Graph API answers. */
function withFields(record, fields) {
if (!fields) return record;
const out = { id: record.id };
@@ -136,16 +117,12 @@ const json = (status, body) => ({
});
let photoBytes = null;
/** The checked-in fixture photo, read once. */
export function photoJpeg() {
photoBytes ??= readFileSync(new URL('./street-640x320.jpg', import.meta.url));
return photoBytes;
}
/**
* The query parameters that name a Graph call (not their values), so a run
* can report which calls it answered without echoing the access token.
*/
/** Name a call by its parameter names only, so the token is never echoed. */
export function describeCall(method, url) {
const keys = [...url.searchParams.keys()]
.filter((key) => key !== 'access_token')
@@ -157,11 +134,10 @@ export function describeCall(method, url) {
}
/**
* Answer one Mapillary request from fixtures.
* Answer one Mapillary request. `known` is false for a request no fixture
* covers, so the gate fails on a new MapillaryJS call instead of missing it.
* @param {{method: string, url: string, images?: ReturnType<typeof photoImages>}} request
* @returns {{status: number, contentType?: string, headers?: object, body: string|Buffer, known: boolean}}
* `known` is false for a request no fixture covers (answered 404); the gate
* fails on those so a new MapillaryJS call cannot pass unnoticed.
*/
export function answerMapillaryRequest({ method, url, images }) {
const address = new URL(url);
+8 -20
View File
@@ -1,19 +1,12 @@
/**
* Evidence the browser QA gates (qa-street-level, qa-panel-resize) collect
* while they run and save when they fail: every page's console (last 200
* messages), failed requests and HTTP errors, Cesium render-loop errors (the
* "An error occurred while rendering" console line and `scene.renderError`)
* and, on failure, a full-page screenshot of every open page plus the Street
* Level UI state, under `qa-artifacts/<gate>/` (CI uploads that directory).
*
* Keys never reach the evidence: URLs lose their `access_token`, `key` (and
* similar) values, and `MLY|…` client tokens and Google `AIza…` keys are
* masked wherever they appear.
* Evidence the browser QA gates collect while they run and save under
* `qa-artifacts/<gate>/` when they fail (CI uploads that directory). Tokens
* and keys are masked before anything is printed or saved.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
/** How many console messages and failed requests each page keeps. */
/** Console messages and failed requests kept per page. */
export const EVIDENCE_LIMIT = 200;
/** Cesium's console line when the render loop throws (CesiumWidget). */
@@ -59,7 +52,6 @@ export function hookRenderErrors(page) {
});
}
/** Render errors the in-page hook saw in the current document. */
export async function readRenderErrors(page) {
try {
return await page.evaluate(() => window.__qaRenderErrors ?? []);
@@ -69,9 +61,8 @@ export async function readRenderErrors(page) {
}
/**
* Watch one page: console, page errors, failed requests and HTTP errors.
* `errors` (shared across pages) receives page errors, render-loop console
* errors and the extra `isError(text)` console lines a gate cares about.
* Watch one page's console, errors and failed requests. `errors` is shared
* across pages; `isError(text)` adds console lines a gate treats as errors.
* @returns {{name: string, page: object, console: Array<object>, failed: Array<object>, renderErrors: Array<string>}}
*/
export function watchPage(page, { name, errors, isError = () => false }) {
@@ -115,7 +106,6 @@ const withTimeout = (promise, ms, label) =>
),
]);
/** The Street Level layer's UI state on a page, or why it could not be read. */
function streetLevelState(page) {
return page.evaluate(() => {
const module =
@@ -125,10 +115,8 @@ function streetLevelState(page) {
}
/**
* Save the evidence of a failed run under `dir`: per page a full-page
* screenshot, the Street Level UI state, the console, failed requests and
* render errors; plus the error itself. Never throws: a page that cannot be
* captured records why instead.
* Save a screenshot and JSON record per page, plus the error. A page that
* cannot be captured records why instead.
* @returns {Promise<string>} the directory written
*/
export async function saveFailureArtifacts({ dir, browser, monitors, error }) {
+12 -33
View File
@@ -1,12 +1,8 @@
#!/usr/bin/env node
/**
* Prove the portable CCTV panel in a real browser: a header drag lifts it out
* of the right rail, every resize handle moves only its own edges, minimum
* sizes hold, the window survives a reload and a header double-click snaps
* it back and forgets the stored position. Page errors and Cesium render-loop
* errors fail the run; a failed run saves screenshots, console and failed
* requests under qa-artifacts/panel-resize/ (scripts/qa-browserEvidence.mjs).
* `--fail-on-retry` (or QA_FAIL_ON_RETRY=1) fails on any header-press retry.
* Browser QA for the floating CCTV panel: lift, resize from every handle,
* minimum size, reload, and dock again. `--fail-on-retry` (or
* QA_FAIL_ON_RETRY=1) fails on any header-press retry.
*/
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
@@ -28,7 +24,6 @@ import {
export const PANEL_ID = 'cctv-panel';
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
export const MIN_SIZE = { width: 300, height: 160 };
/** Where a failed run leaves its screenshots and logs. */
export const ARTIFACT_DIR = 'qa-artifacts/panel-resize';
const VIEWPORT = { width: 1400, height: 900 };
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
@@ -67,7 +62,7 @@ export function handlePoint(rect, dir) {
return { x, y };
}
/** Edges of `after` that moved although a resize in `dir` should have pinned them. */
/** Pinned edges that moved during a resize in `dir`. */
export function driftedEdges(before, after, dir, tolerance = 1.5) {
const edges = (box) => ({
left: box.left,
@@ -116,7 +111,6 @@ async function main() {
timeout: 90_000,
});
await page.waitForSelector(`#${PANEL_ID} .panel-header`);
// The first-run dialog sits over the globe until a choice is made.
await page.evaluate(() => {
document.querySelector('.first-run-explore')?.click();
});
@@ -152,11 +146,9 @@ async function main() {
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`),
};
}, PANEL_ID);
/** Wait until the CCTV box has held still for ten animation frames. */
/**
* Best effort: wait until the CCTV box (to the whole pixel) has held still
* for half a second. The lift is checked against the box at pointerdown,
* so a rail that keeps nudging the panel only costs the wait, not the run.
* Best effort: wait for the box to hold still for half a second. The lift
* is checked against the box at pointerdown, so a timeout only costs time.
*/
const settle = async () => {
try {
@@ -200,7 +192,6 @@ async function main() {
}, STORAGE_KEY);
await boot();
// Expand CCTV so the panel has a body worth resizing.
await page.evaluate((id) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed'))
@@ -220,10 +211,7 @@ async function main() {
assert.equal(docked.floating, false);
assert.equal(docked.inRail, true);
// Lift out: drag the header 200px left and 100px up. The window must lift
// from wherever the panel sits when the pointer goes down, so record that
// box in the page instead of trusting the earlier read.
// What sits above CCTV in the rail, to name whatever shifted it.
// Names whatever above CCTV in the rail shifted it, for a retry note.
const railAbove = () =>
page.evaluate((id) => {
const out = [];
@@ -237,11 +225,8 @@ async function main() {
return out.join(' ');
}, PANEL_ID);
const aboveAtDock = await railAbove();
// Press only where the header really is, recording what the press landed
// on. A press the rail shifted out from under the header (seen on the CI
// runner: something above CCTV grows ~32 px after load) proves nothing
// about the drag, so it is retried; a press that landed in the header and
// did not lift fails at once (scripts/qa-panelDrag.mjs).
// On CI a panel above CCTV grows after load and can shift the header out
// from under the press; the helper retries only such a provable miss.
const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -200,
dy: -100,
@@ -289,7 +274,7 @@ async function main() {
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
const railExcludes = await page.evaluate((id) => {
const rail = document.getElementById('right-context-rail');
// Same rule as the rail: hidden panels (a layer that is off) take no room.
// Same rule as the rail: hidden panels take no room.
const counted = [...rail.children].filter((panel) =>
panel.matches(
'[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])',
@@ -313,15 +298,13 @@ async function main() {
);
console.log('PASS: header drag lifts CCTV out of the rail');
// Park the window near the top: the voice dock sits above every panel at
// the bottom of the screen and would otherwise cover the south corners
// once the loop below has grown the window.
// Park near the top: the voice dock would cover the south corners once
// the loop below has grown the window.
{
const header = await headerPoint();
await drag(header, 0, 120 - header.y);
}
// Every handle moves only its own edges.
const limits = {
minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height,
@@ -332,7 +315,6 @@ async function main() {
const before = await readPanel();
const { dx, dy } = growthDelta(dir, 40);
const point = handlePoint(before, dir);
// Name what the pointer lands on, so a covered handle is obvious.
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
if (!node) return 'nothing';
@@ -350,7 +332,6 @@ async function main() {
}
console.log('PASS: eight resize handles keep the opposite edge pinned');
// Minimum size stops the moving edge.
let before = await readPanel();
await drag(handlePoint(before, 'se'), -2000, -2000);
let after = await readPanel();
@@ -365,7 +346,6 @@ async function main() {
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
console.log('PASS: minimum size holds from both corners');
// Reload restores the window.
const saved = await readPanel();
const record = JSON.parse(saved.stored);
assert.equal(record.floating, true);
@@ -376,7 +356,6 @@ async function main() {
near(restored[key], record[key], 2, `restored ${key}`);
console.log('PASS: reload restores position and size');
// Double-click the header to snap back.
const dock = await dockPanelByDoubleClick(page, PANEL_ID);
const snapped = await readPanel();
assert.equal(
+20 -61
View File
@@ -1,22 +1,10 @@
/**
* Header press-and-verify for the browser QA gates that lift a rail panel
* into a floating window (header drag) and dock it again (header
* double-click). The rail animates panel heights, so a point read once can
* be under another panel by the time the pointer goes down. Each attempt
* re-resolves the header, presses only where the header really is, records
* what the press landed on, and waits for the panel's actual state. Only a
* provable miss (a recorded press that landed outside the header) is
* retried; a press in the header that did not lift or dock, and a press that
* recorded no pointerdown at all, are real failures.
*
* Every retry is logged with what led to it (the pre-press hit check, the
* stillness wait, the panel box at the hit check and at the press) and as a
* GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 (or `--fail-on-retry`) a
* miss the panel's own movement cannot explain fails the run instead: if its
* box was the same at the hit check and at the press, the pointer missed a
* header that stood still. A panel whose content changed its layout between
* the two (a photo finishing loading, a live count wrapping a line) is
* retried with a warning that names the move.
* Header press-and-verify for the QA gates that lift a rail panel into a
* floating window and dock it again. The rail animates panel heights, so a
* point read once can be under another panel by the time the pointer goes
* down. Only a provable miss (a recorded press outside the header) is
* retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the
* panel's own movement cannot explain fails the run instead.
*/
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -34,18 +22,15 @@ export function failOnRetryDefault(
}
/**
* Whether an attempt proves nothing about the panel: it did not reach the
* wanted state and the recorded press demonstrably landed outside the
* header. No recorded press (`pressed` null) is not a miss but a failure:
* the pointer never reached the page, which a retry would only hide.
* A failed attempt whose press landed outside the header. No recorded press
* is a failure, not a miss: a retry would only hide it.
* @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt
*/
export function pressMissed(attempt) {
return !attempt.done && attempt.pressed?.inHeader === false;
}
/** The panel's box, rounded, as `left,top widthxheight`. */
/** Whether the panel's box changed (by more than 2 px) between two reads. */
/** Whether the panel's box changed by more than 2 px between two reads. */
export function panelMoved(before, after) {
if (!before || !after) return false;
return ['left', 'top', 'width', 'height'].some(
@@ -53,6 +38,7 @@ export function panelMoved(before, after) {
);
}
/** The panel's box, rounded, as `left,top widthxheight`. */
export function formatBox(box) {
if (!box) return 'unknown';
const r = (value) => Math.round(value ?? NaN);
@@ -66,7 +52,6 @@ function panelBox(page, panelId) {
}, panelId);
}
/** The centre of the panel's header title, read now. */
export function headerPoint(page, panelId) {
return page.evaluate((id) => {
const title = document.querySelector(`#${id} .panel-header .panel-title`);
@@ -75,7 +60,7 @@ export function headerPoint(page, panelId) {
}, panelId);
}
/** Name the element at a viewport point, and whether it is the panel's header. */
/** Name the element at a point, and whether it is the panel's header. */
export function hitAt(page, point, panelId) {
return page.evaluate(
({ x, y, id }) => {
@@ -91,9 +76,8 @@ export function hitAt(page, point, panelId) {
}
/**
* The header point to press, re-resolved until it lands on the header (or the
* bounded wait runs out; the press then records the miss). `timedOut` says
* the hit check never passed; `box` is the panel's box at the last check.
* Re-resolve the header point until it hits the header or the wait runs out
* (the press then records the miss).
*/
export async function headerTarget(
page,
@@ -112,10 +96,8 @@ export async function headerTarget(
}
/**
* Best effort: wait until the panel's box (to the whole pixel) has held still
* for `stillMs`, so a rail still animating heights does not move the header
* between the hit check and the press. Never fails the run; resolves to
* whether the box held still in time.
* Best effort: wait until the panel's box has held still for `stillMs`, so
* the header does not move between the hit check and the press.
*/
export async function waitForStill(
page,
@@ -145,10 +127,7 @@ export async function waitForStill(
}
}
/**
* Record what the next pointerdown lands on, with the panel's box at that
* moment, in `window.__qaPressBox` (null until a press arrives).
*/
/** Record the next pointerdown's target and the panel's box at that moment. */
export function recordNextPress(page, panelId) {
return page.evaluate((id) => {
window.__qaPressBox = null;
@@ -162,7 +141,6 @@ export function recordNextPress(page, panelId) {
top: r.top,
width: r.width,
height: r.height,
// What the press landed on, so a failed press says why.
target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`,
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
};
@@ -172,12 +150,10 @@ export function recordNextPress(page, panelId) {
}, panelId);
}
/** The press recorded by `recordNextPress`, or null. */
function readPress(page) {
return page.evaluate(() => window.__qaPressBox ?? null);
}
/** Wait (bounded) until the panel is, or is not, a floating window. */
export async function waitForFloating(
page,
panelId,
@@ -200,10 +176,7 @@ export async function waitForFloating(
}
}
/**
* Wait for the page to paint twice: pointer handlers apply synchronously, so
* by then every layout change an input caused is on screen.
*/
/** Two paints: by then every layout change an input caused is on screen. */
export function nextFrames(page) {
return page.evaluate(
() =>
@@ -213,7 +186,6 @@ export function nextFrames(page) {
);
}
/** Press at `from`, move by (dx, dy) in two legs, release. */
export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
@@ -223,10 +195,6 @@ export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
await nextFrames(page);
}
/**
* One line naming why a press is retried: what it landed on, the pre-press
* hit check, the stillness wait and the panel's box at the check and press.
*/
export function describeRetry(
panelId,
attempt,
@@ -243,10 +211,7 @@ export function describeRetry(
.join('; ');
}
/**
* Run `press` (one attempt at the header) until it reaches the wanted state,
* a press in the header fails to, or the retries run out.
*/
/** Retry `press` only while it provably misses the header. */
async function pressUntilDecided(
page,
panelId,
@@ -293,8 +258,6 @@ async function pressUntilDecided(
/**
* Lift a docked panel into a floating window with a header drag.
* @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>}
* `done` is whether the panel is floating; `pressed` names what the press
* landed on and the panel's box at that moment.
*/
export function liftPanelByHeader(
page,
@@ -320,11 +283,7 @@ export function liftPanelByHeader(
);
}
/**
* Dock a floating panel with a header double-click, waiting on the docked
* state rather than a fixed sleep. Same result shape as `liftPanelByHeader`;
* `done` is whether the panel is docked.
*/
/** Dock a floating panel with a header double-click; same result as lifting. */
export function dockPanelByDoubleClick(
page,
panelId,
@@ -348,7 +307,7 @@ export function dockPanelByDoubleClick(
);
}
/** One line naming where an attempt pressed, for an assertion message. */
/** Where an attempt pressed, for an assertion message. */
export function describePress(attempt) {
const { point, hit, pressed } = attempt;
const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`;
+37 -99
View File
@@ -1,30 +1,16 @@
#!/usr/bin/env node
/**
* Browser QA for the Street Level layer against a running server: the
* panel's place in the right rail, the provider chips (which switch the
* layer), the keyless gate, coverage and its credit, the imagery filter and
* SINCE slider, a click on the globe that selects a sequence, the viewer
* (opened, stepped, expanded as a modal dialog, closed), the framing flight,
* the panel as a floating, resizable window, a reload that keeps the panel
* collapsed, and a phone layout. Run with
* Browser QA for the Street Level layer against a running server. Run with
* `npm run qa:street-level -- --url http://localhost:4173`.
*
* `--fixtures` makes the run hermetic for Mapillary (the CI mode): coverage
* tiles, the Graph API, the image CDN and every other *.mapillary.com
* request the page makes are answered from fixtures (fixtureTile and
* scripts/fixtures/street-level/mapillaryGraph.mjs), and nothing reaches
* Mapillary. The server still needs a token, because the browser bundle
* carries it; CI gives it a dummy, non-secret one, which never leaves the
* browser. Fixture runs add what a live run cannot stage: a key Mapillary
* rejects, and a second page whose server has no key. Every run first asks
* the server's real status route from Node, before the browser intercepts
* anything, so a server without the Mapillary routes fails.
* `--fixtures` (the CI mode) answers every *.mapillary.com request from
* fixtures, so nothing reaches Mapillary. The server still needs a token
* because the bundle carries it; CI uses a dummy one. Fixture runs also stage
* a rejected key and a keyless second server.
*
* Steps print `ok N …`, or `skip (reason) N …` when they cannot run here or
* only part of them could. `--strict` fails on any skip whose reason is not
* in STRICT_ALLOWED_SKIPS. A failed run saves screenshots, the Street Level
* UI state, console and failed requests under qa-artifacts/street-level/.
* `--fail-on-retry` (or QA_FAIL_ON_RETRY=1) fails on any header-press retry.
* `--strict` fails on any skip not in STRICT_ALLOWED_SKIPS. `--fail-on-retry`
* (or QA_FAIL_ON_RETRY=1) fails on any header-press retry. A failed run saves
* evidence under qa-artifacts/street-level/.
*/
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
@@ -56,37 +42,28 @@ const PANEL_ID = 'street-level-panel';
/** Where a failed run leaves its screenshots and logs. */
export const ARTIFACT_DIR = 'qa-artifacts/street-level';
/** Viewports every layout assertion runs at. */
export const VIEWPORTS = Object.freeze([
{ width: 1440, height: 900 },
{ width: 1280, height: 800 },
]);
/** Provider chips the panel must show, in order (one per registered provider). */
/** Provider chips the panel must show, in order. */
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']);
/**
* Skip reasons `--strict` accepts, because no setup of this gate can avoid
* them:
* - `no Google 3D`: the map stack needs a Google key (CI forks have none);
* the steps still run their non-Google half.
* - `fixtures only`: staging a rejected key or a keyless second server needs
* the fixture interception; a live run cannot.
* Not accepted: `no Mapillary key`. CI starts the server with a dummy token
* so the whole keyed flow runs hermetically; a keyless strict run means that
* wiring broke and the photo flow silently did not run.
* Skips no setup can avoid: CI forks have no Google key, and live runs cannot
* stage fixture-only cases. `no Mapillary key` is not accepted: CI's dummy
* token runs the keyed flow, so a keyless strict run means that wiring broke.
*/
export const STRICT_ALLOWED_SKIPS = Object.freeze([
'no Google 3D',
'fixtures only',
]);
/** Skips `--strict` rejects. */
export function strictViolations(skips) {
return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason));
}
/** Street Level's share-link token, as the ledger assigned it. */
const STREET_LEVEL_TOKEN = LAYER_STATE_REGISTRY.find(
(entry) => entry.id === 'street-level',
).token;
@@ -94,7 +71,7 @@ const STREET_LEVEL_TOKEN = LAYER_STATE_REGISTRY.find(
/** The Street Level panel's token in the share link's `ui` panel state. */
export const PANEL_UI_TOKEN = 't';
/** Expected right-rail order once the layout controller has run. */
/** Right-rail order once the layout controller has run. */
export const RAIL_ORDER = Object.freeze([
'pp-toggles',
'cctv-panel',
@@ -104,7 +81,7 @@ export const RAIL_ORDER = Object.freeze([
'global-context-panel',
]);
/** The parked camera: over downtown Sacramento, looking north and down. */
/** Over downtown Sacramento, looking north and down. */
export const PARK = Object.freeze({
lon: -121.4944,
lat: 38.5816,
@@ -121,11 +98,8 @@ 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) plus, in the tiles it crosses, the
* photo sequence whose images the Graph fixtures serve (PHOTO_LINE, under
* the parked camera); at overview zooms a few points.
* Deterministic coverage for any tile: a grid of mixed 360°/flat, new/old
* sequences plus the photo sequence (PHOTO_LINE), or a few overview points.
* @returns {Uint8Array} empty for zooms that carry nothing
*/
export function fixtureTile(z, x, y, now = Date.now()) {
@@ -193,12 +167,9 @@ export function fixtureTile(z, x, y, now = Date.now()) {
}
/**
* Ask the server's real Mapillary status route, outside the browser's
* interception, and require its JSON shape. Fixture runs answer the route in
* the page, so without this a server that never registered it would pass.
* Node sends no Origin or Sec-Fetch-Site, so the same-site gate admits it.
* @param {string} url the application's base URL
* @param {typeof fetch} [fetchImpl]
* Ask the real status route from Node, outside the page's interception, so a
* server that never registered it fails even in fixture runs. Node sends no
* Origin, so the same-site gate admits it.
* @returns {Promise<{configured: boolean}>}
*/
export async function assertRealStatusRoute(url, fetchImpl = fetch) {
@@ -236,21 +207,13 @@ export async function assertRealStatusRoute(url, fetchImpl = fetch) {
}
/**
* Answer the page's Mapillary requests from fixtures (CDP Fetch on just the
* Mapillary URLs); nothing reaches Mapillary. `fixture` is live:
* - `configured`: what the status route says in the page; undefined lets the
* server's real route answer (the main page: CI's dummy token is real).
* - `tiles: 'rejected'` answers tiles as the proxy does when Mapillary
* refuses the token; `tileRequests` counts tile requests.
* - `calls` collects the Graph/CDN calls answered (parameter names only, no
* token); `unknown` the ones no fixture covers (answered 404).
* @param {object} page
* Answer the page's Mapillary requests from `fixture`, which steps may change
* mid-run. `configured: undefined` lets the server's real status route answer.
* @param {{configured?: boolean, tiles: 'ok'|'rejected', tileRequests: number, images: Array<object>, calls: Set<string>, unknown: Array<string>}} fixture
*/
export async function serveFixtures(page, fixture) {
// Only these URLs pause. Puppeteer's page-wide interception would pause
// Cesium's worker scripts too, and under it they stall: no terrain, no
// ground polylines, nothing to click.
// CDP Fetch on just these URLs: Puppeteer's page-wide interception stalls
// Cesium's worker scripts, leaving nothing to click.
const cdp = await page.createCDPSession();
await cdp.send('Fetch.enable', {
patterns: [
@@ -351,7 +314,6 @@ async function settle(page, fn, arg, { timeout = 10_000 } = {}) {
}
}
/** Clear the first-run dialog once the app is up. */
async function clearFirstRun(page) {
await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), {
timeout: 150_000,
@@ -382,7 +344,6 @@ export async function boot(page, url) {
await clearFirstRun(page);
}
/** Panel geometry and the bits of state the steps assert on. */
function readPanel(page) {
return page.evaluate(() => {
const el = document.getElementById('street-level-panel');
@@ -417,7 +378,6 @@ function waitForCoverage(page, { timeout = 90_000 } = {}) {
);
}
/** Without a key on the server the panel gates its controls and says why. */
async function assertKeylessGate(page) {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
@@ -443,13 +403,9 @@ async function assertKeylessGate(page) {
}
/**
* Find a canvas point where a real click picks the wanted Street Level item:
* `prefix` + `id` (or any id with the prefix when `id` is null), searched
* around projected `points` ([lon, lat] at the ground) or, without points,
* on a grid over the middle of the screen. `billboardIds` aims at those
* billboards; `billboardPrefix` at every shown billboard whose id starts with
* it (a live run's cones, whose positions the gate does not know). Only points where the Cesium
* canvas is the topmost element count. Runs in the page.
* Runs in the page. Find a point where a real click picks `prefix` + `id` (any
* id when null), searched around `points` ([lon, lat]), the aimed billboards,
* or else a grid mid-screen. Only points where the canvas is topmost count.
* @returns {{x: number, y: number, id: string}|null} client coordinates
*/
export function findPickPoint({
@@ -464,7 +420,6 @@ export function findPickPoint({
const canvas = scene.canvas;
const rect = canvas.getBoundingClientRect();
const Cartographic = viewer.camera.positionCartographic.constructor;
// scene.pick reads x and y; a Cartesian2 when one is at hand.
let Cartesian2 = null;
const windowPoint = (x, y) => (Cartesian2 ? new Cartesian2(x, y) : { x, y });
const wanted = (picked) => {
@@ -553,10 +508,7 @@ async function main() {
});
let count = 0;
const skips = [];
/**
* Run one step. `fn` gets `skip(reason)`: the step still runs whatever it
* can, but prints (and records) as a skip instead of `ok`.
*/
/** `fn` gets `skip(reason)`: the step runs what it can but reports a skip. */
const step = async (label, fn) => {
let skipped = null;
const result = await fn({
@@ -575,7 +527,7 @@ async function main() {
const skipStep = (reason, label) => step(label, ({ skip }) => skip(reason));
const errors = [];
const monitors = [];
/** Collect page errors, render-loop errors and the listener exceptions the layer only warns about. */
/** The layer only warns on listener exceptions, so count those as errors. */
const watch = async (page, name) => {
monitors.push(
watchPage(page, {
@@ -588,7 +540,6 @@ async function main() {
};
const renderErrorsSeen = [];
try {
// Before any interception: fixture runs answer Mapillary in the page.
await step(
'the server registers the real Mapillary status route',
async () => {
@@ -712,9 +663,8 @@ async function main() {
);
async function keyedSteps() {
// The startup flight can still be running: cancel it, park the camera
// over downtown Sacramento and confirm it stays put. A stability window,
// not a state: a late startup flight can only show itself by moving.
// A late startup flight only shows itself by moving the camera, so park
// and check it stays put rather than reading a state.
const park = async () => {
for (let attempt = 0; attempt < 6; attempt++) {
await page.evaluate((at) => {
@@ -765,10 +715,7 @@ async function main() {
.get('street-level')
.module.getUIState(),
);
/**
* Wait until the UI state satisfies `test(u, arg)`, a self-contained
* function run in the page; false when it never does.
*/
/** `test(u, arg)` runs in the page, so it must be self-contained. */
const uiUntil = (test, arg = null, options) =>
settle(
page,
@@ -954,8 +901,7 @@ async function main() {
await step(
'the MapillaryJS viewer loads ahead of the first photo',
async () => {
// Prewarm stands the lazily imported viewer up in the panel's host; a
// broken dynamic import would otherwise only show at the first click.
// Catches a broken dynamic import before the first click would.
await page.waitForSelector(
'#sl-viewer.mapillary-viewer .mapillary-dom',
{ timeout: 60_000 },
@@ -969,7 +915,6 @@ async function main() {
{ timeout: 30_000 },
text,
);
/** Point the camera somewhere new and refresh coverage there now. */
const panTo = (lon, lat) =>
page.evaluate(
(at) => {
@@ -987,10 +932,7 @@ async function main() {
},
{ lon, lat },
);
/**
* Wait until no tile request has arrived for `quietMs` (longer than the
* camera-move debounce, so a refresh the pan scheduled has had its turn).
*/
/** Wait until no tile request has arrived for `quietMs`. */
const tileRequestsQuiet = async (quietMs, timeout = 30_000) => {
const start = Date.now();
let last = fixture.tileRequests;
@@ -1547,10 +1489,8 @@ async function main() {
const r = node.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
});
// Lifting and docking re-resolve the header and retry only a press that
// provably missed it: right after a dock the rail is still animating, and
// a title point read once can be under another panel (scripts/
// qa-panelDrag.mjs, shared with the panel-resize gate).
// Right after a dock the rail is still animating, so the shared helpers
// re-resolve the header and retry only a press that provably missed it.
const floatPanel = async () => {
const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -400,
@@ -1649,7 +1589,6 @@ async function main() {
);
await step('a header double-click docks a floating window', async () => {
await floatPanel();
// Waits on the docked state itself, not a fixed sleep.
const attempt = await dockPanelByDoubleClick(page, PANEL_ID);
assert.equal(
attempt.done,
@@ -1751,9 +1690,8 @@ async function main() {
.get('street-level')
.module.closeViewer(),
);
// The button is what the user presses; a toast over it must not make
// this step about pointer aim, so press it through the DOM and wait
// for the panel to say it is collapsed.
// Press through the DOM: a toast over the button must not make this
// step about pointer aim.
await page.$eval(
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
(button) => {