Files
gods-eye-view/scripts/qa-street-level.mjs
T
Said TurkseverandClaude Opus 5.5 48ca99a38a fix(street-level): keep OPEN STREET VIEW whole beside a long coverage line
.scene-btn (flex: 1) loads after street-level.css and outranked the
button's flex: 0 0 auto, so with Mapillary and Street View both lit the
button shrank to its 72px minimum and its one-line label ran past the
panel edge. Scope the rule under .sl-meta-row; the coverage line wraps
instead. The live gate gives the share-link check 30 s, as the link is
written only once the view rests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 13:36:01 +01:00

2134 lines
78 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. Run with
* `npm run qa:street-level -- --url http://localhost:4173`.
*
* `--fixtures` (the CI mode) answers every *.mapillary.com request from
* fixtures, so nothing reaches Mapillary. The server still needs a token
* because the bundle carries it; CI uses a dummy one. Fixture runs also stage
* a rejected key and a keyless second server.
*
* `--strict` fails on any skip not in STRICT_ALLOWED_SKIPS. `--fail-on-retry`
* (or QA_FAIL_ON_RETRY=1) fails a missed header press unless the panel moved.
* A failed run saves evidence under qa-artifacts/street-level/.
*/
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,
waitForStill,
} 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';
export const VIEWPORTS = Object.freeze([
{ width: 1440, height: 900 },
{ width: 1280, height: 800 },
]);
/** Provider chips the panel must show, in order. */
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary', 'google']);
/**
* Skips no setup can avoid: CI forks have no Google key (so neither Google 3D
* nor Street View), and live runs cannot stage fixture-only cases. `no
* Mapillary key` is not accepted: CI's dummy token runs the keyed flow, so a
* keyless strict run means that wiring broke. Nor is `Google key refused`: a
* key that is set must work.
*/
export const STRICT_ALLOWED_SKIPS = Object.freeze([
'no Google 3D',
'no Google key',
'fixtures only',
]);
/** A Sacramento street with Google Street View (10th St, by the Capitol). */
export const STREET_VIEW_STREET = Object.freeze({
lon: -121.49372,
lat: 38.57986,
});
export function strictViolations(skips) {
return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason));
}
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';
/** 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',
]);
/** 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;
/**
* Deterministic coverage for any tile: a grid of mixed 360°/flat, new/old
* sequences plus the photo sequence (PHOTO_LINE), or a few overview points.
* @returns {Uint8Array} empty for zooms that carry nothing
*/
export function fixtureTile(z, x, y, now = Date.now()) {
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 real status route from Node, outside the page's interception, so a
* server that never registered it fails even in fixture runs. Node sends no
* Origin, so the same-site gate admits it.
* @returns {Promise<{configured: boolean}>}
*/
export async function assertRealStatusRoute(url, fetchImpl = fetch) {
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 `fixture`, which steps may change
* mid-run. `configured: undefined` lets the server's real status route answer.
* @param {{configured?: boolean, tiles: 'ok'|'rejected', tileRequests: number, images: Array<object>, calls: Set<string>, unknown: Array<string>}} fixture
*/
export async function serveFixtures(page, fixture) {
// CDP Fetch on just these URLs: Puppeteer's page-wide interception stalls
// Cesium's worker scripts, leaving nothing to click.
const cdp = await page.createCDPSession();
await cdp.send('Fetch.enable', {
patterns: [
{ 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;
}
}
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();
});
}
/**
* Click a panel control once the panel holds still and the control is what
* lies under its centre: the rail animates panel heights, so a point read
* once can miss by the time the pointer goes down.
*/
async function clickPanelControl(page, selector, { timeout = 5_000 } = {}) {
await waitForStill(page, PANEL_ID);
const deadline = Date.now() + timeout;
for (;;) {
const hit = await page.$eval(selector, (el) => {
el.scrollIntoView({ block: 'nearest' });
const r = el.getBoundingClientRect();
const x = r.left + r.width / 2;
const y = r.top + r.height / 2;
const top = document.elementFromPoint(x, y);
return {
x,
y,
ok: Boolean(top && (top === el || el.contains(top))),
target: top
? `${top.tagName.toLowerCase()}${top.id ? `#${top.id}` : ''}`
: 'nothing',
};
});
if (hit.ok) return page.mouse.click(hit.x, hit.y);
if (Date.now() > deadline)
throw new Error(`${selector} is covered by ${hit.target}`);
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
/** Load the app and clear the first-run dialog. */
export async function boot(page, url) {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await clearFirstRun(page);
}
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 },
);
}
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/);
}
/**
* Runs in the page. Find a point where a real click picks `prefix` + `id` (any
* id when null), searched around `points` ([lon, lat]), the aimed billboards,
* or else a grid mid-screen. Only points where the canvas is topmost count.
* @returns {{x: number, y: number, id: string}|null} client coordinates
*/
export function findPickPoint({
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;
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 = [];
/** `fn` gets `skip(reason)`: the step runs what it can but reports a skip. */
const step = async (label, fn) => {
let skipped = null;
const result = await fn({
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 = [];
/** The layer only warns on listener exceptions, so count those as errors. */
const watch = async (page, name) => {
monitors.push(
watchPage(page, {
name,
errors,
isError: (text) => /listener error/i.test(text),
}),
);
await hookRenderErrors(page);
};
const renderErrorsSeen = [];
try {
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 clickPanelControl(
target,
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
);
assert.ok(
await settle(
target,
() =>
!document
.getElementById('street-level-panel')
.classList.contains('collapsed') &&
getComputedStyle(document.getElementById('sl-body')).display ===
'flex',
),
'Street Level panel did not expand before the timeout',
);
};
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),
);
// Every registered provider usable on this map, in order (Street View
// only on Google 3D).
const listed = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState()
.providers.map((provider) => provider.id),
);
assert.deepEqual(chips, listed);
assert.deepEqual(
EXPECTED_PROVIDERS.filter((id) => listed.includes(id)),
listed,
);
assert.ok(listed.includes('mapillary'));
// One swatch per switched-on source plus "Selected" (Street View
// starts off); each chip wears its colour.
const legend = await page.evaluate(() => ({
swatches: document.querySelectorAll('#sl-legend li').length,
on: window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState()
.providers.filter((provider) => provider.on).length,
}));
assert.equal(legend.swatches, legend.on + 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() {
// A late startup flight only shows itself by moving the camera, so park
// and check it stays put rather than reading a state.
const park = async () => {
for (let attempt = 0; attempt < 6; attempt++) {
await page.evaluate((at) => {
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(),
);
/** `test(u, arg)` runs in the page, so it must be self-contained. */
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 clickPanelControl(page, '#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 clickPanelControl(page, '#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 clickPanelControl(page, 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 clickPanelControl(page, 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 clickPanelControl(page, '[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 clickPanelControl(page, '[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 () => {
// Catches a broken dynamic import before the first click would.
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,
);
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`. */
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 clickPanelControl(page, '#sl-status');
await statusText('OFF');
await clickPanelControl(page, '#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 clickPanelControl(page, '#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 clickPanelControl(page, '#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 clickPanelControl(page, '#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 clickPanelControl(page, '#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 clickPanelControl(page, '#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 };
});
// Right after a dock the rail is still animating, so the shared helpers
// re-resolve the header and retry only a press that provably missed it.
const floatPanel = async () => {
const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -400,
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 clickPanelControl(page, '#sl-viewer-expand');
await settle(page, () =>
document
.getElementById('sl-viewer-wrap')
.classList.contains('sl-viewer-wrap-expanded'),
);
assert.equal(await expanded(), true, 'expanded');
await clickPanelControl(page, '#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();
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 () => {
// The double-click just docked a panel showing a photo: let the photo
// and its sequence finish loading before pressing the header again.
await uiUntil((u) => !u.street.loading && !u.sequence.loading);
await floatPanel();
await clickPanelControl(
page,
'.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,
);
},
);
const googleChip = '#sl-provider-chips [data-chip-id="google"]';
const googleKey = await page.evaluate(() =>
Boolean(window.__GOOGLE_MAPS_API_KEY__),
);
await step(
'Street View starts off, appears only on the Google 3D map, and travels in the share link',
async () => {
const module = () =>
window.__godsEyeView.dataManager.layers.get('street-level').module;
assert.equal(
await page.evaluate(`(${module})().getParams().google`),
false,
'off until lit: panoramas are billed',
);
const setStreetView = (on) =>
page.evaluate(
(value) =>
window.__godsEyeView.dataManager.setLayerParams(
'street-level',
{ google: value },
{ origin: 'user' },
),
on,
);
const shown = () =>
page.evaluate(() => ({
chip: Boolean(
document.querySelector(
'#sl-provider-chips [data-chip-id="google"]',
),
),
legend: [...document.querySelectorAll('#sl-legend li')].some(
(item) => /Street View/.test(item.textContent),
),
credit: document.body.innerHTML.includes('Street View imagery'),
button: !document.getElementById('sl-nearest-btn').hidden,
}));
const original = await stacks.active();
// Off Google 3D (CI's only map) nothing of Street View shows, even
// switched on: Google's terms keep it off other maps.
if (original === 'photoreal') await stacks.set('esri-imagery');
await setStreetView(true);
await uiUntil((u) => u.providers.every((p) => p.id !== 'google'));
assert.ok(
await settle(
page,
() => !document.body.innerHTML.includes('Street View imagery'),
),
'no Google credit on another map',
);
assert.deepEqual(await shown(), {
chip: false,
legend: false,
credit: false,
button: false,
});
// Street View rides the shared provider-switch field (r).
const option = `${STREET_LEVEL_TOKEN}.r.google-1`;
assert.ok(
await settle(
page,
(token) =>
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
.split('_')
.includes(token),
option,
// The link is written once the view rests for 500 ms; under a
// software renderer with a photo open that can take a while.
{ timeout: 30_000 },
),
`the share link keeps the choice: ${option}`,
);
if (await stacks.photoreal()) {
await stacks.set('photoreal');
await uiUntil((u) => u.providers.some((p) => p.id === 'google'));
const chipNow = await page.$eval(googleChip, (node) => ({
className: node.className,
title: node.title,
color: node.style.getPropertyValue('--chip-color'),
}));
assert.equal(chipNow.color, '#4285f4');
if (googleKey) assert.match(chipNow.className, /chip-active/);
else {
assert.match(chipNow.className, /chip-error/);
assert.match(chipNow.title, /GOOGLE_MAPS_API_KEY/);
}
assert.ok(
await settle(page, () =>
document.body.innerHTML.includes('Street View imagery'),
),
'the Google credit shows on Google 3D',
);
}
// Back off, as the next steps expect.
await setStreetView(false);
await stacks.set(original);
await uiUntil(
(u) => u.providers.find((p) => p.id === 'google')?.on !== true,
);
},
);
await step(
'on Google 3D a click on a street opens Google Street View where it is, captioned and linked, and its view drives the pose',
async ({ skip }) => {
if (!googleKey) return skip('no Google key');
// Google's terms: Street View only beside Google's own map.
if (!(await stacks.photoreal())) return skip('no Google 3D');
const original = await stacks.active();
const closePhoto = () =>
page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.closeViewer(),
);
const setOn = async (selector, id, on) => {
const now = (await ui()).providers.find((p) => p.id === id)?.on;
if (now === on) return;
await clickPanelControl(page, selector);
assert.ok(
await uiUntil(
(u, want) =>
u.providers.find((p) => p.id === want.id)?.on === want.on,
{ id, on },
),
`the ${id} chip switched ${on ? 'on' : 'off'}`,
);
};
await closePhoto();
await stacks.set('photoreal');
// A Mapillary line under the pointer wins the click: aim at bare
// ground with only Street View on (lit first: the last lit chip
// would switch the layer off).
await setOn(googleChip, 'google', true);
await setOn(chip, 'mapillary', false);
try {
const street = STREET_VIEW_STREET;
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, 300),
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}, street);
// At street zoom the hint invites the click.
assert.ok(
await uiUntil(
(u) =>
u.providers.find((p) => p.id === 'google')?.hint ===
'click a street to open Street View',
),
'the click hint shows at street zoom',
);
const camera = () =>
page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera.positionWC;
return [c.x, c.y, c.z];
});
const at = await page.evaluate((point) => {
const v = window.__godsEyeView.viewer;
const C = v.camera.positionCartographic.constructor;
const ground = v.scene.globe.ellipsoid.cartographicToCartesian(
C.fromDegrees(point.lon, point.lat, 0),
);
const canvas = v.scene.cartesianToCanvasCoordinates(ground);
const box = v.scene.canvas.getBoundingClientRect();
return (
canvas && { x: box.left + canvas.x, y: box.top + canvas.y }
);
}, street);
assert.ok(at, 'the street is on screen');
const before = await camera();
await page.mouse.click(at.x, at.y);
await uiUntil(
(u) =>
Boolean(u.street.error) ||
(u.street.providerId === 'google' &&
Boolean(u.street.imageId) &&
!u.street.loading),
null,
{ timeout: 30_000 },
);
const opened = (await ui()).street;
if (/refused GOOGLE_MAPS_API_KEY/.test(opened.error || ''))
return skip('Google key refused');
if (/did not answer/.test(opened.error || ''))
return skip('Google did not answer');
assert.equal(opened.error, null);
assert.equal(opened.providerId, 'google');
const after = await camera();
assert.ok(
Math.hypot(...after.map((v, i) => v - before[i])) < 1,
'the camera stays where the user clicked',
);
const shown = await page.evaluate(() => ({
by: document.getElementById('sl-image-by').textContent,
when: document.getElementById('sl-image-when').textContent,
label: document.getElementById('sl-image-link').textContent,
href: document.getElementById('sl-image-link').href,
fitFillHidden:
document.querySelector('[data-sl-render]').parentElement.hidden,
panorama: Boolean(
document.querySelector('#sl-viewer .sl-google-panorama'),
),
mapillaryLeft: Boolean(
document.querySelector('#sl-viewer .mapillary-dom'),
),
}));
assert.match(shown.by, /Image by /);
assert.match(shown.when, /\d{4}-\d{2}$/, 'dated by month');
assert.equal(shown.label, 'STREET VIEW ↗');
assert.match(shown.href, /map_action=pano&pano=/);
assert.equal(shown.fitFillHidden, true, 'no FIT/FILL for Google');
assert.equal(shown.panorama, true);
assert.equal(shown.mapillaryLeft, false, 'no viewer left under it');
// A real drag turns Google's view, and the pose follows it.
const box = await page.$eval(
'#sl-viewer .sl-google-panorama',
(node) => {
const r = node.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
},
);
await page.mouse.move(box.x, box.y);
await page.mouse.down();
await page.mouse.move(box.x - 120, box.y, { steps: 12 });
await page.mouse.up();
assert.ok(
await uiUntil(
(u, start) =>
Number.isFinite(u.street.bearing) &&
Math.abs(u.street.bearing - start) > 5,
opened.bearing ?? 0,
{ timeout: 10_000 },
),
'the pose bearing follows the drag',
);
// The keyboard path: OPEN STREET VIEW at the view centre.
await closePhoto();
await uiUntil((u) => u.street.open === false);
await clickPanelControl(page, '#sl-nearest-btn');
assert.ok(
await uiUntil(
(u) =>
u.street.providerId === 'google' &&
Boolean(u.street.imageId) &&
!u.street.loading,
null,
{ timeout: 30_000 },
),
'OPEN STREET VIEW opens the panorama at the view centre',
);
// Leaving Google 3D closes the panorama (Google's terms).
await stacks.set('esri-imagery');
assert.ok(
await uiUntil((u) => u.street.open === false),
'the panorama closes off Google 3D',
);
} finally {
await closePhoto();
// Off Google 3D the Street View chip is hidden: return to it
// before switching the chip, or the cleanup throws and hides
// the step's own result.
await stacks.set('photoreal');
await setOn(chip, 'mapillary', true);
await setOn(googleChip, 'google', false);
await stacks.set(original);
}
},
);
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(),
);
// Press through the DOM: a toast over the button must not make this
// step about pointer aim.
await page.$eval(
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
(button) => {
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);
});
}