Files
gods-eye-view/scripts/qa-street-level.mjs
T
Said TurkseverandClaude Opus 5.5 fb024d582b 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 <[email protected]>
2026-10-06 16:54:06 +01:00

1855 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Browser QA for the Street Level layer against a running server: the
* panel's place in the right rail, the provider chips (which switch the
* layer), the keyless gate, coverage and its credit, the imagery filter and
* SINCE slider, a click on the globe that selects a sequence, the viewer
* (opened, stepped, expanded as a modal dialog, closed), the framing flight,
* the panel as a floating, resizable window, a reload that keeps the panel
* collapsed, and a phone layout. Run with
* `npm run qa:street-level -- --url http://localhost:4173`.
*
* `--fixtures` makes the run hermetic for Mapillary (the CI mode): coverage
* tiles, the Graph API, the image CDN and every other *.mapillary.com
* request the page makes are answered from fixtures (fixtureTile and
* scripts/fixtures/street-level/mapillaryGraph.mjs), and nothing reaches
* Mapillary. The server still needs a token, because the browser bundle
* carries it; CI gives it a dummy, non-secret one, which never leaves the
* browser. Fixture runs add what a live run cannot stage: a key Mapillary
* rejects, and a second page whose server has no key. Every run first asks
* the server's real status route from Node, before the browser intercepts
* anything, so a server without the Mapillary routes fails.
*
* 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 },
{ width: 1280, height: 800 },
]);
/** 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',
'cctv-panel',
'weather-panel',
'recent-imagery-panel',
'street-level-panel',
'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');
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const DAY_MS = 86_400_000;
/**
* Synthetic coverage for any tile, so fixture runs are deterministic: at
* street zooms a grid of four east-west and four north-south sequences (half
* 360°, half captured three years ago) plus, in the tiles it crosses, the
* photo sequence whose images the Graph fixtures serve (PHOTO_LINE, under
* the parked camera); at overview zooms a few points.
* @returns {Uint8Array} empty for zooms that carry nothing
*/
export function fixtureTile(z, x, y, now = Date.now()) {
const tile = { x, y, z };
const { west, east, south, north } = tileBounds(x, y, z);
const at = (t, lo, hi) => lo + (hi - lo) * t;
if (z <= 5)
return encodeCoverageTile(tile, {
overview: [0.25, 0.5, 0.75].map((t, i) => ({
id: `fx-${z}-${x}-${y}-o${i}`,
lon: at(t, west, east),
lat: at(t, south, north),
isPano: i === 1,
capturedAt: now - 30 * DAY_MS,
})),
});
if (z < 11) return new Uint8Array(0);
const margin = (east - west) * 0.05;
const sequences = [];
for (let i = 1; i <= 4; i++) {
const t = i / 5;
const old = i > 2;
sequences.push({
id: `fx-${z}-${x}-${y}-h${i}`,
isPano: i % 2 === 0,
capturedAt: now - (old ? 1100 : 30) * DAY_MS,
parts: [
[
[west + margin, at(t, south, north)],
[east - margin, at(t, south, north)],
],
],
});
sequences.push({
id: `fx-${z}-${x}-${y}-v${i}`,
isPano: i % 2 === 1,
capturedAt: now - (old ? 30 : 1100) * DAY_MS,
parts: [
[
[at(t, west, east), south + margin],
[at(t, west, east), north - margin],
],
],
});
}
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 });
}
/**
* Ask the server's real Mapillary status route, outside the browser's
* interception, and require its JSON shape. Fixture runs answer the route in
* the page, so without this a server that never registered it would pass.
* Node sends no Origin or Sec-Fetch-Site, so the same-site gate admits it.
* @param {string} url the application's base URL
* @param {typeof fetch} [fetchImpl]
* @returns {Promise<{configured: boolean}>}
*/
export async function assertRealStatusRoute(url, fetchImpl = fetch) {
const route = new URL('/api/mapillary/status', url).href;
let response;
try {
response = await fetchImpl(route, {
headers: { Accept: 'application/json' },
});
} catch (error) {
throw new Error(`${route} is unreachable: ${error?.message || error}`, {
cause: error,
});
}
const type = response.headers.get('content-type') || '';
const text = await response.text();
assert.equal(
response.status,
200,
`${route} must be the server's Mapillary route (HTTP ${response.status}: ${text.slice(0, 120)})`,
);
assert.match(type, /application\/json/, `${route} answers JSON, not ${type}`);
let body;
try {
body = JSON.parse(text);
} catch {
assert.fail(`${route} answered unparseable JSON: ${text.slice(0, 120)}`);
}
assert.equal(
typeof body?.configured,
'boolean',
`${route} reports a boolean \`configured\`: ${text.slice(0, 120)}`,
);
return body;
}
/**
* 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, images: Array<object>, calls: Set<string>, unknown: Array<string>}} fixture
*/
export async function serveFixtures(page, fixture) {
// Only these URLs pause. Puppeteer's page-wide interception would pause
// Cesium's worker scripts too, and under it they stall: no terrain, no
// ground polylines, nothing to click.
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,
});
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,
});
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: '' };
}
}
}
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(() => {});
});
}
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 settle(
page,
() => !document.querySelector('.first-run-explore'),
null,
{
timeout: 3_000,
},
);
await page.keyboard.press('Escape');
await page.evaluate(() => {
for (const el of document.querySelectorAll(
'#first-run-launcher, [class*=first-run]',
))
el.remove();
});
}
/** 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(() => {
const el = document.getElementById('street-level-panel');
const rail = document.getElementById('right-context-rail');
const box = el.getBoundingClientRect();
return {
order: rail ? [...rail.children].map((child) => child.id) : [],
classes: [...el.classList],
width: Math.round(box.width),
right: Math.round(box.right),
status: document.getElementById('sl-status').textContent,
controlsDisabled: document.getElementById('sl-controls').disabled,
bodyDisplay: getComputedStyle(document.getElementById('sl-body')).display,
};
});
}
/** Wait until Street Level is on, drawn, settled and not key-gated. */
function waitForCoverage(page, { timeout = 90_000 } = {}) {
return page.waitForFunction(
() => {
const dm = window.__godsEyeView.dataManager;
const u = dm.layers.get('street-level').module.getUIState();
return (
dm.isEnabled('street-level') &&
u.coverage.count > 0 &&
!u.coverage.loading &&
!u.keyRequired
);
},
{ timeout },
);
}
/** Without a key on the server the panel gates its controls and says why. */
async function assertKeylessGate(page) {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
origin: 'user',
}),
);
await page.waitForFunction(
() => document.getElementById('sl-status').textContent === 'KEY REQUIRED',
{ timeout: 30_000 },
);
const info = await readPanel(page);
assert.equal(info.controlsDisabled, true);
assert.equal(info.status, 'KEY REQUIRED');
const chip = await page.$eval(
'#sl-provider-chips [data-chip-id="mapillary"]',
(node) => ({
error: node.classList.contains('chip-error'),
title: node.title,
}),
);
assert.equal(chip.error, true, 'the keyless provider chip reads as an error');
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:
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath()),
defaultViewport: VIEWPORTS[0],
protocolTimeout: 300_000,
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
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) => {
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;
};
/** A step this mode cannot run at all. */
const skipStep = (reason, label) => step(label, ({ skip }) => skip(reason));
const errors = [];
const monitors = [];
/** Collect page errors, render-loop errors and the listener exceptions the layer only warns about. */
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 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 = newFixture();
await watch(page, 'main');
if (fixtures) await serveFixtures(page, fixture);
await boot(page, url);
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 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));
assert.equal(info.bodyDisplay, 'none');
assert.ok(info.width <= 200 && info.right <= viewport.width);
},
);
}
await page.setViewport(VIEWPORTS[0]);
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 () => {
const info = await panel();
assert.ok(!isCollapsed(info.classes));
assert.equal(info.bodyDisplay, 'flex');
const chips = await page.evaluate(() =>
[
...document.querySelectorAll(
'#sl-provider-chips .data-toggle-chip',
),
].map((chip) => chip.dataset.chipId),
);
assert.deepEqual(chips, EXPECTED_PROVIDERS);
// One swatch per source plus "Selected"; each chip wears its colour.
assert.equal(
await page.evaluate(
() => document.querySelectorAll('#sl-legend li').length,
),
EXPECTED_PROVIDERS.length + 1,
);
assert.equal(
await page.$eval('[data-chip-id="mapillary"]', (chip) =>
chip.style.getPropertyValue('--chip-color'),
),
'#05cb63',
);
},
);
if (!status.configured) {
await step(
'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)',
);
} else {
await keyedSteps();
}
await step('no page errors and no Cesium render-loop errors', async () => {
renderErrorsSeen.push(
...(await readRenderErrors(page)).map(
(text) => `[main] scene.renderError: ${text}`,
),
);
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(', ')}`,
);
});
console.log(
`${count} steps: ${count - skips.length} ok, ${skips.length} skipped`,
);
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(at.lon, at.lat, at.height),
),
orientation: { heading: 0, pitch: at.pitch, roll: 0 },
});
}, PARK);
await sleep(1500);
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',
}),
);
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</a> contributors'),
{ timeout: 15_000 },
);
},
);
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(),
),
photoreal: () =>
page.evaluate(() =>
window.__godsEyeView.mapStackController.isStackAvailable(
'photoreal',
),
),
set: async (id) => {
await page.evaluate(
(stackId) =>
window.__godsEyeView.mapStackController.setStack(stackId),
id,
);
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</a> 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,
);
/** 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.refreshCoverage();
},
{ lon, lat },
);
/**
* Wait until no tile request has arrived for `quietMs` (longer than the
* camera-move debounce, so a refresh the pan scheduled has had its turn).
*/
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');
},
);
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 === 'draped',
{ timeout: 10_000 },
);
const coverageLoaded = () =>
waitForCoverage(page, { timeout: 60_000 });
await coverageLoaded();
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 <body> 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 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'),
);
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(
'× 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'),
);
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 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 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: '',
});
},
);
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();
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}