Files
gods-eye-view/scripts/qa-street-level.mjs
T
Said Turksever 489156366a Merge upstream main into pr/street-level-show-action
Street Level: keep the PR's billedPerImage contract note beside main's keyReady, and both sides' layer tests (armed OPEN, failing source) and (keyless default lighting).
2026-11-06 17:52:55 +00:00

1086 lines
37 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:
* `npm run qa:street-level -- --url http://localhost:4173`.
*
* `--fixtures` (CI) answers every *.mapillary.com, KartaView and Panoramax
* request and the coverage tile route from fixtures, so nothing reaches those
* providers; the server still needs a (dummy) Mapillary token because the
* bundle carries it. `--strict` fails on any
* skip other than "fixtures only". A failed run saves evidence under
* qa-artifacts/street-level/.
*/
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { tileBounds } from '../src/layers/streetLevel/tileMath.js';
import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.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';
import {
answerKartaViewRequest,
describeKartaViewCall,
KV_SEQUENCE_ID,
kartaViewPhotos,
resetKartaViewFixture,
} from './fixtures/street-level/kartaView.mjs';
import {
answerPanoramaxRequest,
describePanoramaxCall,
PNX_PARK,
panoramaxPictures,
resetPanoramaxFixture,
} from './fixtures/street-level/panoramax.mjs';
const VIEWPORT = { width: 1440, height: 900 };
/** Over downtown Sacramento, above the fixture photo line, looking down. */
const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 };
const DAY_MS = 86_400_000;
/** A Sacramento street with Street View, for the sl=google link step. */
const STREET_VIEW_STREET = Object.freeze({ lon: -121.49372, lat: 38.57986 });
/**
* What a Street View lookup and load cost: the Maps JS API's
* SingleImageSearch (the nearest lookup), GetMetadata and the panorama tiles.
* Script loads are not counted.
*/
const STREET_VIEW_REQUEST =
/MapsJsInternalService\/(SingleImageSearch|GetMetadata)|GeoPhotoService|streetviewpixels-pa\.googleapis\.com/;
/**
* Coverage for any street-zoom tile: a grid of 360°/flat, recent/old
* sequences, plus the photo sequence where PHOTO_LINE crosses the tile.
* @returns {Uint8Array} empty below z11, which the layer never asks for
*/
function fixtureTile(z, x, y, now = Date.now()) {
if (z < 11) return new Uint8Array(0);
const { west, east, south, north } = tileBounds(x, y, z);
const at = (t, lo, hi) => lo + (hi - lo) * t;
const margin = (east - west) * 0.05;
const sequences = [];
for (let i = 1; i <= 4; i++) {
const t = i / 5;
const old = i > 2;
const lat = at(t, south, north);
const lon = at(t, west, east);
sequences.push(
{
id: `fx-${z}-${x}-${y}-h${i}`,
isPano: i % 2 === 0,
capturedAt: now - (old ? 1100 : 30) * DAY_MS,
parts: [
[
[west + margin, lat],
[east - margin, lat],
],
],
},
{
id: `fx-${z}-${x}-${y}-v${i}`,
isPano: i % 2 === 1,
capturedAt: now - (old ? 30 : 1100) * DAY_MS,
parts: [
[
[lon, south + margin],
[lon, north - margin],
],
],
},
);
}
const lineSouth = Math.max(PHOTO_LINE.south, south);
const lineNorth = Math.min(PHOTO_LINE.north, north);
if (PHOTO_LINE.lon >= west && PHOTO_LINE.lon < east && lineSouth < lineNorth)
sequences.push({
id: PHOTO_SEQUENCE_ID,
isPano: false,
capturedAt: now - 30 * DAY_MS,
parts: [
[
[PHOTO_LINE.lon, lineSouth],
[PHOTO_LINE.lon, lineNorth],
],
],
});
return encodeCoverageTile({ x, y, z }, { sequences });
}
/** Fixture answers and call names per keyless provider host. */
const KEYLESS_HOSTS = [
{
host: /(^|\.)(kartaview|openstreetcam)\.org$/,
describe: describeKartaViewCall,
answer: answerKartaViewRequest,
},
{
host: /^(api\.panoramax\.xyz|panoramax\.openstreetmap\.fr)$/,
describe: describePanoramaxCall,
answer: answerPanoramaxRequest,
},
];
/**
* Answer the page's Mapillary, KartaView and Panoramax requests from
* `fixture`, which steps may change mid-run. `configured: undefined` lets the
* server's real status route answer.
*/
async function serveFixtures(page, fixture) {
// CDP Fetch on just these URLs: page-wide interception stalls Cesium workers.
const cdp = await page.createCDPSession();
await cdp.send('Fetch.enable', {
patterns: [
{ urlPattern: '*://*.mapillary.com/*' },
{ urlPattern: '*/api/mapillary/*' },
{ urlPattern: '*://api.kartaview.org/*' },
{ urlPattern: '*://cdn.kartaview.org/*' },
{ urlPattern: '*://*.openstreetcam.org/*' },
{ urlPattern: '*://api.panoramax.xyz/*' },
{ urlPattern: '*://panoramax.openstreetmap.fr/*' },
],
});
const json = (status, payload) => ({
status,
contentType: 'application/json',
body: JSON.stringify(payload),
});
const answerFor = (method, address) => {
if (/(^|\.)mapillary\.com$/.test(address.hostname)) {
const call = describeCall(method, address);
const answer = answerMapillaryRequest({
method,
url: address.href,
images: fixture.images,
});
fixture.calls.add(call);
if (!answer.known) fixture.unknown.push(call);
return answer;
}
const keyless = KEYLESS_HOSTS.find(({ host }) =>
host.test(address.hostname),
);
if (keyless) {
const call = keyless.describe(method, address);
const answer = keyless.answer({ method, url: address.href });
fixture.calls.add(call);
if (!answer.known) fixture.unknown.push(call);
return answer;
}
if (address.pathname === '/api/mapillary/status')
return fixture.configured === undefined
? null
: json(200, { configured: fixture.configured });
const tile = address.pathname.match(
/^\/api\/mapillary\/tiles\/coverage\/(\d+)\/(\d+)\/(\d+)$/,
);
if (!tile) return null;
if (fixture.configured === false)
return json(503, { error: 'no_key', keyRequired: true });
if (fixture.rejected)
return json(403, {
error: 'Mapillary rejected the access token',
keyRejected: true,
});
const bytes = fixtureTile(...tile.slice(1).map(Number));
return bytes.length
? { status: 200, contentType: 'application/x-protobuf', body: bytes }
: { status: 204, body: '' };
};
cdp.on('Fetch.requestPaused', ({ requestId, request }) => {
const answer = answerFor(request.method, new URL(request.url));
const done = answer
? cdp.send('Fetch.fulfillRequest', {
requestId,
responseCode: answer.status,
responseHeaders: Object.entries({
...answer.headers,
...(answer.contentType
? { 'Content-Type': answer.contentType }
: {}),
}).map(([name, value]) => ({ name, value: String(value) })),
body: Buffer.from(answer.body ?? '').toString('base64'),
})
: cdp.send('Fetch.continueRequest', { requestId });
// A page that navigated or closed meanwhile has no request to answer.
done.catch(() => {});
});
}
function newFixture(overrides = {}) {
return {
configured: undefined,
rejected: false,
images: photoImages(),
calls: new Set(),
unknown: [],
...overrides,
};
}
/** Load the app and dismiss the first-run dialog. */
async function boot(page, url) {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), {
timeout: 150_000,
});
await page.evaluate(() =>
document.querySelector('.first-run-explore')?.click(),
);
await page.keyboard.press('Escape');
await page.evaluate(() => {
for (const el of document.querySelectorAll(
'#first-run-launcher, [class*=first-run]',
))
el.remove();
});
}
/**
* Open a share link as a fresh document. Changing only the hash of a live
* page and reloading races the app's share-link writer, which can put its own
* state back in the hash before the reload reads it. The page's fixtures (a
* CDP session on the page) carry over.
*/
async function bootLink(page, link) {
await page.goto('about:blank');
await boot(page, link);
}
/** The camera's position in degrees and metres. */
const cameraAt = (page) =>
page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera.positionCartographic;
return {
lon: (c.longitude * 180) / Math.PI,
lat: (c.latitude * 180) / Math.PI,
height: c.height,
};
});
/** Run `test(uiState, arg)` in the page until it is true. */
const uiUntil = (page, test, arg = null, timeout = 30_000) =>
page.waitForFunction(
`(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`,
{ timeout, polling: 100 },
);
/** `id` is the one provider lit. */
const litAlone = (u, id) =>
u.providers.find((p) => p.id === id)?.on === true &&
u.providers.filter((p) => p.on).length === 1;
const getUI = (page) =>
page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState(),
);
const statusIs = (page, text) =>
page.waitForFunction(
(want) => document.getElementById('sl-status').textContent === want,
{ timeout: 30_000 },
text,
);
const waitForCoverage = (page) =>
uiUntil(
page,
(u) =>
u.enabled &&
u.coverage.count > 0 &&
!u.coverage.loading &&
!u.keyRequired,
null,
90_000,
);
/**
* Press a panel control through the DOM: the rail animates panel heights, so
* a pointer aimed from one frame's layout can miss in the next.
*/
const press = (page, selector) =>
page.$eval(selector, (control) => control.click());
/** Expand the strip; a panel state stored by an earlier page may have done so. */
async function expandPanel(page) {
const collapsed = await page.$eval('#street-level-panel', (node) =>
node.classList.contains('collapsed'),
);
if (collapsed)
await press(
page,
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
);
await page.waitForFunction(
() =>
getComputedStyle(document.getElementById('sl-body')).display === 'flex',
{ timeout: 10_000 },
);
}
/** A keyless bundle opens on the first keyless source with live controls. */
async function assertKeylessDefault(page) {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
origin: 'user',
}),
);
await uiUntil(page, litAlone, 'kartaview');
const shown = await page.evaluate(() => ({
status: document.getElementById('sl-status').textContent,
disabled: document.getElementById('sl-controls').disabled,
}));
assert.notEqual(shown.status, 'KEY REQUIRED');
assert.equal(shown.disabled, false, 'controls stay live');
}
async function assertKeyRequired(page) {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
origin: 'user',
}),
);
await statusIs(page, 'KEY REQUIRED');
const shown = await page.evaluate(() => ({
disabled: document.getElementById('sl-controls').disabled,
hint: document.getElementById('sl-error').hidden
? ''
: document.getElementById('sl-error').textContent,
}));
assert.equal(shown.disabled, true, 'controls are gated');
assert.match(shown.hint, /MAPILLARY_CLIENT_TOKEN/, 'names the key to add');
}
/**
* Client coordinates of the first thing drawn whose pick id starts with
* `prefix`, scanning `box` ({x, y, w, h} in canvas pixels; default: a row
* across the middle) every `step` px; null when nothing matches.
*/
const findPick = (page, prefix, { box = null, step = 3 } = {}) =>
page.evaluate(
(want, area, stride) => {
const { scene } = window.__godsEyeView.viewer;
const rect = scene.canvas.getBoundingClientRect();
const { x, y, w, h } = area || {
x: 0,
y: Math.round(rect.height / 2),
w: rect.width,
h: 1,
};
for (let py = y; py < y + h; py += stride)
for (let px = x; px < x + w; px += stride) {
const id = scene.pick({ x: px, y: py })?.id;
if (typeof id === 'string' && id.startsWith(want))
return {
x: rect.left + px,
y: rect.top + py,
local: { x: px, y: py },
id,
};
}
return null;
},
prefix,
box,
step,
);
/**
* `findPick`, retried until a hit or `timeout`: draped lines build in a
* worker after their tile decoded, so a scan right after LOADING clears can
* come too early on a slow machine.
*/
async function findPickUntil(
page,
prefix,
{ timeout = 30_000, box = null, step } = {},
) {
const deadline = Date.now() + timeout;
for (;;) {
const hit = await findPick(page, prefix, { box, step });
if (hit || Date.now() >= deadline) return hit;
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
/** Park the camera at `at`; a late startup flight can move it. */
async function park(page, at = PARK) {
for (let attempt = 0; attempt < 5; attempt++) {
const stayed = await page.evaluate(async (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 },
});
await new Promise((resolve) => setTimeout(resolve, 1500));
const c = v.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
);
}, at);
if (stayed) return;
}
throw new Error(`the camera would not stay parked at ${JSON.stringify(at)}`);
}
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');
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath()),
defaultViewport: VIEWPORT,
protocolTimeout: 300_000,
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
let count = 0;
const skips = [];
const skip = (reason, label) => {
skips.push(reason);
console.log(`skip (${reason}) ${++count} ${label}`);
};
// `fn` may call `skipped(reason)` when a live run has nothing to test.
const step = async (label, fn) => {
let reason = null;
await fn({ skipped: (why) => (reason = why) });
if (reason) skip(reason, label);
else console.log(`ok ${++count} ${label}`);
};
const fixtureStep = (label, fn) =>
fixtures ? step(label, fn) : skip('fixtures only', label);
const errors = [];
const monitors = [];
// The layer only warns on listener exceptions, so count those as errors.
const open = async (name, fixture) => {
const page = await browser.newPage();
monitors.push(
watchPage(page, {
name,
errors,
isError: (text) => /listener error/i.test(text),
}),
);
await hookRenderErrors(page);
if (fixture) await serveFixtures(page, fixture);
await boot(page, url);
return page;
};
const collectRenderErrors = async (page, name) =>
errors.push(
...(await readRenderErrors(page)).map(
(text) => `[${name}] scene.renderError: ${text}`,
),
);
/**
* A keyless provider: lighting its chip darkens the others and draws its
* coverage with its credit; a click on a line opens a photo from it; ×
* closes it. Leaves that provider lit.
*/
const keylessProviderStep = ({
id,
name,
at,
linePrefix,
box,
reset,
check,
}) =>
step(
`${name}: its chip draws keyless coverage and its credit, a line opens its photo, × closes it`,
async ({ skipped }) => {
reset();
await park(page, at);
await press(page, `#sl-provider-chips [data-chip-id="${id}"]`);
await uiUntil(page, litAlone, id);
await waitForCoverage(page);
await page.waitForFunction(
(credit) => document.body.innerHTML.includes(credit),
{ timeout: 15_000 },
`${name}</a> contributors`,
);
const area =
box === 'column'
? await page.evaluate(() => {
const { height, width } =
window.__godsEyeView.viewer.scene.canvas.getBoundingClientRect();
return { x: Math.round(width * 0.3), y: 0, w: 1, h: height };
})
: null;
const line = await findPickUntil(page, linePrefix, { box: area });
if (!line && !fixtures) return skipped(`no ${name} line in view`);
assert.ok(line, `a ${name} line in view`);
await page.mouse.click(line.x, line.y);
await uiUntil(
page,
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
null,
90_000,
);
const { street } = await getUI(page);
if (!fixtures && /No street-level imagery/.test(street.error || ''))
return skipped(`no ${name} photo near the line`);
assert.equal(street.error, null, `viewer error: ${street.error}`);
assert.equal(street.providerId, id);
assert.equal(
await page.$eval('#sl-viewer-wrap', (node) => node.hidden),
false,
);
if (fixtures)
check(street, line.id.slice(linePrefix.length).split('~')[0]);
await press(page, '#sl-viewer-close');
await uiUntil(
page,
(u) => !u.street.open && u.sequence.selectedId === null,
);
assert.equal(
await page.$eval('#sl-viewer-wrap', (node) => node.hidden),
true,
);
},
);
let page = null;
try {
const fixture = newFixture();
page = await open('main', fixtures && fixture);
const { configured } = await page.evaluate(() =>
fetch('/api/mapillary/status').then((res) => res.json()),
);
if (fixtures)
assert.equal(
configured,
true,
'fixture runs need a server started with MAPILLARY_CLIENT_TOKEN (CI uses a dummy one)',
);
await expandPanel(page);
if (!configured && litAlone(await getUI(page), 'mapillary')) {
// A keyed bundle behind a server without the token keeps Mapillary.
await step('keyless server: KEY REQUIRED gates the controls', () =>
assertKeyRequired(page),
);
skip('no Mapillary key', 'the keyed steps');
} else if (!configured) {
await step('keyless server: opens on KartaView, not KEY REQUIRED', () =>
assertKeylessDefault(page),
);
await step(
'keyless server: the MAPILLARY chip explains its key instead of switching',
async () => {
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
await new Promise((resolve) => setTimeout(resolve, 500));
assert.ok(litAlone(await getUI(page), 'kartaview'));
},
);
skip('no Mapillary key', 'the keyed steps');
} else {
await park(page);
await step(
'enabling draws coverage and the Mapillary credit',
async () => {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
origin: 'user',
}),
);
await waitForCoverage(page);
await statusIs(page, 'ON');
assert.equal(
await page.$eval('#sl-controls', (node) => node.disabled),
false,
);
await page.waitForFunction(
() =>
document.body.innerHTML.includes('Mapillary</a> contributors'),
{ timeout: 15_000 },
);
},
);
await fixtureStep(
'a rejected key reads KEY REJECTED and clears when switched off',
async () => {
fixture.rejected = true;
await press(page, '#sl-status');
await statusIs(page, 'OFF');
await press(page, '#sl-status');
await statusIs(page, 'KEY REJECTED');
const shown = await page.evaluate(() => ({
error: document.getElementById('sl-error').textContent,
hidden: document.getElementById('sl-error').hidden,
disabled: document.getElementById('sl-controls').disabled,
}));
assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/);
assert.equal(shown.hidden, false);
assert.equal(shown.disabled, true);
await press(page, '#sl-status');
await statusIs(page, 'OFF');
assert.equal(
await page.$eval(
'#sl-error',
(node) => node.hidden || !node.textContent,
),
true,
'no stale error while off',
);
fixture.rejected = false;
await press(page, '#sl-status');
await waitForCoverage(page);
await statusIs(page, 'ON');
},
);
await park(page);
await waitForCoverage(page);
await step(
'a line selects its sequence, and a cone opens its photo with a caption',
async () => {
// Fixtures answer lookups for the photo sequence only.
const line = await findPickUntil(
page,
fixtures ? `mly:seq:${PHOTO_SEQUENCE_ID}` : 'mly:seq:',
);
assert.ok(line, 'a sequence line across the middle of the view');
await page.mouse.click(line.x, line.y);
await uiUntil(
page,
(u) => u.sequence.selectedId && !u.sequence.loading,
);
// Cones clamp to the ground: on a slow GPU (CI's SwiftShader) they
// can take a few frames after the selection to become pickable.
const cone = await findPickUntil(page, 'mly:img:', {
box: {
x: Math.max(0, line.local.x - 150),
y: Math.max(0, line.local.y - 150),
w: 300,
h: 300,
},
step: 8,
});
assert.ok(cone, 'an image cone near the selected line');
await page.mouse.click(cone.x, cone.y);
await uiUntil(
page,
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
null,
90_000,
);
const { street } = await getUI(page);
assert.equal(street.error, null, `viewer error: ${street.error}`);
await page.waitForSelector('#sl-viewer .mapillary-dom', {
timeout: 60_000,
});
await page.waitForFunction(
() =>
document.getElementById('sl-image-when').textContent.trim()
.length > 0,
{ timeout: 30_000 },
);
const view = await page.evaluate(() => ({
hidden: document.getElementById('sl-viewer-wrap').hidden,
width: document.getElementById('sl-viewer').getBoundingClientRect()
.width,
link: document.getElementById('sl-image-link').href,
}));
assert.equal(view.hidden, false);
assert.ok(view.width > 200, `viewer width ${view.width}`);
assert.match(view.link, /mapillary\.com\/app\/\?pKey=/);
if (fixtures) {
assert.ok(
fixture.images.some((image) => image.id === street.imageId),
`fixture photo ${street.imageId}`,
);
assert.equal(street.sequenceId, PHOTO_SEQUENCE_ID);
}
// Opening selects the photo's sequence, so its cones are drawn.
await uiUntil(
page,
(u) => u.sequence.selectedId === u.street.sequenceId,
);
},
);
await step(
'FIT/FILL keeps native selection, keyboard focus and persistence with a photo open',
async () => {
await page.click('[data-sl-render="fill"]');
await uiUntil(page, (u) => u.street.renderMode === 'fill');
assert.equal(
await page.evaluate(() =>
localStorage.getItem('gev:street-level:render-mode'),
),
'fill',
);
await page.keyboard.press('Home');
await uiUntil(page, (u) => u.street.renderMode === 'letterbox');
assert.deepEqual(
await page.$eval('[data-sl-render="letterbox"]', (node) => ({
selected: node.getAttribute('aria-checked'),
tabIndex: node.tabIndex,
focused: document.activeElement === node,
})),
{ selected: 'true', tabIndex: 0, focused: true },
);
await page.keyboard.press('End');
await uiUntil(page, (u) => u.street.renderMode === 'fill');
},
);
await step('× closes the photo and deselects its sequence', async () => {
await press(page, '#sl-viewer-close');
await uiUntil(
page,
(u) => !u.street.open && u.sequence.selectedId === null,
);
assert.equal(
await page.$eval('#sl-viewer-wrap', (node) => node.hidden),
true,
);
});
await step(
'panorama segments keep native arrow and Home navigation',
async () => {
await page.click('[data-sl-pano="flat"]');
await uiUntil(page, (u) => u.filter.pano === 'flat');
await page.keyboard.press('ArrowRight');
await uiUntil(page, (u) => u.filter.pano === 'all');
await page.keyboard.press('End');
await uiUntil(page, (u) => u.filter.pano === 'flat');
await page.keyboard.press('Home');
await uiUntil(page, (u) => u.filter.pano === 'all');
await waitForCoverage(page);
},
);
await keylessProviderStep({
id: 'kartaview',
name: 'KartaView',
at: PARK,
linePrefix: 'kv:line:',
// A row across the middle crosses the drive, west of the old street.
box: null,
reset: resetKartaViewFixture,
check: (street) => {
assert.equal(street.sequenceId, KV_SEQUENCE_ID);
assert.ok(
kartaViewPhotos().some((photo) => photo.id === street.imageId),
`fixture photo ${street.imageId}`,
);
},
});
await keylessProviderStep({
id: 'panoramax',
name: 'Panoramax',
at: PNX_PARK,
linePrefix: 'pnx:seq:',
// The street runs east-west: scan a column left of the middle.
box: 'column',
reset: resetPanoramaxFixture,
check: (street, lineId) => {
assert.equal(street.sequenceId, lineId, 'the line clicked');
assert.ok(
panoramaxPictures().some((photo) => photo.id === street.imageId),
`fixture picture ${street.imageId}`,
);
},
});
// Back to Mapillary, where the next page and a reload expect it.
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
await uiUntil(page, litAlone, 'mapillary');
await park(page);
const base = url.split('#')[0];
await fixtureStep(
'a link to street-level photos (sl=) opens the photo at its point once the link restores',
async () => {
const photo = fixture.images[0];
// The camera sits ~330 m north of the photo: the lookup must use
// the link's point, not the view centre.
const cameraLat = Number((photo.lat + 0.003).toFixed(6));
await bootLink(
page,
`${base}#v=2&lat=${cameraLat}&lon=${photo.lon}&alt=600&heading=0&pitch=-60&roll=0&sl=mapillary,${photo.lat},${photo.lon}`,
);
await uiUntil(
page,
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
null,
90_000,
);
const { street } = await getUI(page);
assert.equal(street.error, null, `viewer error: ${street.error}`);
assert.equal(street.providerId, 'mapillary');
assert.equal(
street.imageId,
photo.id,
`the photo at the link's point, not the one nearest the camera: ${street.imageId}`,
);
assert.equal(
await page.evaluate(() => location.hash.includes('sl=')),
false,
'the rewritten link no longer carries the one-shot request',
);
},
);
await fixtureStep(
'the show_street_level action opens the nearest photo at a point without a pointer',
async () => {
await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.closeViewer(),
);
const photo = fixture.images[0];
const result = await page.evaluate(
(at) =>
window.__godsEyeView.voiceCommands.runner('show_street_level', {
latitude: at.lat,
longitude: at.lon,
provider: 'mapillary',
fly: false,
}),
{ lat: photo.lat, lon: photo.lon },
);
assert.equal(
result.ok,
true,
`action failed: ${result.stage} ${result.error}`,
);
assert.equal(result.provider, 'mapillary');
assert.ok(
fixture.images.some((image) => image.id === result.imageId),
`a fixture photo opened: ${result.imageId}`,
);
assert.match(result.say || '', /^Mapillary at /);
},
);
await step(
'an sl=google link never loads a photo by itself: keyless it stops at the key, keyed it arms and waits for OPEN',
async ({ skipped }) => {
const point = STREET_VIEW_STREET;
// ~330 m north of the street: a flight would move the camera, and a
// press that ignored the armed point would open something else.
const cameraLat = Number((point.lat + 0.003).toFixed(6));
const googleRequests = [];
const onRequest = (request) => {
if (STREET_VIEW_REQUEST.test(request.url()))
googleRequests.push(request.url());
};
page.on('request', onRequest);
try {
await bootLink(
page,
`${base}#v=2&lat=${cameraLat}&lon=${point.lon}&alt=600&heading=0&pitch=-60&roll=0&map=osm&sl=google,${point.lat},${point.lon}`,
);
await page.waitForFunction(
(want) => {
const c =
window.__godsEyeView.viewer.camera.positionCartographic;
return Math.abs((c.latitude * 180) / Math.PI - want) < 0.0005;
},
{ timeout: 60_000 },
cameraLat,
);
const googleKey = await page.evaluate(() =>
Boolean(window.__GOOGLE_MAPS_API_KEY__),
);
if (!googleKey) {
// Without a key the action stops at the key, before any flight,
// map switch or source change, and the direct call says why.
const direct = await page.evaluate(
(at) =>
window.__godsEyeView.voiceCommands.runner(
'show_street_level',
{
latitude: at.lat,
longitude: at.lon,
provider: 'google',
fly: false,
autoOpen: false,
},
),
point,
);
assert.equal(direct.ok, false);
assert.equal(
direct.stage,
'key',
`${direct.stage} ${direct.error}`,
);
const after = await page.evaluate(() => {
const m =
window.__godsEyeView.dataManager.layers.get(
'street-level',
).module;
const u = m.getUIState();
return {
stack: window.__godsEyeView.mapStackController.getActiveId(),
google: m.getParams().google === true,
armed: u.street.armed,
open: u.street.open,
};
});
assert.deepEqual(
after,
{ stack: 'osm', google: false, armed: null, open: false },
'a keyless Street View link changes nothing',
);
const still = await cameraAt(page);
assert.ok(
Math.abs(still.lat - cameraLat) < 0.0005,
`the camera did not fly: ${still.lat}`,
);
assert.deepEqual(googleRequests, [], 'no Street View request');
return;
}
const photoreal = await page.evaluate(() =>
window.__godsEyeView.mapStackController.isStackAvailable(
'photoreal',
),
);
if (!photoreal) return skipped('no Google 3D');
await uiUntil(
page,
(u) =>
u.street.armed !== null &&
u.providers.some((p) => p.id === 'google' && p.on),
null,
90_000,
);
const beforePress = googleRequests.length;
const waiting = await getUI(page);
assert.equal(
waiting.street.open,
false,
'nothing open without a click',
);
assert.equal(waiting.street.imageId, null);
assert.deepEqual(
waiting.street.armed.point,
{ lat: point.lat, lon: point.lon },
'armed at the link point',
);
assert.equal(
beforePress,
0,
'no Street View request before the press',
);
await expandPanel(page);
await press(page, '#sl-nearest-btn');
await uiUntil(
page,
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
null,
90_000,
);
const { street } = await getUI(page);
assert.equal(street.error, null, `viewer error: ${street.error}`);
assert.equal(street.providerId, 'google');
assert.ok(
googleRequests.length >= 1,
'the press made the Street View request',
);
const metres = Math.hypot(
(street.position.lat - point.lat) * 111_320,
(street.position.lon - point.lon) *
111_320 *
Math.cos((point.lat * Math.PI) / 180),
);
assert.ok(
metres <= 250,
`within 250 m of the link point: ${Math.round(metres)} m`,
);
// Back to Mapillary: the keyless page restores the saved layer
// and expects its KEY REQUIRED.
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
await uiUntil(page, litAlone, 'mapillary');
} finally {
page.off('request', onRequest);
}
},
);
await fixtureStep(
// The fixture bundle carries a token, so this covers a keyless
// server behind a keyed bundle; the keyless default is checked above.
'keyless server (second page): KEY REQUIRED gates the controls',
async () => {
const keyless = await open(
'keyless',
newFixture({ configured: false }),
);
await expandPanel(keyless);
await assertKeyRequired(keyless);
await collectRenderErrors(keyless, 'keyless');
await keyless.close();
},
);
await fixtureStep(
'every Mapillary, KartaView and Panoramax request was answered from a fixture',
() => {
console.log(` (answered: ${[...fixture.calls].sort().join(' | ')})`);
assert.deepEqual(fixture.unknown, []);
},
);
}
await step('no page errors and no Cesium render-loop errors', async () => {
await collectRenderErrors(page, 'main');
assert.deepEqual(errors, []);
});
if (strict)
await step('strict: no skips other than "fixtures only"', () =>
assert.deepEqual(
skips.filter((reason) => reason !== 'fixtures only'),
[],
),
);
console.log(
`${count} steps: ${count - skips.length} ok, ${skips.length} skipped`,
);
} catch (error) {
const dir = await saveFailureArtifacts({
dir: 'qa-artifacts/street-level',
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);
});
}