mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user