#!/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, KartaView and * Panoramax request from fixtures, so nothing reaches those providers. 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'; import { answerKartaViewRequest, describeKartaViewCall, KV_LINE, KV_SEQUENCE_ID, kartaViewLookups, kartaViewPhotos, resetKartaViewFixture, } from './fixtures/street-level/kartaView.mjs'; import { answerPanoramaxRequest, describePanoramaxCall, PNX_CREATOR, PNX_LINE, PNX_PARK, PNX_SEQUENCES, panoramaxHdDownloads, panoramaxPictures, resetPanoramaxFixture, } from './fixtures/street-level/panoramax.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', 'kartaview', 'panoramax', ]); /** * 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, calls: Set, unknown: Array}} 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/*' }, { urlPattern: '*://api.kartaview.org/*' }, { urlPattern: '*://cdn.kartaview.org/*' }, { urlPattern: '*://*.openstreetcam.org/*' }, { urlPattern: '*://api.panoramax.xyz/*' }, { urlPattern: '*://panoramax.openstreetmap.fr/*' }, ], }); 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 (/(^|\.)(kartaview|openstreetcam)\.org$/.test(address.hostname)) { const call = describeKartaViewCall(method, address); answer = answerKartaViewRequest({ method, url: request.url }); fixture.calls.add(call); if (!answer.known) fixture.unknown.push(call); } else if ( address.hostname === 'api.panoramax.xyz' || address.hostname === 'panoramax.openstreetmap.fr' ) { const call = describePanoramaxCall(method, address); answer = answerPanoramaxRequest({ method, url: request.url }); 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/); } /** * With no Mapillary key the panel points at KartaView, which needs none: * lit, it draws coverage and the layer is not key-gated. */ async function assertKeylessKartaView(page) { assert.match( await page.$eval('#sl-coverage-meta', (node) => node.textContent), /light KARTAVIEW/, 'KEY REQUIRED points at KartaView', ); for (let attempt = 0; attempt < 4; 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(1000); } await clickPanelControl( page, '#sl-provider-chips [data-chip-id="kartaview"]', ); await waitForCoverage(page); const state = await page.evaluate(() => ({ status: document.getElementById('sl-status').textContent, keyRequired: window.__godsEyeView.dataManager.layers .get('street-level') .module.getUIState().keyRequired, })); assert.equal(state.keyRequired, false); assert.doesNotMatch(state.status, /KEY/); } /** * 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. * Each call picks for at most `budgetMs`; the next call resumes the search. * @returns {{x: number, y: number, id: string}|null} client coordinates */ export function findPickPoint({ prefix, id, points, billboardIds, billboardPrefix = null, budgetMs = 4000, }) { 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]); } } // A pick is a render of the scene (0.5–2 s each under SwiftShader): try // the centres the canvas is on top of first, nearest the screen centre, // so the first pick usually hits. const uncovered = ([x, y]) => document.elementFromPoint(rect.left + x, rect.top + y) === canvas; const fromMiddle = ([x, y]) => (x - rect.width / 2) ** 2 + (y - rect.height / 2) ** 2; centres.sort( (a, b) => Number(uncovered(b)) - Number(uncovered(a)) || fromMiddle(a) - fromMiddle(b), ); const candidates = []; for (const [cx, cy] of centres) { candidates.push([cx, cy]); for (let r = 2; r <= 16; r += 2) for (const [dx, dy] of [ [r, 0], [-r, 0], [0, r], [0, -r], ]) candidates.push([cx + dx, cy + dy]); } // Aimed at known places: never fall back to a scan of the whole screen. if (!points && !billboardIds && billboardPrefix === 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) candidates.push([x, y]); // One evaluation stays well inside the caller's wait: past the budget it // returns null, and the next poll carries on from where this one stopped. const key = JSON.stringify([prefix, id, billboardPrefix]); const cursors = (window.__qaPickCursors ??= {}); const deadline = performance.now() + budgetMs; let at = cursors[key] ?? 0; if (at >= candidates.length) at = 0; for (; at < candidates.length; at++) { const hit = tryAt(...candidates[at]); if (hit) { delete cursors[key]; return hit; } if (performance.now() > deadline) { cursors[key] = at + 1; return null; } } delete cursors[key]; 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`, ); /** * KartaView: keyless coverage from its JSON tiles, a line click opening * the nearest photo, cones along the drive, › and a 360° photo. Runs on a * draped map so a line is where it is drawn; leaves Mapillary lit. */ async function kartaViewSteps() { // The first lookup from here on is the rate-limited one. resetKartaViewFixture(); const ui = () => page.evaluate(() => window.__godsEyeView.dataManager.layers .get('street-level') .module.getUIState(), ); const uiUntil = (test, arg = null, options) => settle( page, `(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`, undefined, options, ); const lit = (id) => uiUntil( (u, want) => u.providers.find((p) => p.id === want)?.on === true && u.providers.filter((p) => p.on).length === 1, id, ); const stack = () => page.evaluate(() => window.__godsEyeView.mapStackController.getActiveId(), ); const setStack = async (id) => { await page.evaluate( (stackId) => window.__godsEyeView.mapStackController.setStack(stackId), id, ); await settle( page, (stackId) => window.__godsEyeView.mapStackController.getActiveId() === stackId, id, ); }; const kvChip = '#sl-provider-chips [data-chip-id="kartaview"]'; const original = await stack(); const photos = kartaViewPhotos(); await page.evaluate(() => window.__godsEyeView.dataManager.layers .get('street-level') .module.closeViewer(), ); await setStack('esri-imagery'); // Over the fixture drive, looking down: its line is mid-screen. for (let attempt = 0; attempt < 4; 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(1000); } await step( 'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters, SINCE noted as its upload date', async () => { await clickPanelControl(page, kvChip); assert.ok(await lit('kartaview'), 'KartaView alone is lit'); await waitForCoverage(page); const u = await ui(); assert.equal(u.keyRequired, false); assert.deepEqual(u.filterControls, { pano: true, since: true }); // KartaView's SINCE is its upload date: the panel says so. assert.equal(u.sinceNote, 'KartaView: by upload date'); assert.equal( await page.$eval('#sl-since-note', (node) => node.hidden ? null : node.textContent, ), 'KartaView: by upload date', ); assert.equal( await page.$eval(kvChip, (chip) => chip.style.getPropertyValue('--chip-color'), ), '#635bff', ); assert.match( await page.$eval('#sl-coverage-meta', (node) => node.textContent), /streets in view · click a line for its nearest photo/, ); await page.waitForFunction( () => document.body.innerHTML.includes('KartaView contributors'), { timeout: 15_000 }, ); assert.ok( await settle(page, () => (new URLSearchParams(location.hash.slice(1)).get('lo') || '') .split('_') .some( (entry) => entry.startsWith('0.') && entry.includes('kartaview-1'), ), ), `the share link lights KartaView: ${await page.evaluate(() => location.hash)}`, ); }, ); await step( 'KartaView: 360° and SINCE thin its lines through KartaView’s filtered tiles, and ALL / ANY DATE bring them back', 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, ); // Filtered tiles are drawn by KartaView on demand: give them time. const settled = async (test, arg, what) => assert.ok( await uiUntil(test, arg, { timeout: 60_000 }), `${what}: ${JSON.stringify((await ui()).coverage)}`, ); await clickPanelControl(page, '[data-sl-pano="pano"]'); await settled( (u) => u.filter.pano === 'pano' && !u.coverage.loading, null, '360° drawn', ); const panos = (await ui()).coverage.count; assert.ok(panos <= before, `${panos} ≤ ${before}`); // Fixtures: the old street has only flat photos, the drive 360° too. if (fixtures) assert.ok(panos > 0 && panos < before, `0 < ${panos} < ${before}`); await clickPanelControl(page, '[data-sl-pano="all"]'); await settled( (u, n) => u.filter.pano === 'all' && !u.coverage.loading && u.coverage.count === n, before, 'every line again', ); await setStop(5); await settled( (u) => u.filter.sinceDays === 365 && !u.coverage.loading, null, 'LAST YEAR drawn', ); const lastYear = (await ui()).coverage.count; assert.ok(lastYear <= before, `${lastYear} ≤ ${before}`); // Fixtures: the drive is a month old, the old street from 2019. if (fixtures) assert.ok( lastYear > 0 && lastYear < before, `0 < ${lastYear} < ${before}`, ); await setStop(0); await settled( (u, n) => u.filter.sinceDays === 0 && !u.coverage.loading && u.coverage.count === n, before, 'ANY DATE: every line again', ); assert.equal((await ui()).coverage.error, null); }, ); await step( 'KartaView: a click on a line opens the nearest photo where the camera is, cones follow its drive, and › steps along it, past an archived photo', async ({ skip }) => { const along = Array.from({ length: 9 }, (_, i) => [ KV_LINE.lon, KV_LINE.south + ((i + 2) / 13) * (KV_LINE.north - KV_LINE.south), ]); const handle = await page .waitForFunction( findPickPoint, { timeout: 30_000, polling: 250 }, { prefix: 'kv:line:', id: null, points: fixtures ? along : null, billboardIds: null, }, ) .catch(() => null); if (!handle && !fixtures) return skip('no KartaView line in view'); assert.ok(handle, 'a KartaView line is pickable'); const at = await handle.jsonValue(); const camera = () => 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, }; }); const parked = await camera(); // The first lookup is rate-limited (fixtures): it waits and retries. await page.mouse.click(at.x, at.y); await page.waitForFunction( () => { const s = window.__godsEyeView.dataManager.layers .get('street-level') .module.getUIState().street; return (s.imageId && !s.loading) || s.error; }, { timeout: 60_000 }, ); const street = (await ui()).street; if (!fixtures && /No street-level imagery/.test(street.error || '')) return skip('no KartaView photo near the line'); if (fixtures) assert.ok( kartaViewLookups() >= 2, `the rate-limited lookup was asked again (${kartaViewLookups()} lookups)`, ); assert.equal(street.error, null, `viewer error: ${street.error}`); assert.equal(street.providerId, 'kartaview'); assert.equal(street.renderModes, true, 'FIT / FILL for a flat photo'); assert.ok( await settle(page, () => { const img = document.querySelector('.sl-photo-flat'); return img && !img.hidden && img.naturalWidth > 0; }), 'the photo is on screen', ); // A line click picks a spot: the camera stays where it was. await sleep(1500); const now = await camera(); assert.ok( Math.abs(now.lon - parked.lon) < 1e-6 && Math.abs(now.lat - parked.lat) < 1e-6 && Math.abs(now.height - parked.height) < 0.5, `the camera did not move: ${JSON.stringify({ parked, now })}`, ); if (fixtures) { const opened = photos.find((photo) => photo.id === street.imageId); assert.ok(opened, `${street.imageId} is a fixture photo`); assert.equal( opened.archived, false, 'the nearest photo that storage still has', ); assert.ok( await uiUntil( (u, want) => u.sequence.selectedId === want.id && !u.sequence.loading && u.sequence.images === want.count, { id: KV_SEQUENCE_ID, count: photos.length }, { timeout: 30_000 }, ), `cones for the whole drive: ${JSON.stringify((await ui()).sequence)}`, ); // The drive runs north; the photos' compass says 200°. assert.ok( await uiUntil( (u) => Number.isFinite(u.street.bearing) && Math.min(u.street.bearing, 360 - u.street.bearing) < 2, ), `the camera looks along the drive, not where the compass points: ${(await ui()).street.bearing}`, ); } const before = street.imageId; await clickPanelControl(page, '.sl-photo-step-next'); assert.ok( await uiUntil( (u, was) => u.street.imageId && u.street.imageId !== was && !u.street.error, before, { timeout: 30_000 }, ), '› opened the next photo', ); if (fixtures) { const skipped = photos.find( (photo) => photo.id === String(Number(before) + 1), ); assert.equal( skipped?.archived, true, 'the photo after is archived', ); assert.equal( (await ui()).street.imageId, String(Number(before) + 2), '› skipped the archived photo to the next one along the drive', ); } }, ); if (fixtures) await step( 'KartaView: a 360° photo opens on the panorama without FIT / FILL, dragging turns it, and it survives a lost WebGL context', async () => { const pano = photos.find((photo) => photo.isPano); await page.evaluate((id) => { void window.__godsEyeView.dataManager.layers .get('street-level') .module.openImage('kartaview', id); }, pano.id); assert.ok( await uiUntil( (u, want) => u.street.imageId === want && !u.street.loading && u.street.isPano, pano.id, { timeout: 60_000 }, ), `the panorama opened: ${JSON.stringify((await ui()).street)}`, ); const u = await ui(); assert.equal(u.street.error, null); assert.equal(u.street.renderModes, false, 'no frame to fit'); assert.equal( await page.$eval('.sl-photo-pano', (canvas) => canvas.hidden), false, ); const box = await page.$eval('.sl-photo-pano', (canvas) => { const r = canvas.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); const bearing = u.street.bearing; await page.mouse.move(box.x, box.y); await page.mouse.down(); for (let i = 1; i <= 8; i++) await page.mouse.move(box.x - i * 15, box.y); await page.mouse.up(); assert.ok( await uiUntil((v, was) => v.street.bearing !== was, bearing), 'dragging the panorama turned its bearing', ); // A GPU reset: the panorama comes back on a new canvas, and drags. assert.ok( await page.evaluate(() => { const canvas = document.querySelector('.sl-photo-pano'); window.__kvLostCanvas = canvas; const lose = canvas .getContext('webgl') ?.getExtension('WEBGL_lose_context'); lose?.loseContext(); return Boolean(lose); }), 'the browser can lose a WebGL context on purpose', ); assert.ok( await settle( page, () => { const canvas = document.querySelector('.sl-photo-pano'); return ( canvas && canvas !== window.__kvLostCanvas && !canvas.hidden && canvas.width > 1 ); }, null, { timeout: 30_000 }, ), 'the panorama is drawn again on a new canvas', ); const after = await ui(); assert.equal(after.street.imageId, pano.id); assert.equal(after.street.isPano, true); assert.equal(after.street.error, null); const turned = after.street.bearing; await page.mouse.move(box.x, box.y); await page.mouse.down(); for (let i = 1; i <= 8; i++) await page.mouse.move(box.x + i * 15, box.y); await page.mouse.up(); assert.ok( await uiUntil((v, was) => v.street.bearing !== was, turned), 'the new canvas drags', ); }, ); else await skipStep( 'fixtures only', 'KartaView: a 360° photo opens on the panorama without FIT / FILL, dragging turns it, and it survives a lost WebGL context', ); await page.evaluate(() => window.__godsEyeView.dataManager.layers .get('street-level') .module.closeViewer(), ); await clickPanelControl( page, '#sl-provider-chips [data-chip-id="mapillary"]', ); assert.ok(await lit('mapillary'), 'Mapillary is lit again'); await setStack(original); } /** * Panoramax: keyless orange coverage with both filters, a line click * opening that sequence's picture nearest the click (author, licence, * cones, ›), a 360° picture whose original is fetched once on EXPAND. * Live runs park over Paris, where Panoramax has pictures. Runs on a * draped map; leaves Mapillary lit. */ async function panoramaxSteps() { resetPanoramaxFixture(); const ui = () => page.evaluate(() => window.__godsEyeView.dataManager.layers .get('street-level') .module.getUIState(), ); const uiUntil = (test, arg = null, options) => settle( page, `(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`, undefined, options, ); const lit = (id) => uiUntil( (u, want) => u.providers.find((p) => p.id === want)?.on === true && u.providers.filter((p) => p.on).length === 1, id, ); const module = (call, ...args) => page.evaluate( (name, list) => window.__godsEyeView.dataManager.layers .get('street-level') .module[name](...list), call, args, ); const stack = () => page.evaluate(() => window.__godsEyeView.mapStackController.getActiveId(), ); const setStack = async (id) => { await page.evaluate( (stackId) => window.__godsEyeView.mapStackController.setStack(stackId), id, ); await settle( page, (stackId) => window.__godsEyeView.mapStackController.getActiveId() === stackId, id, ); }; const camera = () => 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, }; }); const pnxChip = '#sl-provider-chips [data-chip-id="panoramax"]'; const original = await stack(); await module('closeViewer'); await setStack('esri-imagery'); for (let attempt = 0; attempt < 4; 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 }, }); }, PNX_PARK); await sleep(1000); } try { await step( 'Panoramax: its chip draws keyless orange coverage, keeps both filters, credits Panoramax and lights in the share link', async () => { await clickPanelControl(page, pnxChip); assert.ok(await lit('panoramax'), 'Panoramax alone is lit'); await waitForCoverage(page); const u = await ui(); assert.equal(u.keyRequired, false); assert.deepEqual(u.filterControls, { pano: true, since: true }); assert.equal( await page.$eval(pnxChip, (chip) => chip.style.getPropertyValue('--chip-color'), ), '#ff6f00', ); assert.match( await page.$eval('#sl-coverage-meta', (node) => node.textContent), /sequences in view · click a line for its nearest photo/, ); await page.waitForFunction( () => document.body.innerHTML.includes('Panoramax contributors'), { timeout: 15_000 }, ); assert.ok( await settle(page, () => (new URLSearchParams(location.hash.slice(1)).get('lo') || '') .split('_') .some( (entry) => entry.startsWith('0.') && entry.includes('panoramax-1'), ), ), `the share link lights Panoramax: ${await page.evaluate(() => location.hash)}`, ); }, ); await step( 'Panoramax: a click on a line opens that sequence’s picture nearest the click, camera still, with author and licence, cones and ›', async ({ skip }) => { // Points on the street: the fixture's, or live pictures there. let points = null; if (fixtures) points = Array.from({ length: 9 }, (_, i) => [ PNX_LINE.west + ((i + 2) / 13) * (PNX_LINE.east - PNX_LINE.west), PNX_LINE.lat, ]); else { const near = await fetch( `https://api.panoramax.xyz/api/search?place_position=${PNX_PARK.lon},${PNX_PARK.lat}&place_distance=0-150&place_fov_tolerance=180&limit=12`, ) .then((r) => r.json()) .catch(() => null); points = near?.features?.map((f) => f.geometry.coordinates); if (!points?.length) return skip('no Panoramax picture near the park'); } const handle = await page .waitForFunction( findPickPoint, { timeout: 60_000, polling: 500 }, { prefix: 'pnx:seq:', id: null, points, billboardIds: null, }, ) .catch(() => null); if (!handle && !fixtures) return skip('no Panoramax line in view'); assert.ok(handle, 'a Panoramax line is pickable'); let at = await handle.jsonValue(); if (fixtures) { // Click the top line where the other sequence's pictures are: // the nearest picture is then the other's, and only the line // click's own-sequence lookup opens the clicked line's. const others = panoramaxPictures() .filter((p) => p.sequenceId !== at.id) .slice(40, 100) .map((p) => [p.lon, p.lat]); const aimed = await page .waitForFunction( findPickPoint, { timeout: 60_000, polling: 500 }, { prefix: 'pnx:seq:', id: at.id, points: others, billboardIds: null, }, ) .catch(() => null); assert.ok( aimed, 'the line is pickable over the other’s pictures', ); at = await aimed.jsonValue(); } const parked = await camera(); await page.mouse.click(at.x, at.y); await page.waitForFunction( () => { const s = window.__godsEyeView.dataManager.layers .get('street-level') .module.getUIState().street; return (s.imageId && !s.loading) || s.error; }, { timeout: 60_000 }, ); const street = (await ui()).street; assert.equal(street.error, null, `viewer error: ${street.error}`); assert.equal(street.providerId, 'panoramax'); assert.equal( street.sequenceId, at.id, 'the picture is from the line clicked', ); assert.ok(street.license, 'the caption names the licence'); assert.match( street.externalUrl, /^https:\/\/api\.panoramax\.xyz\/#pic=/, ); await sleep(1500); const now = await camera(); assert.ok( Math.abs(now.lon - parked.lon) < 1e-6 && Math.abs(now.lat - parked.lat) < 1e-6 && Math.abs(now.height - parked.height) < 0.5, `the camera did not move: ${JSON.stringify({ parked, now })}`, ); if (fixtures) { const caption = await page.$eval( '#sl-image-by', (node) => node.textContent, ); if (street.sequenceId === PNX_SEQUENCES[0]) assert.equal(caption, `Image by ${PNX_CREATOR} · CC BY-SA 4.0`); else assert.equal( caption, 'via panoramax.openstreetmap.fr · Licence Ouverte 2.0', 'an account named by an email address is not shown; the instance is', ); const pictures = panoramaxPictures().filter( (p) => p.sequenceId === street.sequenceId, ); assert.ok( await uiUntil( (u, want) => u.sequence.selectedId === want && !u.sequence.loading && u.sequence.images > 0, street.sequenceId, { timeout: 30_000 }, ), `cones along the sequence: ${JSON.stringify((await ui()).sequence)}`, ); assert.ok((await ui()).sequence.images <= pictures.length); } const before = street.imageId; await clickPanelControl(page, '.sl-photo-step-next'); assert.ok( await uiUntil( (u, was) => u.street.imageId && u.street.imageId !== was && !u.street.loading && !u.street.error, before, { timeout: 30_000 }, ), '› opened the next picture', ); const after = (await ui()).street; assert.equal(after.sequenceId, street.sequenceId); if (fixtures) { const all = panoramaxPictures(); const from = all.find((p) => p.id === before); assert.equal( after.imageId, all.find( (p) => p.sequenceId === from.sequenceId && p.index === from.index + 1, ).id, '› followed the picture’s next link', ); } }, ); if (fixtures) await step( 'Panoramax: a 360° picture opens on the panorama without FIT / FILL, drags, and EXPAND fetches its original once', async () => { const pano = panoramaxPictures().find((p) => p.isPano); await module('openImage', 'panoramax', pano.id); assert.ok( await uiUntil( (u, want) => u.street.imageId === want && !u.street.loading && u.street.isPano, pano.id, { timeout: 60_000 }, ), `the panorama opened: ${JSON.stringify((await ui()).street)}`, ); const u = await ui(); assert.equal(u.street.renderModes, false, 'no frame to fit'); const box = await page.$eval('.sl-photo-pano', (canvas) => { const r = canvas.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); const bearing = u.street.bearing; await page.mouse.move(box.x, box.y); await page.mouse.down(); for (let i = 1; i <= 8; i++) await page.mouse.move(box.x - i * 15, box.y); await page.mouse.up(); assert.ok( await uiUntil((v, was) => v.street.bearing !== was, bearing), 'dragging the panorama turned its bearing', ); assert.equal(panoramaxHdDownloads(), 0, 'docked: SD only'); await clickPanelControl(page, '#sl-viewer-expand'); assert.ok( await settle(page, () => Boolean(document.querySelector('.sl-viewer-wrap-expanded')), ), 'the viewer expanded', ); const deadline = Date.now() + 30_000; while (panoramaxHdDownloads() === 0 && Date.now() < deadline) await sleep(250); await sleep(2000); assert.equal( panoramaxHdDownloads(), 1, 'the original, once, for the expanded viewer', ); await page.keyboard.press('Escape'); }, ); else await skipStep( 'fixtures only', 'Panoramax: a 360° picture opens on the panorama without FIT / FILL, drags, and EXPAND fetches its original once', ); } finally { await module('closeViewer'); await clickPanelControl( page, '#sl-provider-chips [data-chip-id="mapillary"]', ); assert.ok(await lit('mapillary'), 'Mapillary is lit again'); await setStack(original); } } 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 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 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. // A loaded machine renders a frame in seconds: 60 s, not 30. const handle = await page.waitForFunction( findPickPoint, { timeout: 60_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. Aim at // where each cone billboard is drawn (its ground position at // height 0 can sit a few pixels off, and every missed pick costs a // render); the same 60 s as the line pick above. const cone = await page.waitForFunction( findPickPoint, { timeout: 60_000, polling: 250 }, { prefix: 'mly:img:', id: null, points: null, billboardIds: null, billboardPrefix: 'mly:img:', }, ); const picked = await cone.jsonValue(); assert.ok(picked, 'an image cone is pickable'); if (fixtures) assert.ok( fixture.images.some((image) => image.id === picked.id), `the cone is a photo of the drive: ${picked.id}`, ); 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, // A pick is a render; under SwiftShader on a busy machine one // evaluation can take seconds. { timeout: 30_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 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), beside // the other providers' switches a chip click recorded. const option = `${STREET_LEVEL_TOKEN}.r.google-1`; assert.ok( await settle( page, (prefix) => (new URLSearchParams(location.hash.slice(1)).get('lo') || '') .split('_') .some( (entry) => entry.startsWith(prefix) && entry .slice(prefix.length) .split('*') .includes('google-1'), ), `${STREET_LEVEL_TOKEN}.r.`, // 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 to Mapillary (one source at a time), as the next steps expect. await page.evaluate(() => window.__godsEyeView.dataManager.setLayerParams( 'street-level', { mapillary: true, google: false }, { origin: 'user' }, ), ); 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, 'GOOGLE 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 kartaViewSteps(); await panoramaxSteps(); 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, and KartaView draws there', 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); await assertKeylessKartaView(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, KartaView and Panoramax 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); }); }