mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
The fixture gate answers api.kartaview.org, cdn.kartaview.org and *.openstreetcam.org from fixtures (a drive with a 360° photo, stored on storage2 while the API names storage1) and adds three steps: keyless coverage with its credit and hidden filters, a line click opening the nearest photo with cones and ›, and a 360° photo on the panorama without FIT / FILL. The Street View share-link check reads the switch field instead of one exact entry, since a chip click records every provider's switch. README, DATA_SOURCES, CHANGELOG and CURRENT-STATE describe KartaView. Co-Authored-By: Claude Opus 5.5 <[email protected]>
2415 lines
88 KiB
JavaScript
2415 lines
88 KiB
JavaScript
#!/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 and KartaView
|
||
* request from fixtures, so nothing reaches Mapillary or KartaView. 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,
|
||
kartaViewPhotos,
|
||
} from './fixtures/street-level/kartaView.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',
|
||
]);
|
||
|
||
/**
|
||
* Skips no setup can avoid: CI forks have no Google key (so neither Google 3D
|
||
* nor Street View), and live runs cannot stage fixture-only cases. `no
|
||
* Mapillary key` is not accepted: CI's dummy token runs the keyed flow, so a
|
||
* keyless strict run means that wiring broke. Nor is `Google key refused`: a
|
||
* key that is set must work.
|
||
*/
|
||
export const STRICT_ALLOWED_SKIPS = Object.freeze([
|
||
'no Google 3D',
|
||
'no Google key',
|
||
'fixtures only',
|
||
]);
|
||
|
||
/** A Sacramento street with Google Street View (10th St, by the Capitol). */
|
||
export const STREET_VIEW_STREET = Object.freeze({
|
||
lon: -121.49372,
|
||
lat: 38.57986,
|
||
});
|
||
|
||
export function strictViolations(skips) {
|
||
return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason));
|
||
}
|
||
|
||
const STREET_LEVEL_TOKEN = LAYER_STATE_REGISTRY.find(
|
||
(entry) => entry.id === 'street-level',
|
||
).token;
|
||
|
||
/** The Street Level panel's token in the share link's `ui` panel state. */
|
||
export const PANEL_UI_TOKEN = 't';
|
||
|
||
/** Right-rail order once the layout controller has run. */
|
||
export const RAIL_ORDER = Object.freeze([
|
||
'pp-toggles',
|
||
'cctv-panel',
|
||
'weather-panel',
|
||
'recent-imagery-panel',
|
||
'street-level-panel',
|
||
'global-context-panel',
|
||
]);
|
||
|
||
/** Over downtown Sacramento, looking north and down. */
|
||
export const PARK = Object.freeze({
|
||
lon: -121.4944,
|
||
lat: 38.5816,
|
||
height: 900,
|
||
pitch: -1.3,
|
||
});
|
||
|
||
export function isCollapsed(classList) {
|
||
return [...classList].includes('collapsed');
|
||
}
|
||
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
const DAY_MS = 86_400_000;
|
||
|
||
/**
|
||
* Deterministic coverage for any tile: a grid of mixed 360°/flat, new/old
|
||
* sequences plus the photo sequence (PHOTO_LINE), or a few overview points.
|
||
* @returns {Uint8Array} empty for zooms that carry nothing
|
||
*/
|
||
export function fixtureTile(z, x, y, now = Date.now()) {
|
||
const tile = { x, y, z };
|
||
const { west, east, south, north } = tileBounds(x, y, z);
|
||
const at = (t, lo, hi) => lo + (hi - lo) * t;
|
||
if (z <= 5)
|
||
return encodeCoverageTile(tile, {
|
||
overview: [0.25, 0.5, 0.75].map((t, i) => ({
|
||
id: `fx-${z}-${x}-${y}-o${i}`,
|
||
lon: at(t, west, east),
|
||
lat: at(t, south, north),
|
||
isPano: i === 1,
|
||
capturedAt: now - 30 * DAY_MS,
|
||
})),
|
||
});
|
||
if (z < 11) return new Uint8Array(0);
|
||
const margin = (east - west) * 0.05;
|
||
const sequences = [];
|
||
for (let i = 1; i <= 4; i++) {
|
||
const t = i / 5;
|
||
const old = i > 2;
|
||
sequences.push({
|
||
id: `fx-${z}-${x}-${y}-h${i}`,
|
||
isPano: i % 2 === 0,
|
||
capturedAt: now - (old ? 1100 : 30) * DAY_MS,
|
||
parts: [
|
||
[
|
||
[west + margin, at(t, south, north)],
|
||
[east - margin, at(t, south, north)],
|
||
],
|
||
],
|
||
});
|
||
sequences.push({
|
||
id: `fx-${z}-${x}-${y}-v${i}`,
|
||
isPano: i % 2 === 1,
|
||
capturedAt: now - (old ? 30 : 1100) * DAY_MS,
|
||
parts: [
|
||
[
|
||
[at(t, west, east), south + margin],
|
||
[at(t, west, east), north - margin],
|
||
],
|
||
],
|
||
});
|
||
}
|
||
const photoSouth = Math.max(PHOTO_LINE.south, south);
|
||
const photoNorth = Math.min(PHOTO_LINE.north, north);
|
||
if (
|
||
PHOTO_LINE.lon >= west &&
|
||
PHOTO_LINE.lon < east &&
|
||
photoSouth < photoNorth
|
||
)
|
||
sequences.push({
|
||
id: PHOTO_SEQUENCE_ID,
|
||
isPano: false,
|
||
capturedAt: now - 30 * DAY_MS,
|
||
parts: [
|
||
[
|
||
[PHOTO_LINE.lon, photoSouth],
|
||
[PHOTO_LINE.lon, photoNorth],
|
||
],
|
||
],
|
||
});
|
||
return encodeCoverageTile(tile, { sequences });
|
||
}
|
||
|
||
/**
|
||
* Ask the real status route from Node, outside the page's interception, so a
|
||
* server that never registered it fails even in fixture runs. Node sends no
|
||
* Origin, so the same-site gate admits it.
|
||
* @returns {Promise<{configured: boolean}>}
|
||
*/
|
||
export async function assertRealStatusRoute(url, fetchImpl = fetch) {
|
||
const route = new URL('/api/mapillary/status', url).href;
|
||
let response;
|
||
try {
|
||
response = await fetchImpl(route, {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
} catch (error) {
|
||
throw new Error(`${route} is unreachable: ${error?.message || error}`, {
|
||
cause: error,
|
||
});
|
||
}
|
||
const type = response.headers.get('content-type') || '';
|
||
const text = await response.text();
|
||
assert.equal(
|
||
response.status,
|
||
200,
|
||
`${route} must be the server's Mapillary route (HTTP ${response.status}: ${text.slice(0, 120)})`,
|
||
);
|
||
assert.match(type, /application\/json/, `${route} answers JSON, not ${type}`);
|
||
let body;
|
||
try {
|
||
body = JSON.parse(text);
|
||
} catch {
|
||
assert.fail(`${route} answered unparseable JSON: ${text.slice(0, 120)}`);
|
||
}
|
||
assert.equal(
|
||
typeof body?.configured,
|
||
'boolean',
|
||
`${route} reports a boolean \`configured\`: ${text.slice(0, 120)}`,
|
||
);
|
||
return body;
|
||
}
|
||
|
||
/**
|
||
* Answer the page's Mapillary requests from `fixture`, which steps may change
|
||
* mid-run. `configured: undefined` lets the server's real status route answer.
|
||
* @param {{configured?: boolean, tiles: 'ok'|'rejected', tileRequests: number, images: Array<object>, calls: Set<string>, unknown: Array<string>}} fixture
|
||
*/
|
||
export async function serveFixtures(page, fixture) {
|
||
// CDP Fetch on just these URLs: Puppeteer's page-wide interception stalls
|
||
// Cesium's worker scripts, leaving nothing to click.
|
||
const cdp = await page.createCDPSession();
|
||
await cdp.send('Fetch.enable', {
|
||
patterns: [
|
||
{ urlPattern: '*://*.mapillary.com/*' },
|
||
{ urlPattern: '*/api/mapillary/*' },
|
||
{ urlPattern: '*://api.kartaview.org/*' },
|
||
{ urlPattern: '*://cdn.kartaview.org/*' },
|
||
{ urlPattern: '*://*.openstreetcam.org/*' },
|
||
],
|
||
});
|
||
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.pathname === '/api/mapillary/status' &&
|
||
fixture.configured !== undefined
|
||
) {
|
||
answer = json(200, { configured: fixture.configured });
|
||
} else {
|
||
const tile = address.pathname.match(
|
||
/^\/api\/mapillary\/tiles\/coverage\/(\d+)\/(\d+)\/(\d+)$/,
|
||
);
|
||
if (tile) {
|
||
fixture.tileRequests++;
|
||
if (fixture.configured === false)
|
||
answer = json(503, { error: 'no_key', keyRequired: true });
|
||
else if (fixture.tiles === 'rejected')
|
||
answer = json(403, {
|
||
error: 'Mapillary rejected the access token',
|
||
keyRejected: true,
|
||
});
|
||
else {
|
||
const bytes = fixtureTile(
|
||
Number(tile[1]),
|
||
Number(tile[2]),
|
||
Number(tile[3]),
|
||
);
|
||
answer = bytes.length
|
||
? {
|
||
status: 200,
|
||
contentType: 'application/x-protobuf',
|
||
body: Buffer.from(bytes),
|
||
}
|
||
: { status: 204, body: '' };
|
||
}
|
||
}
|
||
}
|
||
const done = answer
|
||
? fulfil(requestId, answer)
|
||
: cdp.send('Fetch.continueRequest', { requestId });
|
||
// A page that navigated or closed meanwhile has no request to answer.
|
||
done.catch(() => {});
|
||
});
|
||
}
|
||
|
||
export function newFixture(overrides = {}) {
|
||
return {
|
||
configured: undefined,
|
||
tiles: 'ok',
|
||
tileRequests: 0,
|
||
images: photoImages(),
|
||
calls: new Set(),
|
||
unknown: [],
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
/** Wait for `fn` in the page; false (not a throw) when it does not come true. */
|
||
async function settle(page, fn, arg, { timeout = 10_000 } = {}) {
|
||
try {
|
||
await page.waitForFunction(fn, { timeout, polling: 100 }, arg);
|
||
return true;
|
||
} catch (error) {
|
||
if (error?.name !== 'TimeoutError') throw error;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function clearFirstRun(page) {
|
||
await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), {
|
||
timeout: 150_000,
|
||
});
|
||
await page.evaluate(() =>
|
||
document.querySelector('.first-run-explore')?.click(),
|
||
);
|
||
await settle(
|
||
page,
|
||
() => !document.querySelector('.first-run-explore'),
|
||
null,
|
||
{
|
||
timeout: 3_000,
|
||
},
|
||
);
|
||
await page.keyboard.press('Escape');
|
||
await page.evaluate(() => {
|
||
for (const el of document.querySelectorAll(
|
||
'#first-run-launcher, [class*=first-run]',
|
||
))
|
||
el.remove();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Click a panel control once the panel holds still and the control is what
|
||
* lies under its centre: the rail animates panel heights, so a point read
|
||
* once can miss by the time the pointer goes down.
|
||
*/
|
||
async function clickPanelControl(page, selector, { timeout = 5_000 } = {}) {
|
||
await waitForStill(page, PANEL_ID);
|
||
const deadline = Date.now() + timeout;
|
||
for (;;) {
|
||
const hit = await page.$eval(selector, (el) => {
|
||
el.scrollIntoView({ block: 'nearest' });
|
||
const r = el.getBoundingClientRect();
|
||
const x = r.left + r.width / 2;
|
||
const y = r.top + r.height / 2;
|
||
const top = document.elementFromPoint(x, y);
|
||
return {
|
||
x,
|
||
y,
|
||
ok: Boolean(top && (top === el || el.contains(top))),
|
||
target: top
|
||
? `${top.tagName.toLowerCase()}${top.id ? `#${top.id}` : ''}`
|
||
: 'nothing',
|
||
};
|
||
});
|
||
if (hit.ok) return page.mouse.click(hit.x, hit.y);
|
||
if (Date.now() > deadline)
|
||
throw new Error(`${selector} is covered by ${hit.target}`);
|
||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||
}
|
||
}
|
||
|
||
/** Load the app and clear the first-run dialog. */
|
||
export async function boot(page, url) {
|
||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||
await clearFirstRun(page);
|
||
}
|
||
|
||
function readPanel(page) {
|
||
return page.evaluate(() => {
|
||
const el = document.getElementById('street-level-panel');
|
||
const rail = document.getElementById('right-context-rail');
|
||
const box = el.getBoundingClientRect();
|
||
return {
|
||
order: rail ? [...rail.children].map((child) => child.id) : [],
|
||
classes: [...el.classList],
|
||
width: Math.round(box.width),
|
||
right: Math.round(box.right),
|
||
status: document.getElementById('sl-status').textContent,
|
||
controlsDisabled: document.getElementById('sl-controls').disabled,
|
||
bodyDisplay: getComputedStyle(document.getElementById('sl-body')).display,
|
||
};
|
||
});
|
||
}
|
||
|
||
/** Wait until Street Level is on, drawn, settled and not key-gated. */
|
||
function waitForCoverage(page, { timeout = 90_000 } = {}) {
|
||
return page.waitForFunction(
|
||
() => {
|
||
const dm = window.__godsEyeView.dataManager;
|
||
const u = dm.layers.get('street-level').module.getUIState();
|
||
return (
|
||
dm.isEnabled('street-level') &&
|
||
u.coverage.count > 0 &&
|
||
!u.coverage.loading &&
|
||
!u.keyRequired
|
||
);
|
||
},
|
||
{ timeout },
|
||
);
|
||
}
|
||
|
||
async function assertKeylessGate(page) {
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||
origin: 'user',
|
||
}),
|
||
);
|
||
await page.waitForFunction(
|
||
() => document.getElementById('sl-status').textContent === 'KEY REQUIRED',
|
||
{ timeout: 30_000 },
|
||
);
|
||
const info = await readPanel(page);
|
||
assert.equal(info.controlsDisabled, true);
|
||
assert.equal(info.status, 'KEY REQUIRED');
|
||
const chip = await page.$eval(
|
||
'#sl-provider-chips [data-chip-id="mapillary"]',
|
||
(node) => ({
|
||
error: node.classList.contains('chip-error'),
|
||
title: node.title,
|
||
}),
|
||
);
|
||
assert.equal(chip.error, true, 'the keyless provider chip reads as an error');
|
||
assert.match(chip.title, /MAPILLARY_CLIENT_TOKEN/);
|
||
}
|
||
|
||
/**
|
||
* Runs in the page. Find a point where a real click picks `prefix` + `id` (any
|
||
* id when null), searched around `points` ([lon, lat]), the aimed billboards,
|
||
* or else a grid mid-screen. Only points where the canvas is topmost count.
|
||
* @returns {{x: number, y: number, id: string}|null} client coordinates
|
||
*/
|
||
export function findPickPoint({
|
||
prefix,
|
||
id,
|
||
points,
|
||
billboardIds,
|
||
billboardPrefix = null,
|
||
}) {
|
||
const viewer = window.__godsEyeView.viewer;
|
||
const scene = viewer.scene;
|
||
const canvas = scene.canvas;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const Cartographic = viewer.camera.positionCartographic.constructor;
|
||
let Cartesian2 = null;
|
||
const windowPoint = (x, y) => (Cartesian2 ? new Cartesian2(x, y) : { x, y });
|
||
const wanted = (picked) => {
|
||
const pid =
|
||
typeof picked?.id === 'string' ? picked.id : (picked?.id?.id ?? null);
|
||
if (typeof pid !== 'string' || !pid.startsWith(prefix)) return null;
|
||
const rest = pid.slice(prefix.length).split('~')[0];
|
||
return id === null || rest === id ? rest : null;
|
||
};
|
||
const tryAt = (x, y) => {
|
||
if (x < 2 || y < 2 || x > rect.width - 2 || y > rect.height - 2)
|
||
return null;
|
||
const top = document.elementFromPoint(rect.left + x, rect.top + y);
|
||
if (top !== canvas) return null;
|
||
const hit = wanted(scene.pick(windowPoint(x, y)));
|
||
return hit ? { x: rect.left + x, y: rect.top + y, id: hit } : null;
|
||
};
|
||
const centres = [];
|
||
const aimed = (billboardId) =>
|
||
typeof billboardId === 'string' &&
|
||
((billboardIds || []).includes(billboardId) ||
|
||
(billboardPrefix !== null && billboardId.startsWith(billboardPrefix)));
|
||
if ((billboardIds && billboardIds.length) || billboardPrefix !== null) {
|
||
for (let i = 0; i < scene.primitives.length; i++) {
|
||
const collection = scene.primitives.get(i);
|
||
if (typeof collection?.get !== 'function' || !collection.length) continue;
|
||
for (let j = 0; j < collection.length; j++) {
|
||
const billboard = collection.get(j);
|
||
if (!aimed(billboard?.id) || !billboard.show) continue;
|
||
const at = billboard.computeScreenSpacePosition?.(scene);
|
||
if (at) {
|
||
Cartesian2 ??= at.constructor;
|
||
centres.push([at.x, at.y]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (const [lon, lat] of points || []) {
|
||
const at = scene.cartesianToCanvasCoordinates(
|
||
scene.globe.ellipsoid.cartographicToCartesian(
|
||
Cartographic.fromDegrees(lon, lat, 0),
|
||
),
|
||
);
|
||
if (at) {
|
||
Cartesian2 ??= at.constructor;
|
||
centres.push([at.x, at.y]);
|
||
}
|
||
}
|
||
for (const [cx, cy] of centres)
|
||
for (let r = 0; r <= 16; r += 2)
|
||
for (const [dx, dy] of [
|
||
[0, 0],
|
||
[r, 0],
|
||
[-r, 0],
|
||
[0, r],
|
||
[0, -r],
|
||
]) {
|
||
const hit = tryAt(cx + dx, cy + dy);
|
||
if (hit) return hit;
|
||
}
|
||
if (points || billboardIds) return null;
|
||
for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18)
|
||
for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6) {
|
||
const hit = tryAt(x, y);
|
||
if (hit) return hit;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function main() {
|
||
const { default: puppeteer } = await import('puppeteer');
|
||
const args = process.argv.slice(2);
|
||
const urlIndex = args.indexOf('--url');
|
||
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
|
||
const fixtures = args.includes('--fixtures');
|
||
const strict = args.includes('--strict');
|
||
if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1';
|
||
const browser = await puppeteer.launch({
|
||
headless: true,
|
||
executablePath:
|
||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||
(await puppeteer.executablePath()),
|
||
defaultViewport: VIEWPORTS[0],
|
||
protocolTimeout: 300_000,
|
||
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
|
||
});
|
||
let count = 0;
|
||
const skips = [];
|
||
/** `fn` gets `skip(reason)`: the step runs what it can but reports a skip. */
|
||
const step = async (label, fn) => {
|
||
let skipped = null;
|
||
const result = await fn({
|
||
skip: (reason) => {
|
||
skipped = reason;
|
||
},
|
||
});
|
||
count++;
|
||
if (skipped) {
|
||
skips.push({ reason: skipped, label });
|
||
console.log(`skip (${skipped}) ${count} ${label}`);
|
||
} else console.log(`ok ${count} ${label}`);
|
||
return result;
|
||
};
|
||
/** A step this mode cannot run at all. */
|
||
const skipStep = (reason, label) => step(label, ({ skip }) => skip(reason));
|
||
const errors = [];
|
||
const monitors = [];
|
||
/** The layer only warns on listener exceptions, so count those as errors. */
|
||
const watch = async (page, name) => {
|
||
monitors.push(
|
||
watchPage(page, {
|
||
name,
|
||
errors,
|
||
isError: (text) => /listener error/i.test(text),
|
||
}),
|
||
);
|
||
await hookRenderErrors(page);
|
||
};
|
||
const renderErrorsSeen = [];
|
||
try {
|
||
await step(
|
||
'the server registers the real Mapillary status route',
|
||
async () => {
|
||
const real = await assertRealStatusRoute(url);
|
||
console.log(` (server status: configured=${real.configured})`);
|
||
if (fixtures)
|
||
assert.equal(
|
||
real.configured,
|
||
true,
|
||
'fixture runs need a server started with MAPILLARY_CLIENT_TOKEN (CI uses a dummy, non-secret one): the browser bundle carries the token, and without it no photo can open',
|
||
);
|
||
},
|
||
);
|
||
const page = await browser.newPage();
|
||
const fixture = newFixture();
|
||
await watch(page, 'main');
|
||
if (fixtures) await serveFixtures(page, fixture);
|
||
await boot(page, url);
|
||
const panel = () => readPanel(page);
|
||
const status = await page.evaluate(() =>
|
||
fetch('/api/mapillary/status').then((res) => res.json()),
|
||
);
|
||
for (const viewport of VIEWPORTS) {
|
||
await page.setViewport(viewport);
|
||
await step(
|
||
`panel is a collapsed right-rail strip at ${viewport.width}×${viewport.height}`,
|
||
async () => {
|
||
await settle(
|
||
page,
|
||
(width) => {
|
||
const box = document
|
||
.getElementById('street-level-panel')
|
||
.getBoundingClientRect();
|
||
return box.width <= 200 && box.right <= width;
|
||
},
|
||
viewport.width,
|
||
);
|
||
const info = await panel();
|
||
assert.deepEqual(info.order, RAIL_ORDER);
|
||
assert.ok(isCollapsed(info.classes));
|
||
assert.equal(info.bodyDisplay, 'none');
|
||
assert.ok(info.width <= 200 && info.right <= viewport.width);
|
||
},
|
||
);
|
||
}
|
||
await page.setViewport(VIEWPORTS[0]);
|
||
const expandStrip = async (target = page) => {
|
||
await clickPanelControl(
|
||
target,
|
||
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
|
||
);
|
||
assert.ok(
|
||
await settle(
|
||
target,
|
||
() =>
|
||
!document
|
||
.getElementById('street-level-panel')
|
||
.classList.contains('collapsed') &&
|
||
getComputedStyle(document.getElementById('sl-body')).display ===
|
||
'flex',
|
||
),
|
||
'Street Level panel did not expand before the timeout',
|
||
);
|
||
};
|
||
await expandStrip();
|
||
await step(
|
||
'expanding the strip shows the body, one chip per provider and the legend',
|
||
async () => {
|
||
const info = await panel();
|
||
assert.ok(!isCollapsed(info.classes));
|
||
assert.equal(info.bodyDisplay, 'flex');
|
||
const chips = await page.evaluate(() =>
|
||
[
|
||
...document.querySelectorAll(
|
||
'#sl-provider-chips .data-toggle-chip',
|
||
),
|
||
].map((chip) => chip.dataset.chipId),
|
||
);
|
||
// Every registered provider usable on this map, in order (Street View
|
||
// only on Google 3D).
|
||
const listed = await page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.getUIState()
|
||
.providers.map((provider) => provider.id),
|
||
);
|
||
assert.deepEqual(chips, listed);
|
||
assert.deepEqual(
|
||
EXPECTED_PROVIDERS.filter((id) => listed.includes(id)),
|
||
listed,
|
||
);
|
||
assert.ok(listed.includes('mapillary'));
|
||
// One swatch per switched-on source plus "Selected" (Street View
|
||
// starts off); each chip wears its colour.
|
||
const legend = await page.evaluate(() => ({
|
||
swatches: document.querySelectorAll('#sl-legend li').length,
|
||
on: window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.getUIState()
|
||
.providers.filter((provider) => provider.on).length,
|
||
}));
|
||
assert.equal(legend.swatches, legend.on + 1);
|
||
assert.equal(
|
||
await page.$eval('[data-chip-id="mapillary"]', (chip) =>
|
||
chip.style.getPropertyValue('--chip-color'),
|
||
),
|
||
'#05cb63',
|
||
);
|
||
},
|
||
);
|
||
if (!status.configured) {
|
||
await step(
|
||
'keyless install gates the controls and reports KEY REQUIRED',
|
||
() => assertKeylessGate(page),
|
||
);
|
||
await skipStep(
|
||
'no Mapillary key',
|
||
'the keyed steps (coverage, filters, photos, panel window)',
|
||
);
|
||
console.log(
|
||
'keyless run complete (no MAPILLARY_CLIENT_TOKEN on the server)',
|
||
);
|
||
} else {
|
||
await keyedSteps();
|
||
}
|
||
await step('no page errors and no Cesium render-loop errors', async () => {
|
||
renderErrorsSeen.push(
|
||
...(await readRenderErrors(page)).map(
|
||
(text) => `[main] scene.renderError: ${text}`,
|
||
),
|
||
);
|
||
assert.deepEqual([...errors, ...renderErrorsSeen], []);
|
||
});
|
||
if (strict)
|
||
await step('strict: no skips beyond the documented ones', () => {
|
||
assert.deepEqual(
|
||
strictViolations(skips),
|
||
[],
|
||
`--strict accepts only skips for: ${STRICT_ALLOWED_SKIPS.join(', ')}`,
|
||
);
|
||
});
|
||
console.log(
|
||
`${count} steps: ${count - skips.length} ok, ${skips.length} skipped`,
|
||
);
|
||
|
||
/**
|
||
* 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() {
|
||
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 hides the filters',
|
||
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: false, since: false });
|
||
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</a> 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: a click on a line opens the nearest photo, cones follow its drive, and › steps along it',
|
||
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();
|
||
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');
|
||
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-kv-photo');
|
||
return img && !img.hidden && img.naturalWidth > 0;
|
||
}),
|
||
'the photo is on screen',
|
||
);
|
||
if (fixtures) {
|
||
assert.ok(
|
||
photos.some((photo) => photo.id === street.imageId),
|
||
`${street.imageId} is a fixture photo`,
|
||
);
|
||
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)}`,
|
||
);
|
||
}
|
||
const before = street.imageId;
|
||
await clickPanelControl(page, '.sl-kv-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)
|
||
assert.equal(
|
||
(await ui()).street.imageId,
|
||
String(Number(before) + 1),
|
||
'the next photo along the drive',
|
||
);
|
||
},
|
||
);
|
||
if (fixtures)
|
||
await step(
|
||
'KartaView: a 360° photo opens on the panorama without FIT / FILL, and dragging turns it',
|
||
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-kv-pano', (canvas) => canvas.hidden),
|
||
false,
|
||
);
|
||
const box = await page.$eval('.sl-kv-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',
|
||
);
|
||
},
|
||
);
|
||
else
|
||
await skipStep(
|
||
'fixtures only',
|
||
'KartaView: a 360° photo opens on the panorama without FIT / FILL, and dragging turns it',
|
||
);
|
||
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);
|
||
}
|
||
|
||
async function keyedSteps() {
|
||
// A late startup flight only shows itself by moving the camera, so park
|
||
// and check it stays put rather than reading a state.
|
||
const park = async () => {
|
||
for (let attempt = 0; attempt < 6; attempt++) {
|
||
await page.evaluate((at) => {
|
||
const v = window.__godsEyeView.viewer;
|
||
v.camera.cancelFlight?.();
|
||
const C = v.camera.positionCartographic.constructor;
|
||
v.camera.setView({
|
||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||
C.fromDegrees(at.lon, at.lat, at.height),
|
||
),
|
||
orientation: { heading: 0, pitch: at.pitch, roll: 0 },
|
||
});
|
||
}, PARK);
|
||
await sleep(1500);
|
||
const stable = await page.evaluate((at) => {
|
||
const c = window.__godsEyeView.viewer.camera.positionCartographic;
|
||
return (
|
||
Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 &&
|
||
Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001
|
||
);
|
||
}, PARK);
|
||
if (stable) return;
|
||
}
|
||
};
|
||
await park();
|
||
await step(
|
||
'enabling draws coverage and registers the on-globe credit',
|
||
async () => {
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||
origin: 'user',
|
||
}),
|
||
);
|
||
await waitForCoverage(page);
|
||
const info = await panel();
|
||
assert.equal(info.controlsDisabled, false);
|
||
// Cesium paints on-screen credits a frame or two after they register.
|
||
await page.waitForFunction(
|
||
() =>
|
||
document.body.innerHTML.includes('Mapillary</a> contributors'),
|
||
{ timeout: 15_000 },
|
||
);
|
||
},
|
||
);
|
||
const ui = () =>
|
||
page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.getUIState(),
|
||
);
|
||
/** `test(u, arg)` runs in the page, so it must be self-contained. */
|
||
const uiUntil = (test, arg = null, options) =>
|
||
settle(
|
||
page,
|
||
`(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`,
|
||
undefined,
|
||
options,
|
||
);
|
||
const chip = '#sl-provider-chips [data-chip-id="mapillary"]';
|
||
const stacks = {
|
||
active: () =>
|
||
page.evaluate(() =>
|
||
window.__godsEyeView.mapStackController.getActiveId(),
|
||
),
|
||
photoreal: () =>
|
||
page.evaluate(() =>
|
||
window.__godsEyeView.mapStackController.isStackAvailable(
|
||
'photoreal',
|
||
),
|
||
),
|
||
set: async (id) => {
|
||
await page.evaluate(
|
||
(stackId) =>
|
||
window.__godsEyeView.mapStackController.setStack(stackId),
|
||
id,
|
||
);
|
||
await settle(
|
||
page,
|
||
(stackId) =>
|
||
window.__godsEyeView.mapStackController.getActiveId() === stackId,
|
||
id,
|
||
);
|
||
},
|
||
};
|
||
await step(
|
||
'the header ON/OFF pill switches the layer off and on',
|
||
async () => {
|
||
assert.equal(
|
||
await page.$eval('#sl-status', (node) =>
|
||
[
|
||
node.tagName,
|
||
node.textContent,
|
||
node.getAttribute('aria-pressed'),
|
||
].join(':'),
|
||
),
|
||
'BUTTON:ON:true',
|
||
);
|
||
await clickPanelControl(page, '#sl-status');
|
||
await page.waitForFunction(
|
||
() => !window.__godsEyeView.dataManager.isEnabled('street-level'),
|
||
{ timeout: 15_000 },
|
||
);
|
||
await page.waitForFunction(
|
||
() => document.getElementById('sl-status').textContent === 'OFF',
|
||
{ timeout: 5_000 },
|
||
);
|
||
assert.equal((await ui()).coverage.count, 0);
|
||
await clickPanelControl(page, '#sl-status');
|
||
await waitForCoverage(page);
|
||
assert.equal(
|
||
await page.$eval('#sl-status', (node) =>
|
||
node.getAttribute('aria-pressed'),
|
||
),
|
||
'true',
|
||
);
|
||
},
|
||
);
|
||
await step(
|
||
'the only lit provider chip switches the whole layer off, credit and all',
|
||
async () => {
|
||
await clickPanelControl(page, chip);
|
||
await page.waitForFunction(
|
||
() =>
|
||
!window.__godsEyeView.dataManager.isEnabled('street-level') &&
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.getUIState().coverage.count === 0,
|
||
{ timeout: 15_000 },
|
||
);
|
||
await page.waitForFunction(
|
||
() =>
|
||
!document.body.innerHTML.includes('Mapillary</a> contributors'),
|
||
{ timeout: 15_000 },
|
||
);
|
||
assert.equal(
|
||
await page.$eval(chip, (node) => node.getAttribute('aria-pressed')),
|
||
'false',
|
||
);
|
||
// The provider stays switched on, so the layer comes back with it.
|
||
assert.equal((await ui()).providers[0].on, true);
|
||
},
|
||
);
|
||
await step(
|
||
'lighting the chip turns the layer back on with coverage',
|
||
async () => {
|
||
await clickPanelControl(page, chip);
|
||
await waitForCoverage(page);
|
||
assert.equal(
|
||
await page.$eval(chip, (node) => node.getAttribute('aria-pressed')),
|
||
'true',
|
||
);
|
||
},
|
||
);
|
||
await step(
|
||
'the 360° filter keeps at most the unfiltered sequence count',
|
||
async () => {
|
||
const before = (await ui()).coverage.count;
|
||
await clickPanelControl(page, '[data-sl-pano="pano"]');
|
||
await uiUntil((u) => u.filter.pano === 'pano' && !u.coverage.loading);
|
||
const after = (await ui()).coverage.count;
|
||
assert.ok(after <= before, `${after} ≤ ${before}`);
|
||
// The fixtures hold flat sequences too, so the filter must drop some.
|
||
if (fixtures) assert.ok(after < before, `${after} < ${before}`);
|
||
assert.equal((await ui()).filter.pano, 'pano');
|
||
// The click is a user params request, so the share link records it
|
||
// under Street Level's share token, whatever the ledger assigned.
|
||
await page.waitForFunction(
|
||
(option) =>
|
||
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
|
||
.split('_')
|
||
.includes(option),
|
||
{ timeout: 10_000 },
|
||
`${STREET_LEVEL_TOKEN}.p.p`,
|
||
);
|
||
await clickPanelControl(page, '[data-sl-pano="all"]');
|
||
await uiUntil(
|
||
(u, n) => u.filter.pano === 'all' && u.coverage.count === n,
|
||
before,
|
||
);
|
||
assert.equal((await ui()).coverage.count, before);
|
||
},
|
||
);
|
||
await step(
|
||
'the SINCE slider narrows coverage and names its cut-off date',
|
||
async () => {
|
||
const before = (await ui()).coverage.count;
|
||
const setStop = (index) =>
|
||
page.$eval(
|
||
'#sl-since',
|
||
(input, value) => {
|
||
input.value = String(value);
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
},
|
||
index,
|
||
);
|
||
await setStop(5);
|
||
await uiUntil((u) => u.filter.sinceDays === 365);
|
||
const narrowed = await ui();
|
||
assert.equal(narrowed.filter.sinceDays, 365);
|
||
if (fixtures)
|
||
assert.ok(
|
||
narrowed.coverage.count < before,
|
||
`fixtures hold older sequences: ${narrowed.coverage.count} < ${before}`,
|
||
);
|
||
assert.ok(
|
||
narrowed.coverage.count <= before,
|
||
`${narrowed.coverage.count} ≤ ${before}`,
|
||
);
|
||
await settle(page, () =>
|
||
/^LAST YEAR/.test(
|
||
document.getElementById('sl-since-label').textContent,
|
||
),
|
||
);
|
||
assert.match(
|
||
await page.$eval('#sl-since-label', (node) => node.textContent),
|
||
/^LAST YEAR · SINCE \d{4}-\d{2}-\d{2}$/,
|
||
);
|
||
await setStop(0);
|
||
await uiUntil((u) => u.filter.sinceDays === 0);
|
||
await settle(
|
||
page,
|
||
() =>
|
||
document.getElementById('sl-since-label').textContent ===
|
||
'ANY DATE',
|
||
);
|
||
assert.equal((await ui()).filter.sinceDays, 0);
|
||
assert.equal(
|
||
await page.$eval('#sl-since-label', (node) => node.textContent),
|
||
'ANY DATE',
|
||
);
|
||
},
|
||
);
|
||
await step(
|
||
'the MapillaryJS viewer loads ahead of the first photo',
|
||
async () => {
|
||
// Catches a broken dynamic import before the first click would.
|
||
await page.waitForSelector(
|
||
'#sl-viewer.mapillary-viewer .mapillary-dom',
|
||
{ timeout: 60_000 },
|
||
);
|
||
},
|
||
);
|
||
const statusText = (text) =>
|
||
page.waitForFunction(
|
||
(expected) =>
|
||
document.getElementById('sl-status').textContent === expected,
|
||
{ timeout: 30_000 },
|
||
text,
|
||
);
|
||
const panTo = (lon, lat) =>
|
||
page.evaluate(
|
||
(at) => {
|
||
const v = window.__godsEyeView.viewer;
|
||
const C = v.camera.positionCartographic.constructor;
|
||
v.camera.setView({
|
||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||
C.fromDegrees(at.lon, at.lat, 900),
|
||
),
|
||
orientation: { heading: 0, pitch: -1.3, roll: 0 },
|
||
});
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.refreshCoverage();
|
||
},
|
||
{ lon, lat },
|
||
);
|
||
/** Wait until no tile request has arrived for `quietMs`. */
|
||
const tileRequestsQuiet = async (quietMs, timeout = 30_000) => {
|
||
const start = Date.now();
|
||
let last = fixture.tileRequests;
|
||
let since = Date.now();
|
||
while (Date.now() - start < timeout) {
|
||
await sleep(100);
|
||
if (fixture.tileRequests !== last) {
|
||
last = fixture.tileRequests;
|
||
since = Date.now();
|
||
} else if (Date.now() - since >= quietMs) return last;
|
||
}
|
||
return last;
|
||
};
|
||
const QUIET_MS = 2 * COVERAGE_MOVE_DEBOUNCE_MS + 200;
|
||
if (fixtures)
|
||
await step(
|
||
'a key Mapillary rejects reads KEY REJECTED, stops asking (a pan that asks while accepted asks nothing while rejected), and clears when switched off',
|
||
async () => {
|
||
// Positive control: with the key accepted, the same pan-and-wait
|
||
// the rejected half uses does see new tile requests.
|
||
await tileRequestsQuiet(QUIET_MS);
|
||
const idle = fixture.tileRequests;
|
||
await panTo(-121.46, 38.6);
|
||
const asked = await tileRequestsQuiet(QUIET_MS);
|
||
assert.ok(
|
||
asked > idle,
|
||
`a pan while accepted requests tiles (${idle} → ${asked})`,
|
||
);
|
||
fixture.tiles = 'rejected';
|
||
await clickPanelControl(page, '#sl-status');
|
||
await statusText('OFF');
|
||
await clickPanelControl(page, '#sl-status');
|
||
await statusText('KEY REJECTED');
|
||
const shown = await page.evaluate(() => ({
|
||
error: document.getElementById('sl-error').textContent,
|
||
errorHidden: document.getElementById('sl-error').hidden,
|
||
controls: document.getElementById('sl-controls').disabled,
|
||
chip: document
|
||
.querySelector('#sl-provider-chips [data-chip-id="mapillary"]')
|
||
.classList.contains('chip-error'),
|
||
}));
|
||
assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/);
|
||
assert.equal(shown.errorHidden, false);
|
||
assert.equal(shown.controls, true);
|
||
assert.equal(shown.chip, true);
|
||
// Panning asks for nothing more: the verdict holds for every tile.
|
||
const rejectedAt = await tileRequestsQuiet(QUIET_MS);
|
||
await panTo(-121.42, 38.63);
|
||
const after = await tileRequestsQuiet(QUIET_MS);
|
||
assert.equal(after, rejectedAt, 'no tile requests while rejected');
|
||
// Off clears the message; with a good key it comes back as ON.
|
||
await clickPanelControl(page, '#sl-status');
|
||
await statusText('OFF');
|
||
assert.equal(
|
||
await page.$eval(
|
||
'#sl-error',
|
||
(node) => node.hidden || !node.textContent,
|
||
),
|
||
true,
|
||
'no stale error while the layer is off',
|
||
);
|
||
fixture.tiles = 'ok';
|
||
await clickPanelControl(page, '#sl-status');
|
||
await waitForCoverage(page);
|
||
await statusText('ON');
|
||
},
|
||
);
|
||
else
|
||
await skipStep(
|
||
'fixtures only',
|
||
'a key Mapillary rejects reads KEY REJECTED and stops asking',
|
||
);
|
||
|
||
const fixtureIds = new Map(
|
||
fixture.images.map((image, index) => [image.id, { ...image, index }]),
|
||
);
|
||
const waitForImage = (id = null) =>
|
||
page.waitForFunction(
|
||
(want) => {
|
||
const s = window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.getUIState().street;
|
||
return (
|
||
(s.imageId && !s.loading && (!want || s.imageId === want)) ||
|
||
s.error
|
||
);
|
||
},
|
||
{ timeout: 90_000 },
|
||
id,
|
||
);
|
||
const openById = async (id) => {
|
||
await page.evaluate((imageId) => {
|
||
void window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.openImage('mapillary', imageId);
|
||
}, id);
|
||
await waitForImage(id);
|
||
const street = (await ui()).street;
|
||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||
return street;
|
||
};
|
||
|
||
await park();
|
||
await waitForCoverage(page);
|
||
await step(
|
||
'a click on a coverage line selects its sequence and draws its cones',
|
||
async () => {
|
||
const original = await stacks.active();
|
||
// Draped lines on flat imagery: the line is where it is drawn.
|
||
await stacks.set('esri-imagery');
|
||
await uiUntil((u) => u.surface === 'draped');
|
||
await waitForCoverage(page);
|
||
const target = fixtures ? PHOTO_SEQUENCE_ID : null;
|
||
const along = Array.from({ length: 9 }, (_, i) => [
|
||
PHOTO_LINE.lon,
|
||
PHOTO_LINE.south +
|
||
((i + 2) / 13) * (PHOTO_LINE.north - PHOTO_LINE.south),
|
||
]);
|
||
// Ground primitives are built over a few frames: poll for a pick.
|
||
const handle = await page.waitForFunction(
|
||
findPickPoint,
|
||
{ timeout: 30_000, polling: 250 },
|
||
{
|
||
prefix: 'mly:seq:',
|
||
id: target,
|
||
points: fixtures ? along : null,
|
||
billboardIds: null,
|
||
},
|
||
);
|
||
const at = await handle.jsonValue();
|
||
await page.mouse.click(at.x, at.y);
|
||
const selected = await uiUntil(
|
||
(u, id) =>
|
||
u.sequence.selectedId === id &&
|
||
!u.sequence.loading &&
|
||
u.sequence.images > 0,
|
||
at.id,
|
||
{ timeout: 30_000 },
|
||
);
|
||
const sequence = (await ui()).sequence;
|
||
assert.ok(
|
||
selected,
|
||
`the click at ${Math.round(at.x)},${Math.round(at.y)} on ${at.id} selected it: ${JSON.stringify(sequence)}`,
|
||
);
|
||
if (fixtures)
|
||
assert.equal(sequence.images, fixture.images.length, 'every photo');
|
||
// The cones are on the globe: one of them picks as an image.
|
||
const cone = await page.waitForFunction(
|
||
findPickPoint,
|
||
{ timeout: 15_000, polling: 250 },
|
||
{
|
||
prefix: 'mly:img:',
|
||
id: null,
|
||
points: fixtures
|
||
? fixture.images.map((image) => [image.lon, image.lat])
|
||
: null,
|
||
billboardIds: null,
|
||
// Live: the cones' positions are only known from the globe.
|
||
billboardPrefix: fixtures ? null : 'mly:img:',
|
||
},
|
||
);
|
||
assert.ok(await cone.jsonValue(), 'an image cone is pickable');
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.clearSequence(),
|
||
);
|
||
await uiUntil((u) => u.sequence.selectedId === null);
|
||
await stacks.set(original);
|
||
},
|
||
);
|
||
|
||
let firstImageId = null;
|
||
await park();
|
||
await waitForCoverage(page);
|
||
await step(
|
||
'opening the nearest image shows the viewer with a caption',
|
||
async () => {
|
||
// Fire and forget: the open can outlive one CDP call, so poll instead.
|
||
await page.evaluate(() => {
|
||
void window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.openNearest();
|
||
});
|
||
await waitForImage();
|
||
const street = (await ui()).street;
|
||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||
// Some images carry no creator name; the date/bearing side always fills.
|
||
const filled = await settle(
|
||
page,
|
||
() =>
|
||
document.getElementById('sl-image-when').textContent.trim()
|
||
.length > 0,
|
||
null,
|
||
{ timeout: 30_000 },
|
||
);
|
||
if (!filled) {
|
||
const dump = await page.evaluate(() => {
|
||
const u = window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.getUIState();
|
||
return {
|
||
street: u.street,
|
||
captionNodes:
|
||
document.querySelectorAll('#sl-image-when').length,
|
||
captionNow:
|
||
document.getElementById('sl-image-when').textContent,
|
||
wrapHidden: document.getElementById('sl-viewer-wrap').hidden,
|
||
collapsed: document
|
||
.getElementById('street-level-panel')
|
||
.classList.contains('collapsed'),
|
||
};
|
||
});
|
||
throw new Error(`caption never filled: ${JSON.stringify(dump)}`);
|
||
}
|
||
const view = await page.evaluate(() => ({
|
||
hidden: document.getElementById('sl-viewer-wrap').hidden,
|
||
when: document.getElementById('sl-image-when').textContent.trim(),
|
||
link: document.getElementById('sl-image-link').textContent.trim(),
|
||
href: document.getElementById('sl-image-link').href,
|
||
width: Math.round(
|
||
document.getElementById('sl-viewer').getBoundingClientRect()
|
||
.width,
|
||
),
|
||
}));
|
||
assert.equal(view.hidden, false);
|
||
assert.ok(view.width > 200);
|
||
assert.ok(view.when.length > 0, 'caption shows the capture date');
|
||
assert.equal(street.providerId, 'mapillary');
|
||
firstImageId = street.imageId;
|
||
assert.equal(view.link, 'MAPILLARY ↗');
|
||
if (fixtures) {
|
||
const known = fixtureIds.get(street.imageId);
|
||
assert.ok(known, `a fixture photo opened (${street.imageId})`);
|
||
assert.equal(street.isPano, known.isPano, 'pano flag from the ent');
|
||
assert.equal(street.sequenceId, PHOTO_SEQUENCE_ID);
|
||
}
|
||
const fit = await page.evaluate(() => {
|
||
const inner = document.querySelector('.street-level-panel-inner');
|
||
const box = inner.getBoundingClientRect();
|
||
const wrap = document
|
||
.getElementById('sl-viewer-wrap')
|
||
.getBoundingClientRect();
|
||
return {
|
||
scrollTop: inner.scrollTop,
|
||
top: wrap.top - box.top,
|
||
overflowBottom: wrap.bottom - box.bottom,
|
||
};
|
||
});
|
||
assert.equal(fit.scrollTop, 0, 'panel not scrolled');
|
||
assert.ok(
|
||
fit.top >= 0 && fit.top < 80,
|
||
`viewer right under the header (${fit.top}px)`,
|
||
);
|
||
assert.ok(
|
||
fit.overflowBottom <= 1,
|
||
`whole viewer visible (${fit.overflowBottom}px cut)`,
|
||
);
|
||
assert.match(view.href, /mapillary\.com\/app\/\?pKey=/);
|
||
// The open selects the photo's sequence: its cones are drawn.
|
||
assert.ok(
|
||
await uiUntil(
|
||
(u) => u.sequence.selectedId === u.street.sequenceId,
|
||
null,
|
||
{ timeout: 30_000 },
|
||
),
|
||
'the open photo’s sequence is selected',
|
||
);
|
||
},
|
||
);
|
||
if (fixtures)
|
||
await step(
|
||
'a click on a neighbouring cone steps the viewer to that photo (360° and flat)',
|
||
async () => {
|
||
// Let the framing flight land, so the cone does not move under the click.
|
||
await settle(
|
||
page,
|
||
() => !window.__godsEyeView.viewer.camera._currentFlight,
|
||
null,
|
||
{ timeout: 10_000 },
|
||
);
|
||
await uiUntil((u) => u.sequence.images > 0 && !u.sequence.loading);
|
||
const current = fixtureIds.get(firstImageId);
|
||
const neighbour = [1, -1, 2, -2, 4, -4]
|
||
.map((offset) => fixture.images[current.index + offset])
|
||
.find((image) => image && image.isPano !== current.isPano);
|
||
assert.ok(neighbour, 'a neighbour of the other camera type');
|
||
const handle = await page.waitForFunction(
|
||
findPickPoint,
|
||
{ timeout: 15_000, polling: 250 },
|
||
{
|
||
prefix: 'mly:img:',
|
||
id: neighbour.id,
|
||
points: null,
|
||
billboardIds: [`mly:img:${neighbour.id}`],
|
||
},
|
||
);
|
||
const at = await handle.jsonValue();
|
||
await page.mouse.click(at.x, at.y);
|
||
await waitForImage(neighbour.id);
|
||
const street = (await ui()).street;
|
||
assert.equal(street.error, null);
|
||
assert.equal(street.imageId, neighbour.id, 'stepped to the cone');
|
||
assert.equal(street.isPano, neighbour.isPano);
|
||
assert.notEqual(street.isPano, current.isPano, 'both kinds shown');
|
||
await openById(firstImageId);
|
||
},
|
||
);
|
||
else
|
||
await skipStep(
|
||
'fixtures only',
|
||
'a click on a neighbouring cone steps the viewer to that photo',
|
||
);
|
||
await step(
|
||
'FOLLOW is offered only on the Google 3D map',
|
||
async ({ skip }) => {
|
||
const follow = () =>
|
||
page.$eval('#sl-follow-btn', (node) => ({
|
||
disabled: node.disabled,
|
||
pressed: node.getAttribute('aria-pressed'),
|
||
title: node.title,
|
||
}));
|
||
const followIs = (disabled, pressed = null) =>
|
||
settle(
|
||
page,
|
||
([d, p]) => {
|
||
const node = document.getElementById('sl-follow-btn');
|
||
return (
|
||
node.disabled === d &&
|
||
(p === null || node.getAttribute('aria-pressed') === p)
|
||
);
|
||
},
|
||
[disabled, pressed],
|
||
);
|
||
const original = await stacks.active();
|
||
await stacks.set('esri-imagery');
|
||
await followIs(true);
|
||
let state = await follow();
|
||
assert.equal(state.disabled, true, 'disabled on Esri');
|
||
assert.match(state.title, /needs the Google 3D map/);
|
||
if (await stacks.photoreal()) {
|
||
await stacks.set('photoreal');
|
||
await followIs(false);
|
||
state = await follow();
|
||
assert.equal(state.disabled, false, 'enabled on Google 3D');
|
||
await clickPanelControl(page, '#sl-follow-btn');
|
||
await followIs(false, 'true');
|
||
assert.equal((await follow()).pressed, 'true');
|
||
await stacks.set('esri-imagery');
|
||
await followIs(true, 'false');
|
||
state = await follow();
|
||
assert.equal(
|
||
state.pressed,
|
||
'false',
|
||
'leaving Google 3D stops following',
|
||
);
|
||
assert.equal(state.disabled, true);
|
||
} else {
|
||
console.log(
|
||
' (Google 3D unavailable here: only the disabled path ran)',
|
||
);
|
||
skip('no Google 3D');
|
||
}
|
||
await stacks.set(original);
|
||
},
|
||
);
|
||
await park();
|
||
await step(
|
||
'closing a photo stops the framing flight toward it',
|
||
async () => {
|
||
// Opening frames the photo with a 1.6 s camera flight; closing right
|
||
// away must cancel it, leaving the camera where it was.
|
||
const result = await page.evaluate(async (id) => {
|
||
const v = window.__godsEyeView.viewer;
|
||
const module =
|
||
window.__godsEyeView.dataManager.layers.get(
|
||
'street-level',
|
||
).module;
|
||
const opened = await module.openImage('mapillary', id);
|
||
const flying = Boolean(v.camera._currentFlight);
|
||
const from = v.camera.positionCartographic.clone();
|
||
module.closeViewer();
|
||
const stillFlying = Boolean(v.camera._currentFlight);
|
||
const frames = (n) =>
|
||
new Promise((resolve) => {
|
||
const tick = () =>
|
||
--n <= 0 ? resolve() : requestAnimationFrame(tick);
|
||
requestAnimationFrame(tick);
|
||
});
|
||
await frames(20);
|
||
const to = v.camera.positionCartographic;
|
||
return {
|
||
opened,
|
||
flying,
|
||
stillFlying,
|
||
moved: Math.hypot(
|
||
(to.longitude - from.longitude) * 6_371_000,
|
||
(to.latitude - from.latitude) * 6_371_000,
|
||
to.height - from.height,
|
||
),
|
||
height: to.height,
|
||
};
|
||
}, firstImageId);
|
||
assert.equal(result.opened, true, 'the photo opened');
|
||
assert.equal(result.flying, true, 'opening started a framing flight');
|
||
assert.equal(result.stillFlying, false, 'closing cancelled it');
|
||
assert.ok(
|
||
result.moved < 0.5,
|
||
`the camera stopped where it was (moved ${result.moved.toFixed(2)} m)`,
|
||
);
|
||
assert.ok(
|
||
result.height > PARK.height / 2,
|
||
`the camera never reached the photo (${Math.round(result.height)} m up)`,
|
||
);
|
||
assert.equal((await ui()).street.open, false);
|
||
},
|
||
);
|
||
await step(
|
||
'coverage sits on the bare earth on Google 3D and drapes elsewhere',
|
||
async ({ skip }) => {
|
||
const surface = () =>
|
||
page.evaluate(() => {
|
||
const u = window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.getUIState();
|
||
return { surface: u.surface, count: u.coverage.count };
|
||
});
|
||
const original = await stacks.active();
|
||
await stacks.set('esri-imagery');
|
||
await page.waitForFunction(
|
||
() =>
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.getUIState().surface === 'draped',
|
||
{ timeout: 10_000 },
|
||
);
|
||
const coverageLoaded = () =>
|
||
waitForCoverage(page, { timeout: 60_000 });
|
||
await coverageLoaded();
|
||
assert.equal((await surface()).surface, 'draped', 'draped on Esri');
|
||
if (await stacks.photoreal()) {
|
||
await stacks.set('photoreal');
|
||
await page.waitForFunction(
|
||
() =>
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.getUIState().surface === 'terrain',
|
||
{ timeout: 15_000 },
|
||
);
|
||
await coverageLoaded();
|
||
assert.equal((await surface()).surface, 'terrain');
|
||
} else {
|
||
console.log(
|
||
' (Google 3D unavailable here: only the draped path ran)',
|
||
);
|
||
skip('no Google 3D');
|
||
}
|
||
await stacks.set(original);
|
||
},
|
||
);
|
||
const expanded = () =>
|
||
page.evaluate(() =>
|
||
document
|
||
.getElementById('sl-viewer-wrap')
|
||
.classList.contains('sl-viewer-wrap-expanded'),
|
||
);
|
||
await step(
|
||
'EXPAND opens a modal dialog (the rest of <body> inert) and Esc returns focus to the button',
|
||
async () => {
|
||
await openById(firstImageId);
|
||
const inertBefore = await page.evaluate(
|
||
() =>
|
||
[...document.body.children].filter((node) => node.inert).length,
|
||
);
|
||
await clickPanelControl(page, '#sl-viewer-expand');
|
||
await settle(page, () =>
|
||
document
|
||
.getElementById('sl-viewer-wrap')
|
||
.classList.contains('sl-viewer-wrap-expanded'),
|
||
);
|
||
const dialog = await page.evaluate(() => {
|
||
const wrap = document.getElementById('sl-viewer-wrap');
|
||
const others = [...document.body.children].filter(
|
||
(node) => node !== wrap,
|
||
);
|
||
return {
|
||
role: wrap.getAttribute('role'),
|
||
modal: wrap.getAttribute('aria-modal'),
|
||
inside: wrap.contains(document.activeElement),
|
||
width: Math.round(wrap.getBoundingClientRect().width),
|
||
onBody: wrap.parentElement === document.body,
|
||
notInert: others
|
||
.filter((node) => !node.inert)
|
||
.map((node) => node.id || node.tagName.toLowerCase()),
|
||
wrapInert: wrap.inert,
|
||
};
|
||
});
|
||
assert.equal(dialog.role, 'dialog');
|
||
assert.equal(dialog.modal, 'true');
|
||
assert.equal(dialog.inside, true);
|
||
assert.ok(dialog.width > 900);
|
||
assert.equal(dialog.onBody, true);
|
||
assert.deepEqual(
|
||
dialog.notInert,
|
||
[],
|
||
'every other body child is inert',
|
||
);
|
||
assert.equal(dialog.wrapInert, false);
|
||
await page.keyboard.press('Escape');
|
||
await settle(
|
||
page,
|
||
() =>
|
||
!document
|
||
.getElementById('sl-viewer-wrap')
|
||
.classList.contains('sl-viewer-wrap-expanded'),
|
||
);
|
||
const after = await page.evaluate(() => ({
|
||
expanded: document
|
||
.getElementById('sl-viewer-wrap')
|
||
.classList.contains('sl-viewer-wrap-expanded'),
|
||
focus: document.activeElement?.id,
|
||
inert: [...document.body.children].filter((node) => node.inert)
|
||
.length,
|
||
}));
|
||
assert.equal(
|
||
`${after.expanded}:${after.focus}`,
|
||
'false:sl-viewer-expand',
|
||
);
|
||
assert.equal(
|
||
after.inert,
|
||
inertBefore,
|
||
'the application is live again',
|
||
);
|
||
},
|
||
);
|
||
await step(
|
||
'× closes the image and deselects it on the globe',
|
||
async () => {
|
||
await clickPanelControl(page, '#sl-viewer-close');
|
||
await uiUntil(
|
||
(u) => !u.street.open && u.sequence.selectedId === null,
|
||
);
|
||
const after = await ui();
|
||
assert.equal(after.street.open, false);
|
||
assert.equal(after.sequence.selectedId, null);
|
||
},
|
||
);
|
||
const floating = () =>
|
||
page.$eval('#street-level-panel', (node) =>
|
||
node.classList.contains('panel-floating'),
|
||
);
|
||
const center = (selector) =>
|
||
page.$eval(selector, (node) => {
|
||
const r = node.getBoundingClientRect();
|
||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||
});
|
||
// Right after a dock the rail is still animating, so the shared helpers
|
||
// re-resolve the header and retry only a press that provably missed it.
|
||
const floatPanel = async () => {
|
||
const attempt = await liftPanelByHeader(page, PANEL_ID, {
|
||
dx: -400,
|
||
dy: 80,
|
||
});
|
||
assert.equal(
|
||
attempt.done,
|
||
true,
|
||
`a header drag lifts the panel out of the rail (${describePress(attempt)})`,
|
||
);
|
||
assert.equal(await floating(), true);
|
||
};
|
||
await step(
|
||
'the panel floats on a header drag, resizes, and the viewer takes the room',
|
||
async () => {
|
||
await openById(firstImageId);
|
||
await floatPanel();
|
||
const viewerHeight = () =>
|
||
page.$eval('#sl-viewer', (node) =>
|
||
Math.round(node.getBoundingClientRect().height),
|
||
);
|
||
const before = await viewerHeight();
|
||
const grip = await center('#street-level-panel .panel-resize-grip');
|
||
await page.mouse.move(grip.x, grip.y);
|
||
await page.mouse.down();
|
||
await page.mouse.move(grip.x + 120, grip.y + 160, { steps: 10 });
|
||
await page.mouse.up();
|
||
await settle(
|
||
page,
|
||
(height) =>
|
||
document.getElementById('sl-viewer').getBoundingClientRect()
|
||
.height >
|
||
height + 60,
|
||
before,
|
||
);
|
||
const after = await viewerHeight();
|
||
assert.ok(
|
||
after > before + 60,
|
||
`viewer grew with the window (${before} → ${after}px)`,
|
||
);
|
||
assert.equal(
|
||
await page.$eval(
|
||
'.sl-settings',
|
||
(node) => getComputedStyle(node).overflowY,
|
||
),
|
||
'auto',
|
||
'only the settings block scrolls',
|
||
);
|
||
},
|
||
);
|
||
await step(
|
||
'SHRINK after EXPAND docks the window back in the rail at its default size, viewer inside the panel',
|
||
async () => {
|
||
await clickPanelControl(page, '#sl-viewer-expand');
|
||
await settle(page, () =>
|
||
document
|
||
.getElementById('sl-viewer-wrap')
|
||
.classList.contains('sl-viewer-wrap-expanded'),
|
||
);
|
||
assert.equal(await expanded(), true, 'expanded');
|
||
await clickPanelControl(page, '#sl-viewer-expand');
|
||
await settle(
|
||
page,
|
||
() =>
|
||
!document
|
||
.getElementById('street-level-panel')
|
||
.classList.contains('panel-floating') &&
|
||
!document
|
||
.getElementById('sl-viewer-wrap')
|
||
.classList.contains('sl-viewer-wrap-expanded'),
|
||
);
|
||
assert.equal(await floating(), false, 'docked again');
|
||
const style = await page.$eval('#street-level-panel', (node) => ({
|
||
width: node.style.width,
|
||
height: node.style.height,
|
||
parent: node.parentElement.id,
|
||
}));
|
||
assert.deepEqual(style, {
|
||
width: '',
|
||
height: '',
|
||
parent: 'right-context-rail',
|
||
});
|
||
const home = await page.evaluate(() => {
|
||
const wrap = document.getElementById('sl-viewer-wrap');
|
||
return {
|
||
inPanel: Boolean(wrap.closest('#street-level-panel')),
|
||
onBody: wrap.parentElement === document.body,
|
||
};
|
||
});
|
||
assert.deepEqual(
|
||
home,
|
||
{ inPanel: true, onBody: false },
|
||
'the viewer returned into the panel',
|
||
);
|
||
},
|
||
);
|
||
await step('a header double-click docks a floating window', async () => {
|
||
await floatPanel();
|
||
const attempt = await dockPanelByDoubleClick(page, PANEL_ID);
|
||
assert.equal(
|
||
attempt.done,
|
||
true,
|
||
`docked again (${describePress(attempt)})`,
|
||
);
|
||
});
|
||
await step(
|
||
'collapsing a floating window docks it as the rail strip',
|
||
async () => {
|
||
// The double-click just docked a panel showing a photo: let the photo
|
||
// and its sequence finish loading before pressing the header again.
|
||
await uiUntil((u) => !u.street.loading && !u.sequence.loading);
|
||
await floatPanel();
|
||
await clickPanelControl(
|
||
page,
|
||
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
|
||
);
|
||
await settle(page, () => {
|
||
const node = document.getElementById('street-level-panel');
|
||
return (
|
||
node.classList.contains('collapsed') &&
|
||
!node.classList.contains('panel-floating')
|
||
);
|
||
});
|
||
const state = await page.$eval('#street-level-panel', (node) => ({
|
||
floating: node.classList.contains('panel-floating'),
|
||
collapsed: node.classList.contains('collapsed'),
|
||
height: node.style.height,
|
||
}));
|
||
assert.deepEqual(state, {
|
||
floating: false,
|
||
collapsed: true,
|
||
height: '',
|
||
});
|
||
},
|
||
);
|
||
await step(
|
||
'on a phone the whole photo fits in the docked panel',
|
||
async () => {
|
||
// Width alone drives the phone layout; toggling isMobile would reload.
|
||
await page.setViewport({ width: 390, height: 844 });
|
||
await settle(page, () => innerWidth === 390);
|
||
if (
|
||
await page.$eval('#street-level-panel', (node) =>
|
||
node.classList.contains('collapsed'),
|
||
)
|
||
)
|
||
await expandStrip();
|
||
await openById(firstImageId);
|
||
const measure = () =>
|
||
page.evaluate(() => {
|
||
const inner = document
|
||
.querySelector('.street-level-panel-inner')
|
||
.getBoundingClientRect();
|
||
const meta = document
|
||
.getElementById('sl-image-meta')
|
||
.getBoundingClientRect();
|
||
const viewer = document
|
||
.getElementById('sl-viewer')
|
||
.getBoundingClientRect();
|
||
return {
|
||
cut: Math.round(meta.bottom - inner.bottom),
|
||
top: Math.round(viewer.top - inner.top),
|
||
height: Math.round(viewer.height),
|
||
overflowX: document.documentElement.scrollWidth > innerWidth,
|
||
};
|
||
});
|
||
// The panel settles its phone layout over a frame or two.
|
||
await settle(page, () => {
|
||
const inner = document
|
||
.querySelector('.street-level-panel-inner')
|
||
.getBoundingClientRect();
|
||
const meta = document
|
||
.getElementById('sl-image-meta')
|
||
.getBoundingClientRect();
|
||
return (
|
||
meta.bottom - inner.bottom <= 1 &&
|
||
document.getElementById('sl-viewer').getBoundingClientRect()
|
||
.height >= 100
|
||
);
|
||
});
|
||
const fit = await measure();
|
||
assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`);
|
||
assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`);
|
||
assert.equal(fit.overflowX, false, 'no sideways scroll');
|
||
await page.setViewport(VIEWPORTS[0]);
|
||
await settle(
|
||
page,
|
||
(width) => innerWidth === width,
|
||
VIEWPORTS[0].width,
|
||
);
|
||
},
|
||
);
|
||
const googleChip = '#sl-provider-chips [data-chip-id="google"]';
|
||
const googleKey = await page.evaluate(() =>
|
||
Boolean(window.__GOOGLE_MAPS_API_KEY__),
|
||
);
|
||
await step(
|
||
'Street View starts off, appears only on the Google 3D map, and travels in the share link',
|
||
async () => {
|
||
const module = () =>
|
||
window.__godsEyeView.dataManager.layers.get('street-level').module;
|
||
assert.equal(
|
||
await page.evaluate(`(${module})().getParams().google`),
|
||
false,
|
||
'off until lit: panoramas are billed',
|
||
);
|
||
const setStreetView = (on) =>
|
||
page.evaluate(
|
||
(value) =>
|
||
window.__godsEyeView.dataManager.setLayerParams(
|
||
'street-level',
|
||
{ google: value },
|
||
{ origin: 'user' },
|
||
),
|
||
on,
|
||
);
|
||
const shown = () =>
|
||
page.evaluate(() => ({
|
||
chip: Boolean(
|
||
document.querySelector(
|
||
'#sl-provider-chips [data-chip-id="google"]',
|
||
),
|
||
),
|
||
legend: [...document.querySelectorAll('#sl-legend li')].some(
|
||
(item) => /Street View/.test(item.textContent),
|
||
),
|
||
credit: document.body.innerHTML.includes('Street View imagery'),
|
||
button: !document.getElementById('sl-nearest-btn').hidden,
|
||
}));
|
||
const original = await stacks.active();
|
||
// Off Google 3D (CI's only map) nothing of Street View shows, even
|
||
// switched on: Google's terms keep it off other maps.
|
||
if (original === 'photoreal') await stacks.set('esri-imagery');
|
||
await setStreetView(true);
|
||
await uiUntil((u) => u.providers.every((p) => p.id !== 'google'));
|
||
assert.ok(
|
||
await settle(
|
||
page,
|
||
() => !document.body.innerHTML.includes('Street View imagery'),
|
||
),
|
||
'no Google credit on another map',
|
||
);
|
||
assert.deepEqual(await shown(), {
|
||
chip: false,
|
||
legend: false,
|
||
credit: false,
|
||
button: false,
|
||
});
|
||
// Street View rides the shared provider-switch field (r), 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 step(
|
||
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
|
||
async () => {
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.closeViewer(),
|
||
);
|
||
// Press through the DOM: a toast over the button must not make this
|
||
// step about pointer aim.
|
||
await page.$eval(
|
||
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
|
||
(button) => {
|
||
const panel = document.getElementById('street-level-panel');
|
||
if (!panel.classList.contains('collapsed')) button.click();
|
||
},
|
||
);
|
||
assert.ok(
|
||
await settle(page, () =>
|
||
document
|
||
.getElementById('street-level-panel')
|
||
.classList.contains('collapsed'),
|
||
),
|
||
'the panel collapsed',
|
||
);
|
||
const shared = (collapsed) =>
|
||
settle(
|
||
page,
|
||
(assignment) =>
|
||
(new URLSearchParams(location.hash.slice(1)).get('ui') || '')
|
||
.split('_')
|
||
.includes(assignment),
|
||
`${PANEL_UI_TOKEN}.c.${collapsed ? '1' : '0'}`,
|
||
);
|
||
assert.ok(
|
||
await shared(true),
|
||
`the share link records the collapsed panel as ui ${PANEL_UI_TOKEN}.c.1: ${await page.evaluate(() => location.hash)}`,
|
||
);
|
||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||
await clearFirstRun(page);
|
||
await waitForCoverage(page);
|
||
// The panel would open on the enable transition, before coverage.
|
||
const after = await page.evaluate(() => ({
|
||
enabled: window.__godsEyeView.dataManager.isEnabled('street-level'),
|
||
collapsed: document
|
||
.getElementById('street-level-panel')
|
||
.classList.contains('collapsed'),
|
||
}));
|
||
assert.deepEqual(
|
||
after,
|
||
{ enabled: true, collapsed: true },
|
||
'the restored layer did not reopen the panel',
|
||
);
|
||
assert.ok(await shared(true), 'still shared as collapsed');
|
||
},
|
||
);
|
||
if (fixtures)
|
||
await step(
|
||
'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED',
|
||
async () => {
|
||
const keyless = await browser.newPage();
|
||
await keyless.setViewport(VIEWPORTS[0]);
|
||
await watch(keyless, 'keyless');
|
||
await serveFixtures(keyless, newFixture({ configured: false }));
|
||
await boot(keyless, url);
|
||
await expandStrip(keyless);
|
||
await assertKeylessGate(keyless);
|
||
renderErrorsSeen.push(
|
||
...(await readRenderErrors(keyless)).map(
|
||
(text) => `[keyless] scene.renderError: ${text}`,
|
||
),
|
||
);
|
||
await keyless.close();
|
||
},
|
||
);
|
||
else
|
||
await skipStep(
|
||
'fixtures only',
|
||
'keyless install (a second page) reports KEY REQUIRED',
|
||
);
|
||
if (fixtures)
|
||
await step(
|
||
'every Mapillary and KartaView 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);
|
||
});
|
||
}
|