From fb024d582b675e91ee7c84ea48f9a633c65500d8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 6 Oct 2026 16:54:06 +0100 Subject: [PATCH] test(street-level): run the photo flow in CI against a production build - The CI gate builds the app and serves it with `vite preview`, with a dummy non-secret client token (a fork PR never gets secrets), and answers the Graph API, image tiles and thumbnails from fixtures: 41 photos on a line under the parked camera and one generated 640x320 JPEG (scripts/fixtures/street-level). All 7 photo steps now run in CI, plus a real click on a coverage line and on a cone, the framing flight stopping on close, the inert modal, the viewer returning into the panel, and a reload keeping a collapsed panel collapsed (share ui token t). - Fixture interception is limited to Mapillary hosts via CDP Fetch.enable: page-wide request interception stalled Cesium's workers, so the old fixture mode never drew anything on the globe. - Failure evidence (screenshots, UI state, console, failed requests, render errors) is written to qa-artifacts/ with keys and tokens redacted and uploaded on failure; qa-artifacts/ is gitignored. - Cesium render-loop errors fail the gate; Google 3D branches that cannot run print as skips, and --strict rejects any other skip. - Panel presses log their diagnostics, warn on a retry, and CI now fails on any retry (QA_FAIL_ON_RETRY=1) since the rail settles; a press with no pointerdown is a failure, not a retry. - Assertion-gating sleeps 27 -> 1; the rejected-key "no requests" check has a positive control. Verified locally: strict fixture gate on the production build 28 ok + 2 "no Google 3D" skips (twice, fail-on-retry on); panel gate 5/5 plus no render errors; live gate with a real token 25 ok + 3 fixture-only skips. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 46 +- .gitignore | 2 + .../fixtures/street-level/mapillaryGraph.mjs | 242 ++ .../fixtures/street-level/street-640x320.jpg | Bin 0 -> 3697 bytes scripts/format-scope.json | 2 + scripts/qa-browserEvidence.mjs | 189 ++ scripts/qa-panel-resize.mjs | 51 +- scripts/qa-panelDrag.mjs | 152 +- scripts/qa-street-level.mjs | 2240 +++++++++++------ src/tooling/streetLevelQa.test.mjs | 344 ++- 10 files changed, 2468 insertions(+), 800 deletions(-) create mode 100644 scripts/fixtures/street-level/mapillaryGraph.mjs create mode 100644 scripts/fixtures/street-level/street-640x320.jpg create mode 100644 scripts/qa-browserEvidence.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26ed0abbc..0707c8d20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,15 +60,22 @@ jobs: 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. + # The Street Level gate in its hermetic mode, against a production build + # served by `vite preview`: coverage tiles, the Graph API and the image + # CDN are answered from fixtures (scripts/fixtures/street-level), so no + # real token and no Mapillary network are needed, and the whole photo + # flow runs. The server gets a dummy, non-secret client token: the build + # bakes it into the bundle (the viewer needs one to ask for photos) and + # the preview server's status route reports it as configured. The page + # intercepts every *.mapillary.com request, so the token never leaves it. name: Street Level browser gate runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 env: GEV_REQUIRE_ALLOCATION_GATE: '0' + # A header press that misses is a real layout shift under the pointer + # (the right rail now aligns to where the left rail settles), not noise. + QA_FAIL_ON_RETRY: '1' steps: - name: Check out source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -84,25 +91,42 @@ jobs: - name: Install locked dependencies and headless Chrome run: npm ci - - name: Start the dev server + - name: Build production bundle with a dummy Mapillary token + env: + MAPILLARY_CLIENT_TOKEN: 'MLY|0|qa-fixture' + run: npm run build + + - name: Start the preview server + env: + MAPILLARY_CLIENT_TOKEN: 'MLY|0|qa-fixture' run: | - npx vite --port 4173 --strictPort > vite.log 2>&1 & + npx vite preview --port 4173 --strictPort > preview.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 + cat preview.log exit 1 - name: Street Level gate with Mapillary fixtures - run: npm run qa:street-level:fixtures -- --url http://localhost:4173 + run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173 - name: Portable panel gate + if: success() || failure() run: npm run qa:panel-resize -- --url http://localhost:4173 - - name: Dev server log + - name: Preview server log if: failure() - run: cat vite.log + run: cat preview.log + + - name: Upload gate evidence + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: browser-gate-evidence + path: qa-artifacts/ + if-no-files-found: ignore + retention-days: 14 windows-onboarding: name: Windows onboarding diff --git a/.gitignore b/.gitignore index 1a64fff39..14e8d320b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ qa-shots/ output/ .gev-logs/ .gev-cache/ +# Browser QA gate evidence (screenshots, console and network dumps). +qa-artifacts/ .DS_Store .gstack/ 3d-models/ diff --git a/scripts/fixtures/street-level/mapillaryGraph.mjs b/scripts/fixtures/street-level/mapillaryGraph.mjs new file mode 100644 index 000000000..24691b836 --- /dev/null +++ b/scripts/fixtures/street-level/mapillaryGraph.mjs @@ -0,0 +1,242 @@ +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: + * + * 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. + */ + +const DAY_MS = 86_400_000; + +/** The one sequence with photos: a north-south line through the parked view. */ +export const PHOTO_SEQUENCE_ID = 'fx-photo'; +export const PHOTO_LINE = Object.freeze({ + lon: -121.4944, + south: 38.579, + north: 38.591, +}); +/** 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). */ +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. */ +export function metresApart(a, b) { + const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180; + return Math.hypot( + (b.lon - a.lon) * 111_320 * Math.cos(lat), + (b.lat - a.lat) * METRES_PER_DEG_LAT, + ); +} + +/** + * The fixture photos along PHOTO_LINE, south to north: every third one a + * 360° panorama, the rest flat, all captured a month before `now`. + * @returns {Array<{id: string, lon: number, lat: number, isPano: boolean, capturedAt: number, compassAngle: number}>} + */ +export function photoImages(now = Date.now()) { + const step = PHOTO_SPACING_M / METRES_PER_DEG_LAT; + const count = + Math.floor((PHOTO_LINE.north - PHOTO_LINE.south) / step + 1e-9) + 1; + return Array.from({ length: count }, (_, i) => ({ + id: String(9_100_000_000_000 + i), + lon: PHOTO_LINE.lon, + lat: Number((PHOTO_LINE.south + i * step).toFixed(7)), + isPano: i % 3 === 0, + capturedAt: now - 30 * DAY_MS - i * 1000, + compassAngle: 0, + })); +} + +const point = (image) => ({ + type: 'Point', + coordinates: [image.lon, image.lat], +}); + +/** Every field either caller asks for; extra fields are harmless to both. */ +function imageRecord(image) { + const thumb = `https://${THUMB_HOST}/thumb/${image.id}.jpg`; + return { + id: image.id, + sequence: PHOTO_SEQUENCE_ID, + geometry: point(image), + computed_geometry: point(image), + altitude: 12, + computed_altitude: 12, + atomic_scale: 1, + camera_parameters: image.isPano ? [] : [0.85, 0, 0], + camera_type: image.isPano ? 'spherical' : 'perspective', + captured_at: image.capturedAt, + compass_angle: image.compassAngle, + computed_compass_angle: image.compassAngle, + // Angle-axis world-to-camera rotation: +90° about east looks north, level. + computed_rotation: [Math.PI / 2, 0, 0], + creator: { id: '1', username: 'qa-fixture' }, + exif_orientation: 1, + height: 320, + width: 640, + is_pano: image.isPano, + merge_cc: null, + mesh: null, + organization: null, + quality_score: 0.9, + sfm_cluster: null, + thumb_256_url: thumb, + thumb_1024_url: thumb, + thumb_2048_url: thumb, + }; +} + +/** Pick the requested fields (plus id), as the Graph API answers. */ +function withFields(record, fields) { + if (!fields) return record; + const out = { id: record.id }; + for (const field of fields.split(',')) + if (field in record) out[field] = record[field]; + return out; +} + +const CORS = Object.freeze({ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type', + 'Access-Control-Max-Age': '600', +}); + +const json = (status, body) => ({ + status, + contentType: 'application/json', + headers: CORS, + body: JSON.stringify(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. + */ +export function describeCall(method, url) { + const keys = [...url.searchParams.keys()] + .filter((key) => key !== 'access_token') + .sort(); + const path = url.pathname + .replace(/^\/\d{6,}(?=\/|$)/, '/{imageId}') + .replace(/^\/thumb\/[\w-]+\.jpg$/, '/thumb/{imageId}.jpg'); + return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`; +} + +/** + * Answer one Mapillary request from fixtures. + * @param {{method: string, url: string, images?: ReturnType}} 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); + const photos = images ?? photoImages(); + const byId = new Map(photos.map((image) => [image.id, image])); + if (method === 'OPTIONS') + return { status: 204, headers: CORS, body: '', known: true }; + if (address.hostname === THUMB_HOST) { + if (/^\/thumb\/[\w-]+\.jpg$/.test(address.pathname)) + return { + status: 200, + contentType: 'image/jpeg', + headers: CORS, + body: photoJpeg(), + known: true, + }; + return { ...json(404, { error: 'no such fixture' }), known: false }; + } + if (address.hostname !== 'graph.mapillary.com') + return { ...json(404, { error: 'no such fixture host' }), known: false }; + const q = address.searchParams; + const fields = q.get('fields'); + const path = address.pathname; + if (path === '/images') { + if (q.has('image_ids')) { + const data = q + .get('image_ids') + .split(',') + .map((id) => byId.get(id)) + .filter(Boolean) + .map((image) => withFields(imageRecord(image), fields)); + return { ...json(200, { data }), known: true }; + } + if (q.has('sequence_ids')) { + const ids = q.get('sequence_ids').split(','); + const limit = Number(q.get('limit')) || photos.length; + const data = ids.includes(PHOTO_SEQUENCE_ID) + ? photos + .slice(0, limit) + .map((image) => withFields(imageRecord(image), fields)) + : []; + return { ...json(200, { data }), known: true }; + } + if (q.has('lat') && q.has('lng')) { + const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) }; + const radius = Math.min(50, Number(q.get('radius')) || 50); + const limit = Number(q.get('limit')) || 10; + // In no particular order, as the real API answers. + const data = photos + .filter((image) => metresApart(at, image) <= radius) + .reverse() + .slice(0, limit) + .map((image) => withFields(imageRecord(image), fields)); + return { ...json(200, { data }), known: true }; + } + if (q.has('s2')) return { ...json(200, { data: [] }), known: true }; + return { + ...json(400, { error: 'unsupported images query' }), + known: false, + }; + } + if (path === '/image_ids' && q.has('sequence_id')) { + const data = + q.get('sequence_id') === PHOTO_SEQUENCE_ID + ? photos.map((image) => ({ id: image.id })) + : []; + return { ...json(200, { data }), known: true }; + } + const tiles = path.match(/^\/([\w-]+)\/tiles$/); + if (tiles) return { ...json(200, { data: [] }), known: true }; + const entity = path.match(/^\/([\w-]+)$/); + if (entity && byId.has(entity[1])) + return { + ...json(200, withFields(imageRecord(byId.get(entity[1])), fields)), + known: true, + }; + return { ...json(404, { error: 'no such fixture' }), known: false }; +} diff --git a/scripts/fixtures/street-level/street-640x320.jpg b/scripts/fixtures/street-level/street-640x320.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d3788d6205a28a58c507ed3a40ef0ea3670a46e2 GIT binary patch literal 3697 zcmex=5A1R0qH8UG()kYr#40|o?OVq|A!21+RjFfuYQu&{$jC=g^~VKZZ7W;JPS+O#nc zs74B+8%YTRGYcaVD;rQwS`b50klBcliN)~$Ee0NDMxY~@1sUuaemiLi{+jjUWJ`#m z%ONd+MTw#ufd*YI8Z%s-M0}JMa;XUhvqaOxaj1)>}aA9c0_0Nn<3lB3HZ4WJ8I zg*g^1N)!RQvx#m}IZ6xVvR4jpU=WNDU}#yrqI1Y#iv>}R74EZM4c0UT3r1I7Iqfx4 zA_*53v$aZ?XdGPV#>-`p5!me1Lv_J(Cexmss{WHyGUM4T@8VS3e}+~uDS!TK$-e{) zq>BN_Ij%2r$#%2g`;Uc}95=fZJzMkr$IeTS?Vmrb{RLDdrNxPy2G3$KHvP|^_4_?r zLX=${ku$XAvPpMt`q%gPO_sUsnU}MC|1ov%p8pKZpFv`-p7FoB@cT;+X$dY`fLx%+ zT!z~0*_R9Tx`O@~{dnE{TeER-NE`YR6&BP+|kzPpz8z=X3<@Hix~M4?9N zeoRwc3L(i=3^iHlU;MpT9hjakFP`z@?d8e9Wc5l=rV<>buB%WI_USKmka&FY%*wjt z$zNc8X>pf>q^ZH>(laJ`uRtlTR9x@k?$sdQUAg)BpGoc|SFS5U8X6NFUAnj|(|Yel zf`V|-(_7}HDInj>iu}(YetSum=n9p9Kwv#4B2si#dezw zJQgf+Wd$1dbQV;9_ww3>HLqiyF3;~HGwUf=;*Ek^H0ofw|8qm=_c6Q{?YG}iB4Nty12m3 z?u&%#Ua_+b>cwT#>W_YVsnnXqDIx-PwM_Q1?|=5&&xw0|=;ht-`sbgE@Azq5^2=93 z$s~bSg4vDX!>|1M)1QAHeYZz%?){=a{|c+Sb1z(MQg&mw*kF>t&yS&f+g;uvpm}-u z9gweYAA$OM+rGOD91Wm0-AAkPT9Aj|?(u_qI6LE%w!x=OTYd7GqRkP8 zkDs2g(S2M|FSo$+Ng7N8-PnCUP*=#m7JKJ0L}6xyDoRvzVW| z;-38R75wq>aeu383mBhX02xtPX)`Z*>a2tP@_LC6^Q8~}_{#YD_3QiV^Vc!h>;fs^ zVJHAm/` (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. + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** How many console messages and failed requests each page keeps. */ +export const EVIDENCE_LIMIT = 200; + +/** Cesium's console line when the render loop throws (CesiumWidget). */ +export const RENDER_ERROR_PATTERN = /An error occurred while rendering/i; + +/** Mask tokens in a URL or message before it is printed or saved. */ +export function redact(text) { + return String(text ?? '') + .replace( + /([?&](?:access_token|token|key|api_key|apikey|client_secret|signature)=)[^&\s"']*/gi, + '$1REDACTED', + ) + .replace(/MLY\|[^\s&"',)]+/g, 'MLY|REDACTED') + .replace(/AIza[\w-]{30,}/g, 'AIza…REDACTED'); +} + +function push(list, entry) { + list.push(entry); + if (list.length > EVIDENCE_LIMIT) + list.splice(0, list.length - EVIDENCE_LIMIT); +} + +/** + * Subscribe to `viewer.scene.renderError` in every document the page loads + * (reloads included), into `window.__qaRenderErrors`. Call before `goto`. + */ +export function hookRenderErrors(page) { + return page.evaluateOnNewDocument(() => { + window.__qaRenderErrors = []; + const hook = () => { + const scene = window.__godsEyeView?.viewer?.scene; + if (!scene?.renderError) return false; + scene.renderError.addEventListener((_, error) => + window.__qaRenderErrors.push( + String(error?.stack || error?.message || error), + ), + ); + return true; + }; + const timer = setInterval(() => { + if (hook()) clearInterval(timer); + }, 250); + }); +} + +/** Render errors the in-page hook saw in the current document. */ +export async function readRenderErrors(page) { + try { + return await page.evaluate(() => window.__qaRenderErrors ?? []); + } catch { + return []; + } +} + +/** + * 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. + * @returns {{name: string, page: object, console: Array, failed: Array, renderErrors: Array}} + */ +export function watchPage(page, { name, errors, isError = () => false }) { + const monitor = { name, page, console: [], failed: [], renderErrors: [] }; + page.on('console', (message) => { + const text = redact(message.text()); + push(monitor.console, { type: message.type(), text }); + if (message.type() === 'error' && RENDER_ERROR_PATTERN.test(text)) { + monitor.renderErrors.push(text); + errors.push(`[${name}] render loop: ${text}`); + } else if (isError(text)) errors.push(`[${name}] ${text}`); + }); + page.on('pageerror', (error) => { + const text = redact(error?.stack || error?.message || error); + push(monitor.console, { type: 'pageerror', text }); + errors.push(`[${name}] ${text}`); + }); + page.on('requestfailed', (request) => + push(monitor.failed, { + url: redact(request.url()), + method: request.method(), + failure: request.failure()?.errorText ?? 'failed', + }), + ); + page.on('response', (response) => { + if (response.status() < 400) return; + push(monitor.failed, { + url: redact(response.url()), + method: response.request().method(), + status: response.status(), + }); + }); + return monitor; +} + +const withTimeout = (promise, ms, label) => + Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label} timed out`)), ms), + ), + ]); + +/** 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 = + window.__godsEyeView?.dataManager?.layers?.get('street-level')?.module; + return module?.getUIState ? module.getUIState() : null; + }); +} + +/** + * 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. + * @returns {Promise} the directory written + */ +export async function saveFailureArtifacts({ dir, browser, monitors, error }) { + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, 'error.txt'), + redact(error?.stack || error?.message || String(error)), + ); + const known = new Map(monitors.map((monitor) => [monitor.page, monitor])); + let pages = []; + try { + pages = await browser.pages(); + } catch { + pages = monitors.map((monitor) => monitor.page); + } + let index = 0; + for (const page of pages) { + const monitor = known.get(page); + // The browser's first blank tab holds nothing worth saving. + if (!monitor && page.url() === 'about:blank') continue; + const base = `${String(index++).padStart(2, '0')}-${monitor?.name ?? 'page'}`; + const record = { url: redact(page.url()) }; + try { + await withTimeout( + page.screenshot({ + path: path.join(dir, `${base}.png`), + fullPage: true, + }), + 30_000, + 'screenshot', + ); + } catch (shotError) { + record.screenshotError = String(shotError?.message || shotError); + } + try { + record.streetLevel = await withTimeout( + streetLevelState(page), + 10_000, + 'getUIState', + ); + } catch (stateError) { + record.streetLevelError = String(stateError?.message || stateError); + } + record.renderErrors = [ + ...(monitor?.renderErrors ?? []), + ...(await withTimeout(readRenderErrors(page), 10_000, 'render errors') + .then((list) => list.map((text) => `scene.renderError: ${text}`)) + .catch(() => [])), + ].map(redact); + record.console = monitor?.console ?? []; + record.failedRequests = monitor?.failed ?? []; + await fs.writeFile( + path.join(dir, `${base}.json`), + redact(JSON.stringify(record, null, 2)), + ); + } + return dir; +} diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs index 7892d06a5..59fc29b9f 100644 --- a/scripts/qa-panel-resize.mjs +++ b/scripts/qa-panel-resize.mjs @@ -3,7 +3,10 @@ * 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. + * 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. */ import assert from 'node:assert/strict'; import { pathToFileURL } from 'node:url'; @@ -13,11 +16,20 @@ import { dockPanelByDoubleClick, dragPointer, liftPanelByHeader, + nextFrames, } from './qa-panelDrag.mjs'; +import { + hookRenderErrors, + readRenderErrors, + saveFailureArtifacts, + watchPage, +} from './qa-browserEvidence.mjs'; 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' }; @@ -82,6 +94,7 @@ async function main() { const args = process.argv.slice(2); const urlIndex = args.indexOf('--url'); const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; + if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1'; const browser = await puppeteer.launch({ headless: true, executablePath: @@ -89,11 +102,13 @@ async function main() { (await puppeteer.executablePath()), args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], }); + const monitors = []; try { const page = await browser.newPage(); await page.setViewport(VIEWPORT); const errors = []; - page.on('pageerror', (error) => errors.push(error.stack || error.message)); + monitors.push(watchPage(page, { name: 'panel', errors })); + await hookRenderErrors(page); const boot = async () => { await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); @@ -112,7 +127,7 @@ async function main() { node.remove(); }); await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2); - await new Promise((resolve) => setTimeout(resolve, 400)); + await nextFrames(page); }; const readPanel = () => page.evaluate((id) => { @@ -259,7 +274,17 @@ async function main() { `lifted top (${moved})`, ); near(lifted.width, pressed.width, 2, 'lifted width'); - await new Promise((resolve) => setTimeout(resolve, 700)); + // The rail lets go of the lifted panel on its next layout pass. + await page + .waitForFunction( + (id) => + document + .getElementById(id) + .style.getPropertyValue('--right-panel-allocated-height') === '', + { timeout: 5_000 }, + PANEL_ID, + ) + .catch(() => {}); const afterLayout = await readPanel(); assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it'); const railExcludes = await page.evaluate((id) => { @@ -294,7 +319,6 @@ async function main() { { const header = await headerPoint(); await drag(header, 0, 120 - header.y); - await new Promise((resolve) => setTimeout(resolve, 200)); } // Every handle moves only its own edges. @@ -366,7 +390,22 @@ async function main() { assert.equal(snapped.stored, null, 'the storage key is gone'); console.log('PASS: header double-click returns the panel to the rail'); - assert.deepEqual(errors, []); + const renderErrors = await readRenderErrors(page); + assert.deepEqual( + [...errors, ...renderErrors.map((text) => `scene.renderError: ${text}`)], + [], + 'no page errors and no Cesium render-loop errors', + ); + console.log('PASS: no page errors and no render-loop errors'); + } catch (error) { + const dir = await saveFailureArtifacts({ + dir: ARTIFACT_DIR, + browser, + monitors, + error, + }).catch(() => null); + if (dir) console.error(`failure evidence saved in ${dir}/`); + throw error; } finally { await browser.close(); } diff --git a/scripts/qa-panelDrag.mjs b/scripts/qa-panelDrag.mjs index 5bbd71e58..20ea40f3b 100644 --- a/scripts/qa-panelDrag.mjs +++ b/scripts/qa-panelDrag.mjs @@ -5,8 +5,14 @@ * 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 press that landed outside the header) is retried; a press - * in the header that did not lift or dock is a real failure. + * 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 + * retry fails the run instead. */ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -15,13 +21,37 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export const PRESS_RETRIES = 2; const RETRY_PAUSE_MS = 1000; +/** Whether a retry fails the run (QA_FAIL_ON_RETRY=1 or --fail-on-retry). */ +export function failOnRetryDefault( + env = process.env, + argv = process.argv.slice(2), +) { + return env.QA_FAIL_ON_RETRY === '1' || argv.includes('--fail-on-retry'); +} + /** * Whether an attempt proves nothing about the panel: it did not reach the - * wanted state and the press demonstrably landed outside the header. + * 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. * @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt */ export function pressMissed(attempt) { - return !attempt.done && attempt.pressed?.inHeader !== true; + return !attempt.done && attempt.pressed?.inHeader === false; +} + +/** The panel's box, rounded, as `left,top widthxheight`. */ +export function formatBox(box) { + if (!box) return 'unknown'; + const r = (value) => Math.round(value ?? NaN); + return `${r(box.left)},${r(box.top)} ${r(box.width)}x${r(box.height)}`; +} + +function panelBox(page, panelId) { + return page.evaluate((id) => { + const r = document.getElementById(id).getBoundingClientRect(); + return { left: r.left, top: r.top, width: r.width, height: r.height }; + }, panelId); } /** The centre of the panel's header title, read now. */ @@ -50,7 +80,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). + * 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. */ export async function headerTarget( page, @@ -64,13 +95,15 @@ export async function headerTarget( point = await headerPoint(page, panelId); hit = await hitAt(page, point, panelId); } - return { point, hit }; + const box = await panelBox(page, panelId); + return { point, hit, timedOut: !hit.inHeader, box }; } /** * 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. + * between the hit check and the press. Never fails the run; resolves to + * whether the box held still in time. */ export async function waitForStill( page, @@ -93,8 +126,10 @@ export async function waitForStill( panelId, stillMs, ); + return true; } catch (error) { if (error?.name !== 'TimeoutError') throw error; + return false; } } @@ -114,6 +149,7 @@ export function recordNextPress(page, panelId) { left: r.left, 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`)), @@ -152,6 +188,19 @@ 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. + */ +export function nextFrames(page) { + return page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); +} + /** 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); @@ -159,7 +208,27 @@ export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) { await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps }); await page.mouse.move(from.x + dx, from.y + dy, { steps }); await page.mouse.up(); - await delay(120); + 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, + { round, retries, extra = '' }, +) { + const { hit, pressed, timedOut, still, box } = attempt; + return [ + `retry ${round + 1}/${retries}: the press missed the ${panelId} header (on ${pressed?.target ?? hit.target})`, + `pre-press hit check ${timedOut ? `timed out (on ${hit.target})` : 'passed'}`, + `stillness ${still === false ? 'timed out' : 'held'}`, + `panel box at hit check ${formatBox(box)} -> at press ${formatBox(pressed)}`, + ] + .concat(extra ? [extra] : []) + .join('; '); } /** @@ -170,19 +239,41 @@ async function pressUntilDecided( page, panelId, press, - { retries, log, note, pauseMs = RETRY_PAUSE_MS }, + { + retries, + log, + note, + pauseMs = RETRY_PAUSE_MS, + failOnRetry = failOnRetryDefault(), + }, ) { for (let round = 0; ; round++) { - await waitForStill(page, panelId); + const still = await waitForStill(page, panelId); await recordNextPress(page, panelId); - const { point, hit } = await headerTarget(page, panelId); + const { point, hit, timedOut, box } = await headerTarget(page, panelId); const done = await press(point); - const attempt = { point, hit, done, pressed: await readPress(page) }; + const attempt = { + point, + hit, + timedOut, + still, + box, + done, + pressed: await readPress(page), + retries: round, + }; if (round >= retries || !pressMissed(attempt)) return attempt; - const extra = note ? `; ${await note()}` : ''; - log( - `note: the press missed the ${panelId} header (on ${attempt.pressed?.target ?? hit.target}${extra}); retrying`, - ); + const why = describeRetry(panelId, attempt, { + round, + retries, + extra: note ? await note() : '', + }); + if (failOnRetry) + throw new Error( + `QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`, + ); + log(`note: ${why}; retrying`); + log(`::warning title=Panel press retried::${why}`); await delay(pauseMs); } } @@ -196,7 +287,15 @@ async function pressUntilDecided( export function liftPanelByHeader( page, panelId, - { dx, dy, retries = PRESS_RETRIES, log = console.log, note, pauseMs } = {}, + { + dx, + dy, + retries = PRESS_RETRIES, + log = console.log, + note, + pauseMs, + failOnRetry, + } = {}, ) { return pressUntilDecided( page, @@ -205,7 +304,7 @@ export function liftPanelByHeader( await dragPointer(page, point, dx, dy); return waitForFloating(page, panelId, true); }, - { retries, log, note, pauseMs }, + { retries, log, note, pauseMs, failOnRetry }, ); } @@ -217,7 +316,13 @@ export function liftPanelByHeader( export function dockPanelByDoubleClick( page, panelId, - { retries = PRESS_RETRIES, log = console.log, note, pauseMs } = {}, + { + retries = PRESS_RETRIES, + log = console.log, + note, + pauseMs, + failOnRetry, + } = {}, ) { return pressUntilDecided( page, @@ -227,12 +332,17 @@ export function dockPanelByDoubleClick( await page.mouse.click(point.x, point.y, { clickCount: 2 }); return waitForFloating(page, panelId, false); }, - { retries, log, note, pauseMs }, + { retries, log, note, pauseMs, failOnRetry }, ); } /** One line naming where an attempt pressed, for an assertion message. */ export function describePress(attempt) { const { point, hit, pressed } = attempt; - return `pressed ${Math.round(point.x)},${Math.round(point.y)} on ${pressed?.target ?? `${hit.target} (no press recorded)`}, in header: ${pressed?.inHeader ?? hit.inHeader}`; + const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`; + if (!pressed) + return `${where}: no pointerdown reached the page (hit check on ${hit.target}, in header: ${hit.inHeader})`; + const n = attempt.retries || 0; + const retried = n ? `, after ${n} ${n === 1 ? 'retry' : 'retries'}` : ''; + return `${where} on ${pressed.target}, in header: ${pressed.inHeader}${retried}`; } diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 570fe9281..953094167 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -1,35 +1,61 @@ #!/usr/bin/env node /** - * Browser QA for the Street Level layer against a running dev - * 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, the viewer (visible without scrolling, expanded - * dialog, close) and the panel as a floating, resizable window. Run with `npm run qa:street-level -- --url http://localhost:4173`. - * Without MAPILLARY_CLIENT_TOKEN on the server only the keyless steps run. + * 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 + * `npm run qa:street-level -- --url http://localhost:4173`. * - * `--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 real imagery and are skipped. - * Fixture runs add what a live run cannot stage: a key Mapillary rejects, and - * a second page whose server has no key (the keyless gate most installs see). - * Filter assertions are stricter because the fixture data is known. 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` 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. + * + * 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. */ import assert from 'node:assert/strict'; import { pathToFileURL } from 'node:url'; import { LAYER_STATE_REGISTRY } from '../src/data/layerState.js'; import { tileBounds } from '../src/layers/streetLevel/tileMath.js'; import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs'; +import { COVERAGE_MOVE_DEBOUNCE_MS } from '../src/layers/streetLevel/providers/mapillary/policy.js'; import { describePress, dockPanelByDoubleClick, liftPanelByHeader, } from './qa-panelDrag.mjs'; +import { + hookRenderErrors, + readRenderErrors, + saveFailureArtifacts, + watchPage, +} from './qa-browserEvidence.mjs'; +import { + answerMapillaryRequest, + describeCall, + PHOTO_LINE, + PHOTO_SEQUENCE_ID, + photoImages, +} from './fixtures/street-level/mapillaryGraph.mjs'; 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 }, @@ -39,11 +65,35 @@ export const VIEWPORTS = Object.freeze([ /** Provider chips the panel must show, in order (one per registered provider). */ 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. + */ +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; +/** 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. */ export const RAIL_ORDER = Object.freeze([ 'pp-toggles', @@ -54,6 +104,14 @@ export const RAIL_ORDER = Object.freeze([ 'global-context-panel', ]); +/** The parked camera: over downtown Sacramento, looking north and down. */ +export const PARK = Object.freeze({ + lon: -121.4944, + lat: 38.5816, + height: 900, + pitch: -1.3, +}); + export function isCollapsed(classList) { return [...classList].includes('collapsed'); } @@ -65,7 +123,9 @@ 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. + * 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. * @returns {Uint8Array} empty for zooms that carry nothing */ export function fixtureTile(z, x, y, now = Date.now()) { @@ -111,6 +171,24 @@ export function fixtureTile(z, x, y, now = Date.now()) { ], }); } + const photoSouth = Math.max(PHOTO_LINE.south, south); + const photoNorth = Math.min(PHOTO_LINE.north, north); + if ( + PHOTO_LINE.lon >= west && + PHOTO_LINE.lon < east && + photoSouth < photoNorth + ) + sequences.push({ + id: PHOTO_SEQUENCE_ID, + isPano: false, + capturedAt: now - 30 * DAY_MS, + parts: [ + [ + [PHOTO_LINE.lon, photoSouth], + [PHOTO_LINE.lon, photoNorth], + ], + ], + }); return encodeCoverageTile(tile, { sequences }); } @@ -158,74 +236,138 @@ export async function assertRealStatusRoute(url, fetchImpl = fetch) { } /** - * Answer the page's Mapillary requests from fixtures; nothing reaches - * mapillary.com. `fixture` is live: `configured` is what the status route - * says, `tiles: 'rejected'` answers tiles as the proxy does when Mapillary - * refuses the token, and `tileRequests` counts tile requests. + * 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 - * @param {{configured: boolean, tiles: 'ok'|'rejected', tileRequests: number}} fixture + * @param {{configured?: boolean, tiles: 'ok'|'rejected', tileRequests: number, images: Array, calls: Set, unknown: Array}} fixture */ -async function serveFixtures(page, fixture) { - 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: fixture.configured }), +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. + const cdp = await page.createCDPSession(); + await cdp.send('Fetch.enable', { + patterns: [ + { urlPattern: '*://*.mapillary.com/*' }, + { urlPattern: '*/api/mapillary/*' }, + ], + }); + const fulfil = (requestId, { status, headers = {}, contentType, body }) => + cdp.send('Fetch.fulfillRequest', { + requestId, + responseCode: status, + responseHeaders: Object.entries({ + ...headers, + ...(contentType ? { 'Content-Type': contentType } : {}), + }).map(([name, value]) => ({ name, value: String(value) })), + body: Buffer.from(body ?? '').toString('base64'), + }); + const json = (status, payload) => ({ + status, + contentType: 'application/json', + body: JSON.stringify(payload), + }); + cdp.on('Fetch.requestPaused', ({ requestId, request }) => { + const address = new URL(request.url); + const method = request.method; + let answer = null; + if (/(^|\.)mapillary\.com$/.test(address.hostname)) { + const call = describeCall(method, address); + answer = answerMapillaryRequest({ + method, + url: request.url, + images: fixture.images, }); - const tile = address.pathname.match( - /^\/api\/mapillary\/tiles\/coverage\/(\d+)\/(\d+)\/(\d+)$/, - ); - if (tile) { - fixture.tileRequests++; - if (!fixture.configured) - return request.respond({ - status: 503, - contentType: 'application/json', - body: JSON.stringify({ error: 'no_key', keyRequired: true }), - }); - if (fixture.tiles === 'rejected') - return request.respond({ - status: 403, - contentType: 'application/json', - body: JSON.stringify({ + fixture.calls.add(call); + if (!answer.known) fixture.unknown.push(call); + } else if ( + address.pathname === '/api/mapillary/status' && + fixture.configured !== undefined + ) { + answer = json(200, { configured: fixture.configured }); + } else { + const tile = address.pathname.match( + /^\/api\/mapillary\/tiles\/coverage\/(\d+)\/(\d+)\/(\d+)$/, + ); + if (tile) { + fixture.tileRequests++; + if (fixture.configured === false) + answer = json(503, { error: 'no_key', keyRequired: true }); + else if (fixture.tiles === 'rejected') + answer = json(403, { error: 'Mapillary rejected the access token', keyRejected: true, - }), - }); - 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: '' }); + }); + else { + const bytes = fixtureTile( + Number(tile[1]), + Number(tile[2]), + Number(tile[3]), + ); + answer = bytes.length + ? { + status: 200, + contentType: 'application/x-protobuf', + body: Buffer.from(bytes), + } + : { status: 204, body: '' }; + } + } } - if (/(^|\.)mapillary\.com$/.test(address.hostname)) - return request.respond({ status: 503, body: '' }); - return request.continue(); + const done = answer + ? fulfil(requestId, answer) + : cdp.send('Fetch.continueRequest', { requestId }); + // A page that navigated or closed meanwhile has no request to answer. + done.catch(() => {}); }); } -/** Load the app and clear the first-run dialog. */ -async function boot(page, url) { - await page.goto(url, { waitUntil: 'domcontentloaded' }); +export function newFixture(overrides = {}) { + return { + configured: undefined, + tiles: 'ok', + tileRequests: 0, + images: photoImages(), + calls: new Set(), + unknown: [], + ...overrides, + }; +} + +/** Wait for `fn` in the page; false (not a throw) when it does not come true. */ +async function settle(page, fn, arg, { timeout = 10_000 } = {}) { + try { + await page.waitForFunction(fn, { timeout, polling: 100 }, arg); + return true; + } catch (error) { + if (error?.name !== 'TimeoutError') throw error; + return false; + } +} + +/** Clear the first-run dialog once the app is up. */ +async function clearFirstRun(page) { await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), { timeout: 150_000, }); await page.evaluate(() => document.querySelector('.first-run-explore')?.click(), ); - await sleep(800); + await settle( + page, + () => !document.querySelector('.first-run-explore'), + null, + { + timeout: 3_000, + }, + ); await page.keyboard.press('Escape'); - await sleep(400); await page.evaluate(() => { for (const el of document.querySelectorAll( '#first-run-launcher, [class*=first-run]', @@ -234,6 +376,12 @@ async function boot(page, url) { }); } +/** Load the app and clear the first-run dialog. */ +export async function boot(page, url) { + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await clearFirstRun(page); +} + /** Panel geometry and the bits of state the steps assert on. */ function readPanel(page) { return page.evaluate(() => { @@ -294,12 +442,106 @@ async function assertKeylessGate(page) { assert.match(chip.title, /MAPILLARY_CLIENT_TOKEN/); } +/** + * 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. + * @returns {{x: number, y: number, id: string}|null} client coordinates + */ +export function findPickPoint({ + prefix, + id, + points, + billboardIds, + billboardPrefix = null, +}) { + const viewer = window.__godsEyeView.viewer; + const scene = viewer.scene; + 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) => { + const pid = + typeof picked?.id === 'string' ? picked.id : (picked?.id?.id ?? null); + if (typeof pid !== 'string' || !pid.startsWith(prefix)) return null; + const rest = pid.slice(prefix.length).split('~')[0]; + return id === null || rest === id ? rest : null; + }; + const tryAt = (x, y) => { + if (x < 2 || y < 2 || x > rect.width - 2 || y > rect.height - 2) + return null; + const top = document.elementFromPoint(rect.left + x, rect.top + y); + if (top !== canvas) return null; + const hit = wanted(scene.pick(windowPoint(x, y))); + return hit ? { x: rect.left + x, y: rect.top + y, id: hit } : null; + }; + const centres = []; + const aimed = (billboardId) => + typeof billboardId === 'string' && + ((billboardIds || []).includes(billboardId) || + (billboardPrefix !== null && billboardId.startsWith(billboardPrefix))); + if ((billboardIds && billboardIds.length) || billboardPrefix !== null) { + for (let i = 0; i < scene.primitives.length; i++) { + const collection = scene.primitives.get(i); + if (typeof collection?.get !== 'function' || !collection.length) continue; + for (let j = 0; j < collection.length; j++) { + const billboard = collection.get(j); + if (!aimed(billboard?.id) || !billboard.show) continue; + const at = billboard.computeScreenSpacePosition?.(scene); + if (at) { + Cartesian2 ??= at.constructor; + centres.push([at.x, at.y]); + } + } + } + } + for (const [lon, lat] of points || []) { + const at = scene.cartesianToCanvasCoordinates( + scene.globe.ellipsoid.cartographicToCartesian( + Cartographic.fromDegrees(lon, lat, 0), + ), + ); + if (at) { + Cartesian2 ??= at.constructor; + centres.push([at.x, at.y]); + } + } + for (const [cx, cy] of centres) + for (let r = 0; r <= 16; r += 2) + for (const [dx, dy] of [ + [0, 0], + [r, 0], + [-r, 0], + [0, r], + [0, -r], + ]) { + const hit = tryAt(cx + dx, cy + dy); + if (hit) return hit; + } + if (points || billboardIds) return null; + for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18) + for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6) { + const hit = tryAt(x, y); + if (hit) return hit; + } + return null; +} + async function main() { const { default: puppeteer } = await import('puppeteer'); const args = process.argv.slice(2); const urlIndex = args.indexOf('--url'); const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; const fixtures = args.includes('--fixtures'); + const strict = args.includes('--strict'); + if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1'; const browser = await puppeteer.launch({ headless: true, executablePath: @@ -309,51 +551,81 @@ async function main() { protocolTimeout: 300_000, args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], }); - let passed = 0; + 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`. + */ const step = async (label, fn) => { - const result = await fn(); - passed++; - console.log(`ok ${passed} ${label}`); + let skipped = null; + const result = await fn({ + skip: (reason) => { + skipped = reason; + }, + }); + count++; + if (skipped) { + skips.push({ reason: skipped, label }); + console.log(`skip (${skipped}) ${count} ${label}`); + } else console.log(`ok ${count} ${label}`); return result; }; - // Opening a photo needs MapillaryJS and real imagery: live runs only. - const photoStep = fixtures - ? async (label) => console.log(`skip (fixtures) ${label}`) - : step; + /** A step this mode cannot run at all. */ + const skipStep = (reason, label) => step(label, ({ skip }) => skip(reason)); const errors = []; - /** Collect page errors, and the listener exceptions the layer only warns about. */ - const watchErrors = (page) => { - page.on('pageerror', (error) => errors.push(error.message)); - page.on('console', (message) => { - if (/listener error/i.test(message.text())) errors.push(message.text()); - }); + const monitors = []; + /** Collect page errors, render-loop errors and the listener exceptions the layer only warns about. */ + const watch = async (page, name) => { + monitors.push( + watchPage(page, { + name, + errors, + isError: (text) => /listener error/i.test(text), + }), + ); + await hookRenderErrors(page); }; + const renderErrorsSeen = []; try { - // Before any interception: fixture runs answer this route in the page. + // Before any interception: fixture runs answer Mapillary in the page. await step( 'the server registers the real Mapillary status route', async () => { const real = await assertRealStatusRoute(url); console.log(` (server status: configured=${real.configured})`); + if (fixtures) + assert.equal( + real.configured, + true, + 'fixture runs need a server started with MAPILLARY_CLIENT_TOKEN (CI uses a dummy, non-secret one): the browser bundle carries the token, and without it no photo can open', + ); }, ); const page = await browser.newPage(); - const fixture = { configured: true, tiles: 'ok', tileRequests: 0 }; + const fixture = newFixture(); + await watch(page, 'main'); if (fixtures) await serveFixtures(page, fixture); - watchErrors(page); await boot(page, url); - const module = () => - window.__godsEyeView.dataManager.layers.get('street-level').module; const panel = () => readPanel(page); const status = await page.evaluate(() => fetch('/api/mapillary/status').then((res) => res.json()), ); for (const viewport of VIEWPORTS) { await page.setViewport(viewport); - await sleep(600); await step( `panel is a collapsed right-rail strip at ${viewport.width}×${viewport.height}`, async () => { + await settle( + page, + (width) => { + const box = document + .getElementById('street-level-panel') + .getBoundingClientRect(); + return box.width <= 200 && box.right <= width; + }, + viewport.width, + ); const info = await panel(); assert.deepEqual(info.order, RAIL_ORDER); assert.ok(isCollapsed(info.classes)); @@ -363,10 +635,18 @@ async function main() { ); } await page.setViewport(VIEWPORTS[0]); - await page.click( - '.panel-collapse-btn[data-collapse-target="street-level-panel"]', - ); - await sleep(500); + const expandStrip = async (target = page) => { + await target.click( + '.panel-collapse-btn[data-collapse-target="street-level-panel"]', + ); + await settle( + target, + () => + getComputedStyle(document.getElementById('sl-body')).display === + 'flex', + ); + }; + await expandStrip(); await step( 'expanding the strip shows the body, one chip per provider and the legend', async () => { @@ -401,718 +681,1166 @@ async function main() { 'keyless install gates the controls and reports KEY REQUIRED', () => assertKeylessGate(page), ); + await skipStep( + 'no Mapillary key', + 'the keyed steps (coverage, filters, photos, panel window)', + ); console.log( 'keyless run complete (no MAPILLARY_CLIENT_TOKEN on the server)', ); - return; + } else { + await keyedSteps(); } - // The startup flight can still be running: cancel it, park the camera over - // downtown Sacramento and confirm it stays put before enabling the layer. - const park = () => - page.evaluate(() => { - const v = window.__godsEyeView.viewer; - v.camera.cancelFlight?.(); - const C = v.camera.positionCartographic.constructor; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian( - C.fromDegrees(-121.4944, 38.5816, 900), - ), - orientation: { heading: 0, pitch: -1.3, roll: 0 }, - }); - }); - for (let attempt = 0; attempt < 6; attempt++) { - await park(); - await sleep(1500); - const stable = await page.evaluate(() => { - const c = window.__godsEyeView.viewer.camera.positionCartographic; - return Math.abs((c.longitude * 180) / Math.PI + 121.4944) < 0.01; - }); - if (stable) break; - } - await step( - 'enabling draws coverage and registers the on-globe credit', - async () => { - await page.evaluate(() => - window.__godsEyeView.dataManager.setEnabled('street-level', true, { - origin: 'user', - }), - ); - await waitForCoverage(page); - const info = await panel(); - assert.equal(info.controlsDisabled, false); - // Cesium paints on-screen credits a frame or two after they register. - await page.waitForFunction( - () => document.body.innerHTML.includes('Mapillary contributors'), - { timeout: 15_000 }, - ); - }, - ); - const ui = () => - page.evaluate(() => - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(), + await step('no page errors and no Cesium render-loop errors', async () => { + renderErrorsSeen.push( + ...(await readRenderErrors(page)).map( + (text) => `[main] scene.renderError: ${text}`, + ), ); - const chip = '#sl-provider-chips [data-chip-id="mapillary"]'; - let firstImageId = null; - await step( - 'the header ON/OFF pill switches the layer off and on', - async () => { - assert.equal( - await page.$eval('#sl-status', (node) => - [ - node.tagName, - node.textContent, - node.getAttribute('aria-pressed'), - ].join(':'), - ), - 'BUTTON:ON:true', + assert.deepEqual([...errors, ...renderErrorsSeen], []); + }); + if (strict) + await step('strict: no skips beyond the documented ones', () => { + assert.deepEqual( + strictViolations(skips), + [], + `--strict accepts only skips for: ${STRICT_ALLOWED_SKIPS.join(', ')}`, ); - await page.click('#sl-status'); - await page.waitForFunction( - () => !window.__godsEyeView.dataManager.isEnabled('street-level'), - { timeout: 15_000 }, - ); - await page.waitForFunction( - () => document.getElementById('sl-status').textContent === 'OFF', - { timeout: 5_000 }, - ); - assert.equal((await ui()).coverage.count, 0); - await page.click('#sl-status'); - await waitForCoverage(page); - assert.equal( - await page.$eval('#sl-status', (node) => - node.getAttribute('aria-pressed'), - ), - 'true', - ); - }, + }); + console.log( + `${count} steps: ${count - skips.length} ok, ${skips.length} skipped`, ); - await step( - 'the only lit provider chip switches the whole layer off, credit and all', - async () => { - await page.click(chip); - await page.waitForFunction( - () => - !window.__godsEyeView.dataManager.isEnabled('street-level') && - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().coverage.count === 0, - { timeout: 15_000 }, - ); - await page.waitForFunction( - () => !document.body.innerHTML.includes('Mapillary contributors'), - { timeout: 15_000 }, - ); - assert.equal( - await page.$eval(chip, (node) => node.getAttribute('aria-pressed')), - 'false', - ); - // The provider stays switched on, so the layer comes back with it. - assert.equal((await ui()).providers[0].on, true); - }, - ); - await step( - 'lighting the chip turns the layer back on with coverage', - async () => { - await page.click(chip); - await waitForCoverage(page); - assert.equal( - await page.$eval(chip, (node) => node.getAttribute('aria-pressed')), - 'true', - ); - }, - ); - await step( - 'the 360° filter keeps at most the unfiltered sequence count', - async () => { - const before = (await ui()).coverage.count; - await page.click('[data-sl-pano="pano"]'); - await sleep(600); - const after = (await ui()).coverage.count; - assert.ok(after <= before, `${after} ≤ ${before}`); - // The fixtures hold flat sequences too, so the filter must drop some. - if (fixtures) assert.ok(after < before, `${after} < ${before}`); - assert.equal((await ui()).filter.pano, 'pano'); - // The click is a user params request, so the share link records it - // under Street Level's share token, whatever the ledger assigned. - await page.waitForFunction( - (option) => - (new URLSearchParams(location.hash.slice(1)).get('lo') || '') - .split('_') - .includes(option), - { timeout: 10_000 }, - `${STREET_LEVEL_TOKEN}.p.p`, - ); - await page.click('[data-sl-pano="all"]'); - await sleep(600); - assert.equal((await ui()).coverage.count, before); - }, - ); - await step( - 'the SINCE slider narrows coverage and names its cut-off date', - async () => { - const before = (await ui()).coverage.count; - const setStop = (index) => - page.$eval( - '#sl-since', - (input, value) => { - input.value = String(value); - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - }, - index, - ); - await setStop(5); - await sleep(600); - const narrowed = await ui(); - assert.equal(narrowed.filter.sinceDays, 365); - if (fixtures) - assert.ok( - narrowed.coverage.count < before, - `fixtures hold older sequences: ${narrowed.coverage.count} < ${before}`, - ); - assert.ok( - narrowed.coverage.count <= before, - `${narrowed.coverage.count} ≤ ${before}`, - ); - assert.match( - await page.$eval('#sl-since-label', (node) => node.textContent), - /^LAST YEAR · SINCE \d{4}-\d{2}-\d{2}$/, - ); - await setStop(0); - await sleep(600); - assert.equal((await ui()).filter.sinceDays, 0); - assert.equal( - await page.$eval('#sl-since-label', (node) => node.textContent), - 'ANY DATE', - ); - }, - ); - 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. - await page.waitForSelector( - '#sl-viewer.mapillary-viewer .mapillary-dom', - { - timeout: 60_000, - }, - ); - }, - ); - if (fixtures) - await step( - 'a key Mapillary rejects reads KEY REJECTED, stops asking, and clears when switched off', - async () => { - const statusText = (text) => - page.waitForFunction( - (expected) => - document.getElementById('sl-status').textContent === expected, - { timeout: 30_000 }, - text, - ); - fixture.tiles = 'rejected'; - await page.click('#sl-status'); - await statusText('OFF'); - await page.click('#sl-status'); - await statusText('KEY REJECTED'); - const shown = await page.evaluate(() => ({ - error: document.getElementById('sl-error').textContent, - errorHidden: document.getElementById('sl-error').hidden, - controls: document.getElementById('sl-controls').disabled, - chip: document - .querySelector('#sl-provider-chips [data-chip-id="mapillary"]') - .classList.contains('chip-error'), - })); - assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/); - assert.equal(shown.errorHidden, false); - assert.equal(shown.controls, true); - assert.equal(shown.chip, true); - // Panning asks for nothing more: the verdict holds for every tile. - const asked = fixture.tileRequests; - await page.evaluate(() => { + + 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. + const park = async () => { + for (let attempt = 0; attempt < 6; attempt++) { + await page.evaluate((at) => { const v = window.__godsEyeView.viewer; + v.camera.cancelFlight?.(); const C = v.camera.positionCartographic.constructor; v.camera.setView({ destination: v.scene.globe.ellipsoid.cartographicToCartesian( - C.fromDegrees(-121.47, 38.6, 900), + C.fromDegrees(at.lon, at.lat, at.height), ), - orientation: { heading: 0, pitch: -1.3, roll: 0 }, + orientation: { heading: 0, pitch: at.pitch, roll: 0 }, }); - }); + }, PARK); await sleep(1500); - assert.equal( - fixture.tileRequests, - asked, - 'no tile requests while rejected', + const stable = await page.evaluate((at) => { + const c = window.__godsEyeView.viewer.camera.positionCartographic; + return ( + Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 && + Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001 + ); + }, PARK); + if (stable) return; + } + }; + await park(); + await step( + 'enabling draws coverage and registers the on-globe credit', + async () => { + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('street-level', true, { + origin: 'user', + }), ); - // Off clears the message; with a good key it comes back as ON. - await page.click('#sl-status'); - await statusText('OFF'); - assert.equal( - await page.$eval( - '#sl-error', - (node) => node.hidden || !node.textContent, - ), - true, - 'no stale error while the layer is off', - ); - fixture.tiles = 'ok'; - await page.click('#sl-status'); await waitForCoverage(page); - await statusText('ON'); - }, - ); - await photoStep( - 'opening the nearest image shows the viewer with a caption', - async () => { - // Fire and forget: the open can outlive one CDP call, so poll instead. - await page.evaluate(() => { - void window.__godsEyeView.dataManager.layers - .get('street-level') - .module.openNearest(); - }); - await page.waitForFunction( - () => { - const s = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().street; - return s.imageId || s.error; - }, - { timeout: 90_000 }, - ); - const street = await page.evaluate( - () => - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().street, - ); - assert.equal(street.error, null, `viewer error: ${street.error}`); - // Some images carry no creator name; the date/bearing side always fills. - try { + const info = await panel(); + assert.equal(info.controlsDisabled, false); + // Cesium paints on-screen credits a frame or two after they register. await page.waitForFunction( () => - document.getElementById('sl-image-when').textContent.trim() - .length > 0, - { timeout: 30_000 }, + document.body.innerHTML.includes('Mapillary contributors'), + { timeout: 15_000 }, ); - } catch (error) { - const dump = await page.evaluate(() => { - const u = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(); - // Force one more render: a caption that fills now means an update - // was missed; one that stays empty means the render path is wrong. - u.street.renderMode && - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.setViewerRenderMode(u.street.renderMode); - return { - street: u.street, - captionNodes: document.querySelectorAll('#sl-image-when').length, - captionNow: document.getElementById('sl-image-when').textContent, - wrapHidden: document.getElementById('sl-viewer-wrap').hidden, - collapsed: document - .getElementById('street-level-panel') - .classList.contains('collapsed'), - }; - }); - throw new Error( - `caption never filled: ${JSON.stringify(dump)}; errors=${JSON.stringify(errors)}`, - { cause: error }, - ); - } - const view = await page.evaluate(() => ({ - hidden: document.getElementById('sl-viewer-wrap').hidden, - when: document.getElementById('sl-image-when').textContent.trim(), - link: document.getElementById('sl-image-link').textContent.trim(), - href: document.getElementById('sl-image-link').href, - width: Math.round( - document.getElementById('sl-viewer').getBoundingClientRect().width, + }, + ); + const ui = () => + page.evaluate(() => + window.__godsEyeView.dataManager.layers + .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. + */ + const uiUntil = (test, arg = null, options) => + settle( + page, + `(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`, + undefined, + options, + ); + const chip = '#sl-provider-chips [data-chip-id="mapillary"]'; + const stacks = { + active: () => + page.evaluate(() => + window.__godsEyeView.mapStackController.getActiveId(), ), - })); - assert.equal(view.hidden, false); - assert.ok(view.width > 200); - assert.ok(view.when.length > 0, 'caption shows the capture date'); - assert.equal(street.providerId, 'mapillary'); - firstImageId = street.imageId; - assert.equal(view.link, 'MAPILLARY ↗'); - const fit = await page.evaluate(() => { - const inner = document.querySelector('.street-level-panel-inner'); - const box = inner.getBoundingClientRect(); - const wrap = document - .getElementById('sl-viewer-wrap') - .getBoundingClientRect(); - return { - scrollTop: inner.scrollTop, - top: wrap.top - box.top, - overflowBottom: wrap.bottom - box.bottom, - }; - }); - assert.equal(fit.scrollTop, 0, 'panel not scrolled'); - assert.ok( - fit.top >= 0 && fit.top < 80, - `viewer right under the header (${fit.top}px)`, - ); - assert.ok( - fit.overflowBottom <= 1, - `whole viewer visible (${fit.overflowBottom}px cut)`, - ); - assert.match(view.href, /mapillary\.com\/app\/\?pKey=/); - }, - ); - // FOLLOW needs an open photo as well as Google 3D: a photo step. - await photoStep('FOLLOW is offered only on the Google 3D map', async () => { - const stacks = () => window.__godsEyeView.mapStackController; - const follow = () => - page.$eval('#sl-follow-btn', (node) => ({ - disabled: node.disabled, - pressed: node.getAttribute('aria-pressed'), - title: node.title, - })); - const setStack = async (id) => { - await page.evaluate( - (stackId) => - window.__godsEyeView.mapStackController.setStack(stackId), - id, - ); - await sleep(800); - }; - const original = await page.evaluate(() => - window.__godsEyeView.mapStackController.getActiveId(), - ); - await setStack('esri-imagery'); - let state = await follow(); - assert.equal(state.disabled, true, 'disabled on Esri'); - assert.match(state.title, /needs the Google 3D map/); - const photoreal = await page.evaluate(() => - window.__godsEyeView.mapStackController.isStackAvailable('photoreal'), - ); - if (photoreal) { - await setStack('photoreal'); - state = await follow(); - assert.equal(state.disabled, false, 'enabled on Google 3D'); - await page.click('#sl-follow-btn'); - await sleep(300); - assert.equal((await follow()).pressed, 'true'); - await setStack('esri-imagery'); - state = await follow(); - assert.equal( - state.pressed, - 'false', - 'leaving Google 3D stops following', - ); - assert.equal(state.disabled, true); - } else { - console.log( - ' (Google 3D unavailable here: only the disabled path ran)', - ); - } - await setStack(original); - void stacks; - }); - await step( - 'coverage sits on the bare earth on Google 3D and drapes elsewhere', - async () => { - const surface = () => - page.evaluate(() => { - const u = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(); - return { surface: u.surface, count: u.coverage.count }; - }); - const setStack = (id) => - page.evaluate( + photoreal: () => + page.evaluate(() => + window.__godsEyeView.mapStackController.isStackAvailable( + 'photoreal', + ), + ), + set: async (id) => { + await page.evaluate( (stackId) => window.__godsEyeView.mapStackController.setStack(stackId), id, ); - const original = await page.evaluate(() => - window.__godsEyeView.mapStackController.getActiveId(), + await settle( + page, + (stackId) => + window.__godsEyeView.mapStackController.getActiveId() === stackId, + id, + ); + }, + }; + await step( + 'the header ON/OFF pill switches the layer off and on', + async () => { + assert.equal( + await page.$eval('#sl-status', (node) => + [ + node.tagName, + node.textContent, + node.getAttribute('aria-pressed'), + ].join(':'), + ), + 'BUTTON:ON:true', + ); + await page.click('#sl-status'); + await page.waitForFunction( + () => !window.__godsEyeView.dataManager.isEnabled('street-level'), + { timeout: 15_000 }, + ); + await page.waitForFunction( + () => document.getElementById('sl-status').textContent === 'OFF', + { timeout: 5_000 }, + ); + assert.equal((await ui()).coverage.count, 0); + await page.click('#sl-status'); + await waitForCoverage(page); + assert.equal( + await page.$eval('#sl-status', (node) => + node.getAttribute('aria-pressed'), + ), + 'true', + ); + }, + ); + await step( + 'the only lit provider chip switches the whole layer off, credit and all', + async () => { + await page.click(chip); + await page.waitForFunction( + () => + !window.__godsEyeView.dataManager.isEnabled('street-level') && + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState().coverage.count === 0, + { timeout: 15_000 }, + ); + await page.waitForFunction( + () => + !document.body.innerHTML.includes('Mapillary contributors'), + { timeout: 15_000 }, + ); + assert.equal( + await page.$eval(chip, (node) => node.getAttribute('aria-pressed')), + 'false', + ); + // The provider stays switched on, so the layer comes back with it. + assert.equal((await ui()).providers[0].on, true); + }, + ); + await step( + 'lighting the chip turns the layer back on with coverage', + async () => { + await page.click(chip); + await waitForCoverage(page); + assert.equal( + await page.$eval(chip, (node) => node.getAttribute('aria-pressed')), + 'true', + ); + }, + ); + await step( + 'the 360° filter keeps at most the unfiltered sequence count', + async () => { + const before = (await ui()).coverage.count; + await page.click('[data-sl-pano="pano"]'); + await uiUntil((u) => u.filter.pano === 'pano' && !u.coverage.loading); + const after = (await ui()).coverage.count; + assert.ok(after <= before, `${after} ≤ ${before}`); + // The fixtures hold flat sequences too, so the filter must drop some. + if (fixtures) assert.ok(after < before, `${after} < ${before}`); + assert.equal((await ui()).filter.pano, 'pano'); + // The click is a user params request, so the share link records it + // under Street Level's share token, whatever the ledger assigned. + await page.waitForFunction( + (option) => + (new URLSearchParams(location.hash.slice(1)).get('lo') || '') + .split('_') + .includes(option), + { timeout: 10_000 }, + `${STREET_LEVEL_TOKEN}.p.p`, + ); + await page.click('[data-sl-pano="all"]'); + await uiUntil( + (u, n) => u.filter.pano === 'all' && u.coverage.count === n, + before, + ); + assert.equal((await ui()).coverage.count, before); + }, + ); + await step( + 'the SINCE slider narrows coverage and names its cut-off date', + async () => { + const before = (await ui()).coverage.count; + const setStop = (index) => + page.$eval( + '#sl-since', + (input, value) => { + input.value = String(value); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }, + index, + ); + await setStop(5); + await uiUntil((u) => u.filter.sinceDays === 365); + const narrowed = await ui(); + assert.equal(narrowed.filter.sinceDays, 365); + if (fixtures) + assert.ok( + narrowed.coverage.count < before, + `fixtures hold older sequences: ${narrowed.coverage.count} < ${before}`, + ); + assert.ok( + narrowed.coverage.count <= before, + `${narrowed.coverage.count} ≤ ${before}`, + ); + await settle(page, () => + /^LAST YEAR/.test( + document.getElementById('sl-since-label').textContent, + ), + ); + assert.match( + await page.$eval('#sl-since-label', (node) => node.textContent), + /^LAST YEAR · SINCE \d{4}-\d{2}-\d{2}$/, + ); + await setStop(0); + await uiUntil((u) => u.filter.sinceDays === 0); + await settle( + page, + () => + document.getElementById('sl-since-label').textContent === + 'ANY DATE', + ); + assert.equal((await ui()).filter.sinceDays, 0); + assert.equal( + await page.$eval('#sl-since-label', (node) => node.textContent), + 'ANY DATE', + ); + }, + ); + 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. + await page.waitForSelector( + '#sl-viewer.mapillary-viewer .mapillary-dom', + { timeout: 60_000 }, + ); + }, + ); + const statusText = (text) => + page.waitForFunction( + (expected) => + document.getElementById('sl-status').textContent === expected, + { timeout: 30_000 }, + text, ); - // FOLLOW left the camera at eye height; look down on the city again. - await park(); - await sleep(1500); - await setStack('esri-imagery'); - await page.waitForFunction( - () => + /** Point the camera somewhere new and refresh coverage there now. */ + const panTo = (lon, lat) => + page.evaluate( + (at) => { + const v = window.__godsEyeView.viewer; + const C = v.camera.positionCartographic.constructor; + v.camera.setView({ + destination: v.scene.globe.ellipsoid.cartographicToCartesian( + C.fromDegrees(at.lon, at.lat, 900), + ), + orientation: { heading: 0, pitch: -1.3, roll: 0 }, + }); window.__godsEyeView.dataManager.layers .get('street-level') - .module.getUIState().surface === 'draped', - { timeout: 10_000 }, + .module.refreshCoverage(); + }, + { lon, lat }, ); - const coverageLoaded = () => waitForCoverage(page, { timeout: 60_000 }); - await coverageLoaded(); - assert.equal((await surface()).surface, 'draped', 'draped on Esri'); - const photoreal = await page.evaluate(() => - window.__godsEyeView.mapStackController.isStackAvailable('photoreal'), + /** + * 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). + */ + const tileRequestsQuiet = async (quietMs, timeout = 30_000) => { + const start = Date.now(); + let last = fixture.tileRequests; + let since = Date.now(); + while (Date.now() - start < timeout) { + await sleep(100); + if (fixture.tileRequests !== last) { + last = fixture.tileRequests; + since = Date.now(); + } else if (Date.now() - since >= quietMs) return last; + } + return last; + }; + const QUIET_MS = 2 * COVERAGE_MOVE_DEBOUNCE_MS + 200; + if (fixtures) + await step( + 'a key Mapillary rejects reads KEY REJECTED, stops asking (a pan that asks while accepted asks nothing while rejected), and clears when switched off', + async () => { + // Positive control: with the key accepted, the same pan-and-wait + // the rejected half uses does see new tile requests. + await tileRequestsQuiet(QUIET_MS); + const idle = fixture.tileRequests; + await panTo(-121.46, 38.6); + const asked = await tileRequestsQuiet(QUIET_MS); + assert.ok( + asked > idle, + `a pan while accepted requests tiles (${idle} → ${asked})`, + ); + fixture.tiles = 'rejected'; + await page.click('#sl-status'); + await statusText('OFF'); + await page.click('#sl-status'); + await statusText('KEY REJECTED'); + const shown = await page.evaluate(() => ({ + error: document.getElementById('sl-error').textContent, + errorHidden: document.getElementById('sl-error').hidden, + controls: document.getElementById('sl-controls').disabled, + chip: document + .querySelector('#sl-provider-chips [data-chip-id="mapillary"]') + .classList.contains('chip-error'), + })); + assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/); + assert.equal(shown.errorHidden, false); + assert.equal(shown.controls, true); + assert.equal(shown.chip, true); + // Panning asks for nothing more: the verdict holds for every tile. + const rejectedAt = await tileRequestsQuiet(QUIET_MS); + await panTo(-121.42, 38.63); + const after = await tileRequestsQuiet(QUIET_MS); + assert.equal(after, rejectedAt, 'no tile requests while rejected'); + // Off clears the message; with a good key it comes back as ON. + await page.click('#sl-status'); + await statusText('OFF'); + assert.equal( + await page.$eval( + '#sl-error', + (node) => node.hidden || !node.textContent, + ), + true, + 'no stale error while the layer is off', + ); + fixture.tiles = 'ok'; + await page.click('#sl-status'); + await waitForCoverage(page); + await statusText('ON'); + }, ); - if (photoreal) { - await setStack('photoreal'); + else + await skipStep( + 'fixtures only', + 'a key Mapillary rejects reads KEY REJECTED and stops asking', + ); + + const fixtureIds = new Map( + fixture.images.map((image, index) => [image.id, { ...image, index }]), + ); + const waitForImage = (id = null) => + page.waitForFunction( + (want) => { + const s = window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState().street; + return ( + (s.imageId && !s.loading && (!want || s.imageId === want)) || + s.error + ); + }, + { timeout: 90_000 }, + id, + ); + const openById = async (id) => { + await page.evaluate((imageId) => { + void window.__godsEyeView.dataManager.layers + .get('street-level') + .module.openImage('mapillary', imageId); + }, id); + await waitForImage(id); + const street = (await ui()).street; + assert.equal(street.error, null, `viewer error: ${street.error}`); + return street; + }; + + await park(); + await waitForCoverage(page); + await step( + 'a click on a coverage line selects its sequence and draws its cones', + async () => { + const original = await stacks.active(); + // Draped lines on flat imagery: the line is where it is drawn. + await stacks.set('esri-imagery'); + await uiUntil((u) => u.surface === 'draped'); + await waitForCoverage(page); + const target = fixtures ? PHOTO_SEQUENCE_ID : null; + const along = Array.from({ length: 9 }, (_, i) => [ + PHOTO_LINE.lon, + PHOTO_LINE.south + + ((i + 2) / 13) * (PHOTO_LINE.north - PHOTO_LINE.south), + ]); + // Ground primitives are built over a few frames: poll for a pick. + const handle = await page.waitForFunction( + findPickPoint, + { timeout: 30_000, polling: 250 }, + { + prefix: 'mly:seq:', + id: target, + points: fixtures ? along : null, + billboardIds: null, + }, + ); + const at = await handle.jsonValue(); + await page.mouse.click(at.x, at.y); + const selected = await uiUntil( + (u, id) => + u.sequence.selectedId === id && + !u.sequence.loading && + u.sequence.images > 0, + at.id, + { timeout: 30_000 }, + ); + const sequence = (await ui()).sequence; + assert.ok( + selected, + `the click at ${Math.round(at.x)},${Math.round(at.y)} on ${at.id} selected it: ${JSON.stringify(sequence)}`, + ); + if (fixtures) + assert.equal(sequence.images, fixture.images.length, 'every photo'); + // The cones are on the globe: one of them picks as an image. + const cone = await page.waitForFunction( + findPickPoint, + { timeout: 15_000, polling: 250 }, + { + prefix: 'mly:img:', + id: null, + points: fixtures + ? fixture.images.map((image) => [image.lon, image.lat]) + : null, + billboardIds: null, + // Live: the cones' positions are only known from the globe. + billboardPrefix: fixtures ? null : 'mly:img:', + }, + ); + assert.ok(await cone.jsonValue(), 'an image cone is pickable'); + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.clearSequence(), + ); + await uiUntil((u) => u.sequence.selectedId === null); + await stacks.set(original); + }, + ); + + let firstImageId = null; + await park(); + await waitForCoverage(page); + await step( + 'opening the nearest image shows the viewer with a caption', + async () => { + // Fire and forget: the open can outlive one CDP call, so poll instead. + await page.evaluate(() => { + void window.__godsEyeView.dataManager.layers + .get('street-level') + .module.openNearest(); + }); + await waitForImage(); + const street = (await ui()).street; + assert.equal(street.error, null, `viewer error: ${street.error}`); + // Some images carry no creator name; the date/bearing side always fills. + const filled = await settle( + page, + () => + document.getElementById('sl-image-when').textContent.trim() + .length > 0, + null, + { timeout: 30_000 }, + ); + if (!filled) { + const dump = await page.evaluate(() => { + const u = window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState(); + return { + street: u.street, + captionNodes: + document.querySelectorAll('#sl-image-when').length, + captionNow: + document.getElementById('sl-image-when').textContent, + wrapHidden: document.getElementById('sl-viewer-wrap').hidden, + collapsed: document + .getElementById('street-level-panel') + .classList.contains('collapsed'), + }; + }); + throw new Error(`caption never filled: ${JSON.stringify(dump)}`); + } + const view = await page.evaluate(() => ({ + hidden: document.getElementById('sl-viewer-wrap').hidden, + when: document.getElementById('sl-image-when').textContent.trim(), + link: document.getElementById('sl-image-link').textContent.trim(), + href: document.getElementById('sl-image-link').href, + width: Math.round( + document.getElementById('sl-viewer').getBoundingClientRect() + .width, + ), + })); + assert.equal(view.hidden, false); + assert.ok(view.width > 200); + assert.ok(view.when.length > 0, 'caption shows the capture date'); + assert.equal(street.providerId, 'mapillary'); + firstImageId = street.imageId; + assert.equal(view.link, 'MAPILLARY ↗'); + if (fixtures) { + const known = fixtureIds.get(street.imageId); + assert.ok(known, `a fixture photo opened (${street.imageId})`); + assert.equal(street.isPano, known.isPano, 'pano flag from the ent'); + assert.equal(street.sequenceId, PHOTO_SEQUENCE_ID); + } + const fit = await page.evaluate(() => { + const inner = document.querySelector('.street-level-panel-inner'); + const box = inner.getBoundingClientRect(); + const wrap = document + .getElementById('sl-viewer-wrap') + .getBoundingClientRect(); + return { + scrollTop: inner.scrollTop, + top: wrap.top - box.top, + overflowBottom: wrap.bottom - box.bottom, + }; + }); + assert.equal(fit.scrollTop, 0, 'panel not scrolled'); + assert.ok( + fit.top >= 0 && fit.top < 80, + `viewer right under the header (${fit.top}px)`, + ); + assert.ok( + fit.overflowBottom <= 1, + `whole viewer visible (${fit.overflowBottom}px cut)`, + ); + assert.match(view.href, /mapillary\.com\/app\/\?pKey=/); + // The open selects the photo's sequence: its cones are drawn. + assert.ok( + await uiUntil( + (u) => u.sequence.selectedId === u.street.sequenceId, + null, + { timeout: 30_000 }, + ), + 'the open photo’s sequence is selected', + ); + }, + ); + if (fixtures) + await step( + 'a click on a neighbouring cone steps the viewer to that photo (360° and flat)', + async () => { + // Let the framing flight land, so the cone does not move under the click. + await settle( + page, + () => !window.__godsEyeView.viewer.camera._currentFlight, + null, + { timeout: 10_000 }, + ); + await uiUntil((u) => u.sequence.images > 0 && !u.sequence.loading); + const current = fixtureIds.get(firstImageId); + const neighbour = [1, -1, 2, -2, 4, -4] + .map((offset) => fixture.images[current.index + offset]) + .find((image) => image && image.isPano !== current.isPano); + assert.ok(neighbour, 'a neighbour of the other camera type'); + const handle = await page.waitForFunction( + findPickPoint, + { timeout: 15_000, polling: 250 }, + { + prefix: 'mly:img:', + id: neighbour.id, + points: null, + billboardIds: [`mly:img:${neighbour.id}`], + }, + ); + const at = await handle.jsonValue(); + await page.mouse.click(at.x, at.y); + await waitForImage(neighbour.id); + const street = (await ui()).street; + assert.equal(street.error, null); + assert.equal(street.imageId, neighbour.id, 'stepped to the cone'); + assert.equal(street.isPano, neighbour.isPano); + assert.notEqual(street.isPano, current.isPano, 'both kinds shown'); + await openById(firstImageId); + }, + ); + else + await skipStep( + 'fixtures only', + 'a click on a neighbouring cone steps the viewer to that photo', + ); + await step( + 'FOLLOW is offered only on the Google 3D map', + async ({ skip }) => { + const follow = () => + page.$eval('#sl-follow-btn', (node) => ({ + disabled: node.disabled, + pressed: node.getAttribute('aria-pressed'), + title: node.title, + })); + const followIs = (disabled, pressed = null) => + settle( + page, + ([d, p]) => { + const node = document.getElementById('sl-follow-btn'); + return ( + node.disabled === d && + (p === null || node.getAttribute('aria-pressed') === p) + ); + }, + [disabled, pressed], + ); + const original = await stacks.active(); + await stacks.set('esri-imagery'); + await followIs(true); + let state = await follow(); + assert.equal(state.disabled, true, 'disabled on Esri'); + assert.match(state.title, /needs the Google 3D map/); + if (await stacks.photoreal()) { + await stacks.set('photoreal'); + await followIs(false); + state = await follow(); + assert.equal(state.disabled, false, 'enabled on Google 3D'); + await page.click('#sl-follow-btn'); + await followIs(false, 'true'); + assert.equal((await follow()).pressed, 'true'); + await stacks.set('esri-imagery'); + await followIs(true, 'false'); + state = await follow(); + assert.equal( + state.pressed, + 'false', + 'leaving Google 3D stops following', + ); + assert.equal(state.disabled, true); + } else { + console.log( + ' (Google 3D unavailable here: only the disabled path ran)', + ); + skip('no Google 3D'); + } + await stacks.set(original); + }, + ); + await park(); + await step( + 'closing a photo stops the framing flight toward it', + async () => { + // Opening frames the photo with a 1.6 s camera flight; closing right + // away must cancel it, leaving the camera where it was. + const result = await page.evaluate(async (id) => { + const v = window.__godsEyeView.viewer; + const module = + window.__godsEyeView.dataManager.layers.get( + 'street-level', + ).module; + const opened = await module.openImage('mapillary', id); + const flying = Boolean(v.camera._currentFlight); + const from = v.camera.positionCartographic.clone(); + module.closeViewer(); + const stillFlying = Boolean(v.camera._currentFlight); + const frames = (n) => + new Promise((resolve) => { + const tick = () => + --n <= 0 ? resolve() : requestAnimationFrame(tick); + requestAnimationFrame(tick); + }); + await frames(20); + const to = v.camera.positionCartographic; + return { + opened, + flying, + stillFlying, + moved: Math.hypot( + (to.longitude - from.longitude) * 6_371_000, + (to.latitude - from.latitude) * 6_371_000, + to.height - from.height, + ), + height: to.height, + }; + }, firstImageId); + assert.equal(result.opened, true, 'the photo opened'); + assert.equal(result.flying, true, 'opening started a framing flight'); + assert.equal(result.stillFlying, false, 'closing cancelled it'); + assert.ok( + result.moved < 0.5, + `the camera stopped where it was (moved ${result.moved.toFixed(2)} m)`, + ); + assert.ok( + result.height > PARK.height / 2, + `the camera never reached the photo (${Math.round(result.height)} m up)`, + ); + assert.equal((await ui()).street.open, false); + }, + ); + await step( + 'coverage sits on the bare earth on Google 3D and drapes elsewhere', + async ({ skip }) => { + const surface = () => + page.evaluate(() => { + const u = window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState(); + return { surface: u.surface, count: u.coverage.count }; + }); + const original = await stacks.active(); + await stacks.set('esri-imagery'); await page.waitForFunction( () => window.__godsEyeView.dataManager.layers .get('street-level') - .module.getUIState().surface === 'terrain', - { timeout: 15_000 }, + .module.getUIState().surface === 'draped', + { timeout: 10_000 }, ); + const coverageLoaded = () => + waitForCoverage(page, { timeout: 60_000 }); await coverageLoaded(); - assert.equal((await surface()).surface, 'terrain'); - } else { - console.log( - ' (Google 3D unavailable here: only the draped path ran)', + assert.equal((await surface()).surface, 'draped', 'draped on Esri'); + if (await stacks.photoreal()) { + await stacks.set('photoreal'); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState().surface === 'terrain', + { timeout: 15_000 }, + ); + await coverageLoaded(); + assert.equal((await surface()).surface, 'terrain'); + } else { + console.log( + ' (Google 3D unavailable here: only the draped path ran)', + ); + skip('no Google 3D'); + } + await stacks.set(original); + }, + ); + const expanded = () => + page.evaluate(() => + document + .getElementById('sl-viewer-wrap') + .classList.contains('sl-viewer-wrap-expanded'), + ); + await step( + 'EXPAND opens a modal dialog (the rest of inert) and Esc returns focus to the button', + async () => { + await openById(firstImageId); + const inertBefore = await page.evaluate( + () => + [...document.body.children].filter((node) => node.inert).length, ); - } - await setStack(original); - }, - ); - await photoStep( - 'EXPAND opens a modal dialog and Esc returns focus to the button', - async () => { - await page.click('#sl-viewer-expand'); - await sleep(600); - const dialog = await page.evaluate(() => { - const wrap = document.getElementById('sl-viewer-wrap'); - return { - role: wrap.getAttribute('role'), - modal: wrap.getAttribute('aria-modal'), - inside: wrap.contains(document.activeElement), - width: Math.round(wrap.getBoundingClientRect().width), - }; - }); - assert.equal(dialog.role, 'dialog'); - assert.equal(dialog.modal, 'true'); - assert.equal(dialog.inside, true); - assert.ok(dialog.width > 900); - await page.keyboard.press('Escape'); - await sleep(400); - assert.equal( - await page - .evaluate(() => ({ - expanded: document + await page.click('#sl-viewer-expand'); + await settle(page, () => + document + .getElementById('sl-viewer-wrap') + .classList.contains('sl-viewer-wrap-expanded'), + ); + const dialog = await page.evaluate(() => { + const wrap = document.getElementById('sl-viewer-wrap'); + const others = [...document.body.children].filter( + (node) => node !== wrap, + ); + return { + role: wrap.getAttribute('role'), + modal: wrap.getAttribute('aria-modal'), + inside: wrap.contains(document.activeElement), + width: Math.round(wrap.getBoundingClientRect().width), + onBody: wrap.parentElement === document.body, + notInert: others + .filter((node) => !node.inert) + .map((node) => node.id || node.tagName.toLowerCase()), + wrapInert: wrap.inert, + }; + }); + assert.equal(dialog.role, 'dialog'); + assert.equal(dialog.modal, 'true'); + assert.equal(dialog.inside, true); + assert.ok(dialog.width > 900); + assert.equal(dialog.onBody, true); + assert.deepEqual( + dialog.notInert, + [], + 'every other body child is inert', + ); + assert.equal(dialog.wrapInert, false); + await page.keyboard.press('Escape'); + await settle( + page, + () => + !document .getElementById('sl-viewer-wrap') .classList.contains('sl-viewer-wrap-expanded'), - focus: document.activeElement?.id, - })) - .then((r) => `${r.expanded}:${r.focus}`), - 'false:sl-viewer-expand', - ); - }, - ); - await photoStep( - '× closes the image and deselects it on the globe', - async () => { - await page.click('#sl-viewer-close'); - await sleep(500); - const after = await page.evaluate(() => { - const u = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(); - return { open: u.street.open, sequence: u.sequence.selectedId }; - }); - assert.equal(after.open, false); - assert.equal(after.sequence, null); - }, - ); - const floating = () => - page.$eval('#street-level-panel', (node) => - node.classList.contains('panel-floating'), - ); - const center = (selector) => - page.$eval(selector, (node) => { - 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). - const floatPanel = async () => { - const attempt = await liftPanelByHeader(page, PANEL_ID, { - dx: -400, - dy: 80, - }); - assert.equal( - attempt.done, - true, - `a header drag lifts the panel out of the rail (${describePress(attempt)})`, - ); - assert.equal(await floating(), true); - }; - await photoStep( - 'the panel floats on a header drag, resizes, and the viewer takes the room', - async () => { - await page.evaluate((id) => { - void window.__godsEyeView.dataManager.layers - .get('street-level') - .module.openImage('mapillary', id); - }, firstImageId); - await page.waitForFunction( - () => { - const s = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().street; - return (s.imageId && !s.loading) || s.error; - }, - { timeout: 90_000 }, - ); - assert.equal((await ui()).street.error, null); - await floatPanel(); - const viewerHeight = () => - page.$eval('#sl-viewer', (node) => - Math.round(node.getBoundingClientRect().height), ); - const before = await viewerHeight(); - const grip = await center('#street-level-panel .panel-resize-grip'); - await page.mouse.move(grip.x, grip.y); - await page.mouse.down(); - await page.mouse.move(grip.x + 120, grip.y + 160, { steps: 10 }); - await page.mouse.up(); - await sleep(600); - const after = await viewerHeight(); - assert.ok( - after > before + 60, - `viewer grew with the window (${before} → ${after}px)`, - ); - assert.equal( - await page.$eval( - '.sl-settings', - (node) => getComputedStyle(node).overflowY, - ), - 'auto', - 'only the settings block scrolls', - ); - }, - ); - await photoStep( - 'SHRINK after EXPAND docks the window back in the rail at its default size', - async () => { - await page.click('#sl-viewer-expand'); - await sleep(500); - await page.click('#sl-viewer-expand'); - await sleep(600); - assert.equal(await floating(), false, 'docked again'); - const style = await page.$eval('#street-level-panel', (node) => ({ - width: node.style.width, - height: node.style.height, - parent: node.parentElement.id, - })); - assert.deepEqual(style, { - width: '', - height: '', - parent: 'right-context-rail', - }); - }, - ); - 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, - true, - `docked again (${describePress(attempt)})`, + const after = await page.evaluate(() => ({ + expanded: document + .getElementById('sl-viewer-wrap') + .classList.contains('sl-viewer-wrap-expanded'), + focus: document.activeElement?.id, + inert: [...document.body.children].filter((node) => node.inert) + .length, + })); + assert.equal( + `${after.expanded}:${after.focus}`, + 'false:sl-viewer-expand', + ); + assert.equal( + after.inert, + inertBefore, + 'the application is live again', + ); + }, ); - }); - await step( - 'collapsing a floating window docks it as the rail strip', - async () => { - await floatPanel(); - await page.click( - '.panel-collapse-btn[data-collapse-target="street-level-panel"]', + await step( + '× closes the image and deselects it on the globe', + async () => { + await page.click('#sl-viewer-close'); + await uiUntil( + (u) => !u.street.open && u.sequence.selectedId === null, + ); + const after = await ui(); + assert.equal(after.street.open, false); + assert.equal(after.sequence.selectedId, null); + }, + ); + const floating = () => + page.$eval('#street-level-panel', (node) => + node.classList.contains('panel-floating'), ); - await sleep(600); - const state = await page.$eval('#street-level-panel', (node) => ({ - floating: node.classList.contains('panel-floating'), - collapsed: node.classList.contains('collapsed'), - height: node.style.height, - })); - assert.deepEqual(state, { - floating: false, - collapsed: true, - height: '', + const center = (selector) => + page.$eval(selector, (node) => { + const r = node.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }); - }, - ); - await photoStep( - 'on a phone the whole photo fits in the docked panel', - async () => { - // Width alone drives the phone layout; toggling isMobile would reload. - await page.setViewport({ width: 390, height: 844 }); - await sleep(800); - if ( - await page.$eval('#street-level-panel', (node) => - node.classList.contains('collapsed'), - ) - ) + // 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). + const floatPanel = async () => { + const attempt = await liftPanelByHeader(page, PANEL_ID, { + dx: -400, + dy: 80, + }); + assert.equal( + attempt.done, + true, + `a header drag lifts the panel out of the rail (${describePress(attempt)})`, + ); + assert.equal(await floating(), true); + }; + await step( + 'the panel floats on a header drag, resizes, and the viewer takes the room', + async () => { + await openById(firstImageId); + await floatPanel(); + const viewerHeight = () => + page.$eval('#sl-viewer', (node) => + Math.round(node.getBoundingClientRect().height), + ); + const before = await viewerHeight(); + const grip = await center('#street-level-panel .panel-resize-grip'); + await page.mouse.move(grip.x, grip.y); + await page.mouse.down(); + await page.mouse.move(grip.x + 120, grip.y + 160, { steps: 10 }); + await page.mouse.up(); + await settle( + page, + (height) => + document.getElementById('sl-viewer').getBoundingClientRect() + .height > + height + 60, + before, + ); + const after = await viewerHeight(); + assert.ok( + after > before + 60, + `viewer grew with the window (${before} → ${after}px)`, + ); + assert.equal( + await page.$eval( + '.sl-settings', + (node) => getComputedStyle(node).overflowY, + ), + 'auto', + 'only the settings block scrolls', + ); + }, + ); + await step( + 'SHRINK after EXPAND docks the window back in the rail at its default size, viewer inside the panel', + async () => { + await page.click('#sl-viewer-expand'); + await settle(page, () => + document + .getElementById('sl-viewer-wrap') + .classList.contains('sl-viewer-wrap-expanded'), + ); + assert.equal(await expanded(), true, 'expanded'); + await page.click('#sl-viewer-expand'); + await settle( + page, + () => + !document + .getElementById('street-level-panel') + .classList.contains('panel-floating') && + !document + .getElementById('sl-viewer-wrap') + .classList.contains('sl-viewer-wrap-expanded'), + ); + assert.equal(await floating(), false, 'docked again'); + const style = await page.$eval('#street-level-panel', (node) => ({ + width: node.style.width, + height: node.style.height, + parent: node.parentElement.id, + })); + assert.deepEqual(style, { + width: '', + height: '', + parent: 'right-context-rail', + }); + const home = await page.evaluate(() => { + const wrap = document.getElementById('sl-viewer-wrap'); + return { + inPanel: Boolean(wrap.closest('#street-level-panel')), + onBody: wrap.parentElement === document.body, + }; + }); + assert.deepEqual( + home, + { inPanel: true, onBody: false }, + 'the viewer returned into the panel', + ); + }, + ); + 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, + true, + `docked again (${describePress(attempt)})`, + ); + }); + await step( + 'collapsing a floating window docks it as the rail strip', + async () => { + await floatPanel(); await page.click( '.panel-collapse-btn[data-collapse-target="street-level-panel"]', ); - await page.evaluate((id) => { - void window.__godsEyeView.dataManager.layers - .get('street-level') - .module.openImage('mapillary', id); - }, firstImageId); - await page.waitForFunction( - () => { - const s = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().street; - return (s.imageId && !s.loading) || s.error; - }, - { timeout: 90_000 }, - ); - await sleep(600); - const fit = await page.evaluate(() => { - const inner = document - .querySelector('.street-level-panel-inner') - .getBoundingClientRect(); - const meta = document - .getElementById('sl-image-meta') - .getBoundingClientRect(); - const viewer = document - .getElementById('sl-viewer') - .getBoundingClientRect(); - return { - cut: Math.round(meta.bottom - inner.bottom), - top: Math.round(viewer.top - inner.top), - height: Math.round(viewer.height), - overflowX: document.documentElement.scrollWidth > innerWidth, - }; - }); - assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`); - assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`); - assert.equal(fit.overflowX, false, 'no sideways scroll'); - await page.setViewport(VIEWPORTS[0]); - await sleep(600); - }, - ); - if (fixtures) - await step( - 'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED', - async () => { - const keyless = await browser.newPage(); - await keyless.setViewport(VIEWPORTS[0]); - await serveFixtures(keyless, { - configured: false, - tiles: 'ok', - tileRequests: 0, + await settle(page, () => { + const node = document.getElementById('street-level-panel'); + return ( + node.classList.contains('collapsed') && + !node.classList.contains('panel-floating') + ); + }); + const state = await page.$eval('#street-level-panel', (node) => ({ + floating: node.classList.contains('panel-floating'), + collapsed: node.classList.contains('collapsed'), + height: node.style.height, + })); + assert.deepEqual(state, { + floating: false, + collapsed: true, + height: '', }); - watchErrors(keyless); - await boot(keyless, url); - await keyless.click( - '.panel-collapse-btn[data-collapse-target="street-level-panel"]', - ); - await assertKeylessGate(keyless); - await keyless.close(); }, ); - await step('no page errors', () => { - assert.deepEqual(errors, []); - }); - void module; + await step( + 'on a phone the whole photo fits in the docked panel', + async () => { + // Width alone drives the phone layout; toggling isMobile would reload. + await page.setViewport({ width: 390, height: 844 }); + await settle(page, () => innerWidth === 390); + if ( + await page.$eval('#street-level-panel', (node) => + node.classList.contains('collapsed'), + ) + ) + await expandStrip(); + await openById(firstImageId); + const measure = () => + page.evaluate(() => { + const inner = document + .querySelector('.street-level-panel-inner') + .getBoundingClientRect(); + const meta = document + .getElementById('sl-image-meta') + .getBoundingClientRect(); + const viewer = document + .getElementById('sl-viewer') + .getBoundingClientRect(); + return { + cut: Math.round(meta.bottom - inner.bottom), + top: Math.round(viewer.top - inner.top), + height: Math.round(viewer.height), + overflowX: document.documentElement.scrollWidth > innerWidth, + }; + }); + // The panel settles its phone layout over a frame or two. + await settle(page, () => { + const inner = document + .querySelector('.street-level-panel-inner') + .getBoundingClientRect(); + const meta = document + .getElementById('sl-image-meta') + .getBoundingClientRect(); + return ( + meta.bottom - inner.bottom <= 1 && + document.getElementById('sl-viewer').getBoundingClientRect() + .height >= 100 + ); + }); + const fit = await measure(); + assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`); + assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`); + assert.equal(fit.overflowX, false, 'no sideways scroll'); + await page.setViewport(VIEWPORTS[0]); + await settle( + page, + (width) => innerWidth === width, + VIEWPORTS[0].width, + ); + }, + ); + await step( + 'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token', + async () => { + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .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. + await page.$eval( + '.panel-collapse-btn[data-collapse-target="street-level-panel"]', + (button) => { + const panel = document.getElementById('street-level-panel'); + if (!panel.classList.contains('collapsed')) button.click(); + }, + ); + assert.ok( + await settle(page, () => + document + .getElementById('street-level-panel') + .classList.contains('collapsed'), + ), + 'the panel collapsed', + ); + const shared = (collapsed) => + settle( + page, + (assignment) => + (new URLSearchParams(location.hash.slice(1)).get('ui') || '') + .split('_') + .includes(assignment), + `${PANEL_UI_TOKEN}.c.${collapsed ? '1' : '0'}`, + ); + assert.ok( + await shared(true), + `the share link records the collapsed panel as ui ${PANEL_UI_TOKEN}.c.1: ${await page.evaluate(() => location.hash)}`, + ); + await page.reload({ waitUntil: 'domcontentloaded' }); + await clearFirstRun(page); + await waitForCoverage(page); + // The panel would open on the enable transition, before coverage. + const after = await page.evaluate(() => ({ + enabled: window.__godsEyeView.dataManager.isEnabled('street-level'), + collapsed: document + .getElementById('street-level-panel') + .classList.contains('collapsed'), + })); + assert.deepEqual( + after, + { enabled: true, collapsed: true }, + 'the restored layer did not reopen the panel', + ); + assert.ok(await shared(true), 'still shared as collapsed'); + }, + ); + if (fixtures) + await step( + 'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED', + async () => { + const keyless = await browser.newPage(); + await keyless.setViewport(VIEWPORTS[0]); + await watch(keyless, 'keyless'); + await serveFixtures(keyless, newFixture({ configured: false })); + await boot(keyless, url); + await expandStrip(keyless); + await assertKeylessGate(keyless); + renderErrorsSeen.push( + ...(await readRenderErrors(keyless)).map( + (text) => `[keyless] scene.renderError: ${text}`, + ), + ); + await keyless.close(); + }, + ); + else + await skipStep( + 'fixtures only', + 'keyless install (a second page) reports KEY REQUIRED', + ); + if (fixtures) + await step( + 'every Mapillary request was answered from a fixture', + () => { + console.log( + ` (answered: ${[...fixture.calls].sort().join(' | ')})`, + ); + assert.deepEqual(fixture.unknown, [], 'no unanswered call'); + }, + ); + } + } catch (error) { + const dir = await saveFailureArtifacts({ + dir: ARTIFACT_DIR, + browser, + monitors, + error, + }).catch(() => null); + if (dir) console.error(`failure evidence saved in ${dir}/`); + throw error; } finally { await browser.close(); } diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index bffd247c2..0053f08f5 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -6,7 +6,24 @@ import { fixtureTile, EXPECTED_PROVIDERS, isCollapsed, + PARK, + STRICT_ALLOWED_SKIPS, + strictViolations, } from '../../scripts/qa-street-level.mjs'; +import { + answerMapillaryRequest, + describeCall, + metresApart, + PHOTO_LINE, + PHOTO_SEQUENCE_ID, + photoImages, + THUMB_HOST, +} from '../../scripts/fixtures/street-level/mapillaryGraph.mjs'; +import { redact } from '../../scripts/qa-browserEvidence.mjs'; + +// The press helpers wait for paint; Node has no frames, so a tick stands in. +globalThis.requestAnimationFrame ??= (callback) => + setTimeout(() => callback(Date.now()), 0); test('the harness expects exactly the providers the app registers, in chip order', () => { // Any method a provider asks its source for is a no-op: only ids matter. @@ -45,11 +62,12 @@ test('fixture tiles give the hermetic gate something real to filter', async () = y: 6286, z: 14, }); - assert.equal(street.sequences.length, 8); - const pano = street.sequences.filter((s) => s.isPano).length; + const grid = street.sequences.filter((s) => s.id !== PHOTO_SEQUENCE_ID); + assert.equal(grid.length, 8); + const pano = grid.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; + const old = grid.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, @@ -159,6 +177,8 @@ function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) { const evaluate = async (fn, ...args) => inPage(fn, ...args); const press = () => { state.last = presses[state.downs++] ?? 'header'; + // 'none': the pointerdown never reaches the page. + if (state.last === 'none') return; const target = state.last === 'header' ? header : other; for (const listener of listeners.splice(0)) inPage(listener, { target }); }; @@ -202,7 +222,8 @@ test('a header press is retried only when it provably missed the header', async pressMissed({ done: false, pressed: { inHeader: false } }), true, ); - assert.equal(pressMissed({ done: false, pressed: null }), true); + // No recorded press is a failure, not a miss to retry. + assert.equal(pressMissed({ done: false, pressed: null }), false); }); test('a lift whose press the rail moved off the header is retried, then lifts', async () => { @@ -218,10 +239,17 @@ test('a lift whose press the rail moved off the header is retried, then lifts', }); assert.equal(attempt.done, true); assert.equal(page.state.downs, 2); - assert.equal(notes.length, 1); + assert.equal(notes.length, 2, 'a note and a GitHub warning'); assert.match(notes[0], /missed the street-level-panel header/); assert.match(notes[0], /section#weather-panel\.panel/, 'names what it hit'); - assert.match(describePress(attempt), /in header: true/); + assert.match(notes[0], /pre-press hit check passed/); + assert.match(notes[0], /stillness (held|timed out)/); + assert.match( + notes[0], + /panel box at hit check 100,200 300x400 -> at press 100,200 300x400/, + ); + assert.match(notes[1], /^::warning title=Panel press retried::retry 1\/2/); + assert.match(describePress(attempt), /in header: true, after 1 retry$/); }); test('a press in the header that does not lift fails at once, naming the target', async () => { @@ -278,3 +306,307 @@ test('both panel gates press the header through the shared helper', () => { assert.match(source, /dockPanelByDoubleClick\(page, PANEL_ID/, script); } }); + +/* ── Hermetic photo flow: the photo line and the Graph fixtures ────────── */ + +test('the photo sequence runs through the parked view, clipped to each tile it crosses', async () => { + const { decodeCoverageTile } = + await import('../layers/streetLevel/providers/mapillary/decode.js'); + const { lonToTileX, latToTileY } = + await import('../layers/streetLevel/tileMath.js'); + const x = lonToTileX(PHOTO_LINE.lon, 14); + const rows = new Set( + [PHOTO_LINE.south, PHOTO_LINE.north].map((lat) => latToTileY(lat, 14)), + ); + assert.ok(rows.size > 1, 'the line crosses a tile edge'); + let south = Infinity; + let north = -Infinity; + for (const y of rows) { + const line = decodeCoverageTile(fixtureTile(14, x, y), { + x, + y, + z: 14, + }).sequences.find((s) => s.id === PHOTO_SEQUENCE_ID); + assert.ok(line, `tile 14/${x}/${y} carries the photo line`); + for (const [lon, lat] of line.parts[0]) { + assert.ok(Math.abs(lon - PHOTO_LINE.lon) < 1e-4); + south = Math.min(south, lat); + north = Math.max(north, lat); + } + } + assert.ok(Math.abs(south - PHOTO_LINE.south) < 1e-4); + assert.ok(Math.abs(north - PHOTO_LINE.north) < 1e-4); + // Far away there is no photo line, only the grid. + const elsewhere = decodeCoverageTile(fixtureTile(14, 100, 100), { + x: 100, + y: 100, + z: 14, + }); + assert.equal( + elsewhere.sequences.some((s) => s.id === PHOTO_SEQUENCE_ID), + false, + ); + // The parked camera looks down onto the line. + assert.equal(PARK.lon, PHOTO_LINE.lon); + assert.ok(PARK.lat > PHOTO_LINE.south && PARK.lat < PHOTO_LINE.north); +}); + +test('the fixture photos: one sequence, 360° and flat, close enough for any nearest lookup on the line', () => { + const images = photoImages(Date.UTC(2026, 9, 1)); + assert.ok(images.length >= 3); + assert.ok(images.some((image) => image.isPano)); + assert.ok(images.some((image) => !image.isPano)); + for (let i = 1; i < images.length; i++) { + const gap = metresApart(images[i - 1], images[i]); + // Above the cones' 3 m thinning, and at most 50 m apart: any point on + // the line has a photo within the Graph API's 50 m radius. + assert.ok(gap > 3 && gap < 50, `gap ${gap}`); + } + assert.equal(new Set(images.map((image) => image.id)).size, images.length); +}); + +const graph = (path, params) => + `https://graph.mapillary.com/${path}?${new URLSearchParams({ access_token: 'MLY|0|qa-fixture', ...params })}`; +const answer = (url, method = 'GET') => { + const out = answerMapillaryRequest({ method, url }); + return { + ...out, + json: out.contentType === 'application/json' ? JSON.parse(out.body) : null, + }; +}; + +test('the Graph fixtures answer the provider’s nearest and sequence lookups', () => { + const images = photoImages(); + const middle = images[Math.floor(images.length / 2)]; + const near = answer( + graph('images', { + lat: String(middle.lat + 0.0001), + lng: String(middle.lon), + radius: '50', + limit: '8', + fields: 'id,geometry,is_pano,captured_at,sequence', + }), + ); + assert.equal(near.status, 200); + assert.ok(near.json.data.length > 0); + assert.ok(near.json.data.some((record) => record.id === middle.id)); + for (const record of near.json.data) { + assert.deepEqual(Object.keys(record).sort(), [ + 'captured_at', + 'geometry', + 'id', + 'is_pano', + 'sequence', + ]); + assert.equal(record.sequence, PHOTO_SEQUENCE_ID); + } + const far = answer( + graph('images', { lat: '0', lng: '0', radius: '50', fields: 'id' }), + ); + assert.deepEqual(far.json.data, [], 'nothing outside the radius'); + const sequence = answer( + graph('images', { + sequence_ids: PHOTO_SEQUENCE_ID, + fields: 'id,geometry,compass_angle,captured_at,is_pano', + limit: '2000', + }), + ); + assert.equal(sequence.json.data.length, images.length); + assert.deepEqual( + answer(graph('images', { sequence_ids: 'other', fields: 'id' })).json.data, + [], + ); +}); + +test('the Graph fixtures answer every call MapillaryJS makes to open a photo', () => { + const [first, second] = photoImages(); + const spatial = answer( + graph('images', { + image_ids: `${first.id},${second.id}`, + fields: + 'id,computed_geometry,geometry,sequence,camera_type,computed_rotation,thumb_1024_url,thumb_2048_url,width,height,merge_cc,sfm_cluster,mesh,creator,captured_at', + }), + ); + assert.equal(spatial.status, 200); + assert.equal(spatial.headers['Access-Control-Allow-Origin'], '*'); + const [pano, flat] = spatial.json.data; + assert.equal(pano.camera_type, 'spherical'); + assert.equal(flat.camera_type, 'perspective'); + assert.equal(pano.merge_cc, null, 'unmerged: no mesh request'); + assert.equal(pano.sfm_cluster, null, 'no cluster request'); + assert.equal(pano.computed_rotation.length, 3); + assert.equal(new URL(pano.thumb_2048_url).hostname, THUMB_HOST); + assert.equal( + answer(graph('images', { s2: '9749618446378729472', fields: 'id' })).json + .data.length, + 0, + ); + assert.deepEqual( + answer(graph('image_ids', { sequence_id: PHOTO_SEQUENCE_ID })).json.data[0], + { id: first.id }, + ); + assert.deepEqual( + answer(graph(`${first.id}/tiles`, { z: '11', fields: 'url,z,x,y' })).json, + { data: [] }, + ); + const photo = answer(pano.thumb_2048_url); + assert.equal(photo.status, 200); + assert.equal(photo.contentType, 'image/jpeg'); + assert.deepEqual([...photo.body.subarray(0, 3)], [0xff, 0xd8, 0xff]); + const preflight = answer(graph('images', { image_ids: first.id }), 'OPTIONS'); + assert.equal(preflight.status, 204); + assert.match( + preflight.headers['Access-Control-Allow-Headers'], + /Authorization/, + ); +}); + +test('a Mapillary call no fixture covers is answered but flagged unknown', () => { + for (const url of [ + graph('images', { bbox: '0,0,1,1' }), + graph('map_features', { fields: 'id' }), + 'https://tiles.mapillary.com/maps/vtp/mly1_public/2/14/1/1', + ]) { + const out = answer(url); + assert.equal(out.known, false, url); + assert.ok(out.status >= 400); + } +}); + +test('answered calls are named without the token', () => { + const call = describeCall( + 'GET', + new URL(graph('9100000000000/tiles', { z: '11', fields: 'url' })), + ); + assert.equal(call, 'GET graph.mapillary.com/{imageId}/tiles?fields&z'); + assert.doesNotMatch(call, /MLY|access_token/); + assert.equal( + describeCall( + 'GET', + new URL('https://qa-fixture.mapillary.com/thumb/9100000000016.jpg'), + ), + 'GET qa-fixture.mapillary.com/thumb/{imageId}.jpg', + ); +}); + +test('evidence never carries a Mapillary token', () => { + assert.equal( + redact( + 'GET https://graph.mapillary.com/images?access_token=MLY|123|abc&fields=id', + ), + 'GET https://graph.mapillary.com/images?access_token=REDACTED&fields=id', + ); + assert.equal( + redact('token MLY|123|abc in a log'), + 'token MLY|REDACTED in a log', + ); + assert.equal( + redact( + 'https://tile.googleapis.com/v1/3dtiles/x.glb?session=s1&key=AIzaSyBexampleexampleexampleexample00', + ), + 'https://tile.googleapis.com/v1/3dtiles/x.glb?session=s1&key=REDACTED', + ); + assert.equal( + redact('loaded with AIzaSyBexampleexampleexampleexample00 inline'), + 'loaded with AIza…REDACTED inline', + ); +}); + +test('--strict accepts only the documented skips', () => { + assert.deepEqual( + [...STRICT_ALLOWED_SKIPS], + ['no Google 3D', 'fixtures only'], + ); + assert.deepEqual( + strictViolations([ + { reason: 'no Google 3D', label: 'terrain' }, + { reason: 'fixtures only', label: 'keyless page' }, + { reason: 'no Mapillary key', label: 'the keyed steps' }, + ]), + [{ reason: 'no Mapillary key', label: 'the keyed steps' }], + ); +}); + +test('a press that records no pointerdown fails at once, without a retry', async () => { + const { liftPanelByHeader, describePress } = + await import('../../scripts/qa-panelDrag.mjs'); + const page = fakePanelPage({ presses: ['none', 'header'] }); + const attempt = await liftPanelByHeader(page, 'street-level-panel', { + dx: -400, + dy: 80, + log: () => assert.fail('no retry'), + pauseMs: 0, + }); + assert.equal(attempt.done, false); + assert.equal(attempt.pressed, null); + assert.equal(page.state.downs, 1); + assert.match(describePress(attempt), /no pointerdown reached the page/); +}); + +test('QA_FAIL_ON_RETRY turns a retry into a failure that says why', async () => { + const { liftPanelByHeader, failOnRetryDefault } = + await import('../../scripts/qa-panelDrag.mjs'); + assert.equal(failOnRetryDefault({ QA_FAIL_ON_RETRY: '1' }, []), true); + assert.equal(failOnRetryDefault({}, ['--fail-on-retry']), true); + assert.equal(failOnRetryDefault({}, []), false); + const page = fakePanelPage({ presses: ['other', 'header'] }); + await assert.rejects( + liftPanelByHeader(page, 'street-level-panel', { + dx: -400, + dy: 80, + log: () => {}, + pauseMs: 0, + failOnRetry: true, + }), + /QA_FAIL_ON_RETRY=1 .*missed the street-level-panel header.*pre-press hit check passed/, + ); + assert.equal(page.state.downs, 1); +}); + +test('both gates save failure evidence and fail on render-loop errors', () => { + for (const script of ['qa-street-level.mjs', 'qa-panel-resize.mjs']) { + const source = fs.readFileSync( + new URL(`../../scripts/${script}`, import.meta.url), + 'utf8', + ); + assert.match(source, /saveFailureArtifacts\(/, script); + assert.match(source, /hookRenderErrors\(/, script); + assert.match(source, /readRenderErrors\(/, script); + assert.match(source, /--fail-on-retry/, script); + } +}); + +test('CI runs the gates hermetically against a production build, strict, with evidence', () => { + const ci = fs.readFileSync( + new URL('../../.github/workflows/ci.yml', import.meta.url), + 'utf8', + ); + const job = ci.slice(ci.indexOf('street-level-browser:')); + const gate = job.slice(0, job.indexOf('\n windows-onboarding:')); + // The dummy token is baked into the bundle at build time and the preview + // server's status route reads it at run time: both steps carry it. + assert.match( + gate, + /MAPILLARY_CLIENT_TOKEN: 'MLY\|0\|qa-fixture'\s+run: npm run build/, + ); + assert.match( + gate, + /MAPILLARY_CLIENT_TOKEN: 'MLY\|0\|qa-fixture'\s+run: \|\s+npx vite preview --port 4173 --strictPort/, + ); + assert.doesNotMatch(gate, /npx vite --port/); + assert.equal( + (gate.match(/MAPILLARY_CLIENT_TOKEN: 'MLY\|0\|qa-fixture'/g) || []).length, + 2, + ); + assert.match(gate, /qa:street-level:fixtures -- --strict/); + assert.match( + gate, + /if: failure\(\)[\s\S]*actions\/upload-artifact@[0-9a-f]{40}/, + ); + assert.match(gate, /path: qa-artifacts\//); + assert.match( + gate, + /QA_FAIL_ON_RETRY: '1'/, + 'a missed header press fails CI now that the rail settles', + ); +});