diff --git a/scripts/qa-mapillary.mjs b/scripts/qa-mapillary.mjs
new file mode 100644
index 000000000..2ce906d2e
--- /dev/null
+++ b/scripts/qa-mapillary.mjs
@@ -0,0 +1,345 @@
+#!/usr/bin/env node
+/**
+ * Browser QA for the Street Level (Mapillary) layer against a running dev
+ * server: the panel's place in the right rail, the keyless gate, coverage,
+ * a ready-made feature plan (no LLM), the embedded viewer and its expanded
+ * dialog. Run with `npm run qa:mapillary -- --url http://localhost:4173`.
+ * Without MAPILLARY_CLIENT_TOKEN on the server only the keyless steps run.
+ */
+import assert from 'node:assert/strict';
+import { pathToFileURL } from 'node:url';
+
+/** Viewports every layout assertion runs at. */
+export const VIEWPORTS = Object.freeze([
+ { width: 1440, height: 900 },
+ { width: 1280, height: 800 },
+]);
+
+/** A feature plan the executor can run without the planner. */
+export function hydrantPlan() {
+ return {
+ intent: 'map_features',
+ layer: 'points',
+ place: null,
+ use_current_view: true,
+ values: ['object--fire-hydrant'],
+ seen_after: null,
+ seen_before: null,
+ prefer_pano: false,
+ visualise: 'icons',
+ title: 'Fire hydrants · current view',
+ answer: 'Showing fire hydrants in the current view.',
+ };
+}
+
+/** Expected right-rail order once the layout controller has run. */
+export const RAIL_ORDER = Object.freeze([
+ 'pp-toggles',
+ 'cctv-panel',
+ 'mapillary-panel',
+ 'global-context-panel',
+]);
+
+export function isCollapsed(classList) {
+ return [...classList].includes('collapsed');
+}
+
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+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 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 passed = 0;
+ const step = async (label, fn) => {
+ const result = await fn();
+ passed++;
+ console.log(`ok ${passed} ${label}`);
+ return result;
+ };
+ try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(
+ () => Boolean(window.__godsEyeView?.dataManager),
+ {
+ timeout: 150_000,
+ },
+ );
+ await page.evaluate(() =>
+ document.querySelector('.first-run-explore')?.click(),
+ );
+ await sleep(800);
+ await page.keyboard.press('Escape');
+ await sleep(400);
+ await page.evaluate(() => {
+ for (const el of document.querySelectorAll(
+ '#first-run-launcher, [class*=first-run]',
+ ))
+ el.remove();
+ });
+ const module = () =>
+ window.__godsEyeView.dataManager.layers.get('mapillary').module;
+ const panel = () =>
+ page.evaluate(() => {
+ const el = document.getElementById('mapillary-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),
+ keyless: !document.getElementById('mly-keyless').hidden,
+ controlsDisabled: document.getElementById('mly-controls').disabled,
+ bodyDisplay: getComputedStyle(document.getElementById('mly-body'))
+ .display,
+ };
+ });
+ const status = await page.evaluate(() =>
+ fetch('/api/mapillary/status').then((res) => res.json()),
+ );
+ for (const viewport of VIEWPORTS) {
+ await page.setViewport(viewport);
+ await sleep(600);
+ await step(
+ `panel is a collapsed right-rail strip at ${viewport.width}×${viewport.height}`,
+ async () => {
+ 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]);
+ await page.click(
+ '.panel-collapse-btn[data-collapse-target="mapillary-panel"]',
+ );
+ await sleep(500);
+ await step(
+ 'expanding the strip shows the body and the four-swatch legend',
+ async () => {
+ const info = await panel();
+ assert.ok(!isCollapsed(info.classes));
+ assert.equal(info.bodyDisplay, 'flex');
+ assert.equal(
+ await page.evaluate(
+ () => document.querySelectorAll('#mly-legend li').length,
+ ),
+ 4,
+ );
+ },
+ );
+ if (!status.configured) {
+ await step(
+ 'keyless install gates the controls and names the token',
+ async () => {
+ await page.evaluate(() =>
+ window.__godsEyeView.dataManager.setEnabled('mapillary', true, {
+ origin: 'user',
+ }),
+ );
+ await sleep(800);
+ const info = await panel();
+ assert.equal(info.keyless, true);
+ assert.equal(info.controlsDisabled, true);
+ assert.match(
+ await page.evaluate(
+ () => document.getElementById('mly-keyless').textContent,
+ ),
+ /MAPILLARY_CLIENT_TOKEN/,
+ );
+ },
+ );
+ console.log(
+ 'keyless run complete (no MAPILLARY_CLIENT_TOKEN on the server)',
+ );
+ return;
+ }
+ // The startup flight can still be running: cancel it, park the camera over
+ // downtown Sacramento and confirm it stays put before enabling the layer.
+ const park = () =>
+ page.evaluate(() => {
+ 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(-121.4944, 38.5816, 900),
+ ),
+ orientation: { heading: 0, pitch: -1.3, roll: 0 },
+ });
+ });
+ for (let attempt = 0; attempt < 6; attempt++) {
+ await park();
+ await sleep(1500);
+ const stable = await page.evaluate(() => {
+ const c = window.__godsEyeView.viewer.camera.positionCartographic;
+ return Math.abs((c.longitude * 180) / Math.PI + 121.4944) < 0.01;
+ });
+ if (stable) break;
+ }
+ await step(
+ 'enabling draws coverage and registers the on-globe credit',
+ async () => {
+ await page.evaluate(() =>
+ window.__godsEyeView.dataManager.setEnabled('mapillary', true, {
+ origin: 'user',
+ }),
+ );
+ await page.waitForFunction(
+ () => {
+ const u = window.__godsEyeView.dataManager.layers
+ .get('mapillary')
+ .module.getUIState();
+ return u.coverage.sequences > 0 && !u.coverage.loading;
+ },
+ { timeout: 90_000 },
+ );
+ const info = await panel();
+ assert.equal(info.keyless, 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 },
+ );
+ },
+ );
+ await step(
+ 'a ready-made plan streams results and fills the chips',
+ async () => {
+ // Fire and forget: the fan-out can outlive a CDP call, so poll instead.
+ await page.evaluate((plan) => {
+ void window.__godsEyeView.dataManager.layers
+ .get('mapillary')
+ .module.runPlan(plan, { prompt: 'fire hydrants here' });
+ }, hydrantPlan());
+ await sleep(500);
+ await page.waitForFunction(
+ () =>
+ !window.__godsEyeView.dataManager.layers
+ .get('mapillary')
+ .module.getUIState().query.busy,
+ { timeout: 120_000 },
+ );
+ const state = await page.evaluate(() => {
+ const u = window.__godsEyeView.dataManager.layers
+ .get('mapillary')
+ .module.getUIState();
+ return {
+ total: u.features.total,
+ answer: u.query.answer,
+ chips: document.querySelectorAll(
+ '#mly-result-chips .mly-value-chip',
+ ).length,
+ };
+ });
+ assert.ok(state.total > 0, 'hydrants found in view');
+ assert.match(state.answer, /fire hydrants?/i);
+ assert.ok(state.chips >= 1);
+ },
+ );
+ await step(
+ 'opening the nearest image shows the viewer with a caption',
+ async () => {
+ await page.evaluate(() =>
+ window.__godsEyeView.dataManager.layers
+ .get('mapillary')
+ .module.openNearest(),
+ );
+ await page.waitForFunction(
+ () => {
+ const s = window.__godsEyeView.dataManager.layers
+ .get('mapillary')
+ .module.getUIState().street;
+ return s.imageId || s.error;
+ },
+ { timeout: 90_000 },
+ );
+ await sleep(2500);
+ const view = await page.evaluate(() => ({
+ hidden: document.getElementById('mly-viewer-wrap').hidden,
+ by: document.getElementById('mly-image-by').textContent,
+ width: Math.round(
+ document.getElementById('mly-viewer').getBoundingClientRect().width,
+ ),
+ }));
+ assert.equal(view.hidden, false);
+ assert.ok(view.width > 200);
+ assert.match(view.by, /Image by/);
+ },
+ );
+ await step(
+ 'EXPAND opens a modal dialog and Esc returns focus to the button',
+ async () => {
+ await page.click('#mly-viewer-expand');
+ await sleep(600);
+ const dialog = await page.evaluate(() => {
+ const wrap = document.getElementById('mly-viewer-wrap');
+ return {
+ role: wrap.getAttribute('role'),
+ modal: wrap.getAttribute('aria-modal'),
+ inside: wrap.contains(document.activeElement),
+ width: Math.round(wrap.getBoundingClientRect().width),
+ };
+ });
+ assert.equal(dialog.role, 'dialog');
+ assert.equal(dialog.modal, 'true');
+ assert.equal(dialog.inside, true);
+ assert.ok(dialog.width > 900);
+ await page.keyboard.press('Escape');
+ await sleep(400);
+ assert.equal(
+ await page
+ .evaluate(() => ({
+ expanded: document
+ .getElementById('mly-viewer-wrap')
+ .classList.contains('mly-viewer-wrap-expanded'),
+ focus: document.activeElement?.id,
+ }))
+ .then((r) => `${r.expanded}:${r.focus}`),
+ 'false:mly-viewer-expand',
+ );
+ },
+ );
+ await step('× closes the image and deselects it on the globe', async () => {
+ await page.click('#mly-viewer-close');
+ await sleep(500);
+ const after = await page.evaluate(() => {
+ const u = window.__godsEyeView.dataManager.layers
+ .get('mapillary')
+ .module.getUIState();
+ return { open: u.street.open, sequence: u.sequence.selectedId };
+ });
+ assert.equal(after.open, false);
+ assert.equal(after.sequence, null);
+ });
+ await step('no page errors', () => {
+ assert.deepEqual(errors, []);
+ });
+ void module;
+ } finally {
+ await browser.close();
+ }
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
+}
diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs
index 577027069..1a3c62e82 100644
--- a/src/data/layerState.test.mjs
+++ b/src/data/layerState.test.mjs
@@ -158,8 +158,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
- assert.equal(REGISTERED_LAYER_IDS.length, 21);
- assert.equal(new Set(REGISTERED_LAYER_IDS).size, 21);
+ assert.equal(REGISTERED_LAYER_IDS.length, 22);
+ assert.equal(new Set(REGISTERED_LAYER_IDS).size, 22);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
diff --git a/src/layers/mapillary/query.test.mjs b/src/layers/mapillary/query.test.mjs
index e30ef47f1..07d7240c8 100644
--- a/src/layers/mapillary/query.test.mjs
+++ b/src/layers/mapillary/query.test.mjs
@@ -4,6 +4,8 @@ import {
bboxFromGeocode,
enforceMinimumSpan,
fitBboxToTileBudget,
+ noResultsAnswer,
+ pluralLabel,
summarizeResults,
} from './query.js';
import { countTilesForBbox } from './tileMath.js';
@@ -54,15 +56,48 @@ test('summarizeResults reports totals, top classes and the sighting window', ()
});
assert.match(
text,
- /^1,234 features: 1,000 fire hydrant, 200 bench, 20 mailbox and 1 more classes/,
+ /^1,234 features: 1,000 fire hydrants, 200 benches, 20 mailboxes and 1 more class/,
);
assert.match(text, /seen 2019–2026 · capped · 2 tiles failed$/);
+});
+
+test('summarizeResults leads with the class when there is only one', () => {
+ const text = summarizeResults({
+ total: 834,
+ counts: new Map([['object--fire-hydrant', 834]]),
+ earliest: Date.UTC(2017, 0, 1),
+ latest: Date.UTC(2021, 0, 1),
+ failed: 1,
+ });
+ assert.equal(text, '834 fire hydrants · seen 2017–2021 · 1 tile failed');
assert.equal(
- summarizeResults({ total: 0, counts: new Map() }),
- 'No matching features in this area.',
+ summarizeResults({ total: 1, counts: new Map([['object--bench', 1]]) }),
+ '1 bench',
);
});
+test('zero results produce no summary so the answer can be replaced', () => {
+ assert.equal(summarizeResults({ total: 0, counts: new Map() }), '');
+ assert.equal(
+ noResultsAnswer(
+ { title: 'Fire hydrants · Springfield' },
+ { label: 'Springfield, IL' },
+ ),
+ 'No fire hydrants mapped in Springfield, IL yet. Try a larger area, another place, or set SINCE back to any date.',
+ );
+ assert.match(
+ noResultsAnswer({}, null),
+ /^No matching features mapped here yet/,
+ );
+});
+
+test("pluralLabel handles the taxonomy's English labels", () => {
+ assert.equal(pluralLabel('Fire hydrant', 2), 'fire hydrants');
+ assert.equal(pluralLabel('Bench', 3), 'benches');
+ assert.equal(pluralLabel('Mailbox', 4), 'mailboxes');
+ assert.equal(pluralLabel('Bench', 1), 'bench');
+});
+
test('node-sized geocode bounds are grown to a queryable neighbourhood box', () => {
const tiny = bboxFromGeocode({
geometry: {
diff --git a/src/layers/mapillary/source.test.mjs b/src/layers/mapillary/source.test.mjs
new file mode 100644
index 000000000..f76a5d495
--- /dev/null
+++ b/src/layers/mapillary/source.test.mjs
@@ -0,0 +1,145 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createMapillarySource, MapillarySourceError } from './source.js';
+
+function fakeFetch(handler) {
+ const calls = [];
+ const fetchImpl = async (url, init = {}) => {
+ calls.push({ url: String(url), init });
+ return handler(String(url), init);
+ };
+ return { calls, fetchImpl };
+}
+
+const jsonResponse = (payload, status = 200) =>
+ new Response(JSON.stringify(payload), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ });
+
+test('tiles go through the local proxy and never carry the token', async () => {
+ const { calls, fetchImpl } = fakeFetch(
+ () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
+ );
+ const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
+ const bytes = await source.getTile('coverage', 14, 2662, 6285);
+ assert.deepEqual([...bytes], [1, 2, 3]);
+ assert.equal(calls[0].url, '/api/mapillary/tiles/coverage/14/2662/6285');
+ assert.doesNotMatch(calls[0].url, /MLY/);
+});
+
+test('an empty tile (204) is an empty byte array, not an error', async () => {
+ const { fetchImpl } = fakeFetch(() => new Response(null, { status: 204 }));
+ const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
+ assert.equal((await source.getTile('coverage', 14, 1, 1)).length, 0);
+});
+
+test('a missing key on the proxy surfaces as keyRequired', async () => {
+ const { fetchImpl } = fakeFetch(() =>
+ jsonResponse({ error: 'no_key', keyRequired: true }, 503),
+ );
+ const source = createMapillarySource({ token: '', fetchImpl });
+ assert.equal(source.hasToken(), false);
+ await assert.rejects(
+ () => source.getTile('coverage', 14, 1, 1),
+ (error) =>
+ error instanceof MapillarySourceError &&
+ error.status === 503 &&
+ error.keyRequired === true,
+ );
+});
+
+test('graph lookups call graph.mapillary.com with the client token and requested fields', async () => {
+ const { calls, fetchImpl } = fakeFetch(() =>
+ jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
+ );
+ const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
+ const images = await source.getSequenceImages('seq-1');
+ assert.equal(images.length, 1);
+ const url = new URL(calls[0].url);
+ assert.equal(url.host, 'graph.mapillary.com');
+ assert.equal(url.pathname, '/images');
+ assert.equal(url.searchParams.get('access_token'), 'MLY|1|abc');
+ assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
+ assert.match(url.searchParams.get('fields'), /geometry/);
+
+ const nearest = await source.nearestImages({
+ lat: 38.5,
+ lon: -121.5,
+ radius: 500,
+ limit: 500,
+ });
+ assert.equal(nearest.length, 1);
+ const nearestUrl = new URL(calls[1].url);
+ assert.equal(
+ nearestUrl.searchParams.get('radius'),
+ '50',
+ 'radius is clamped to the API maximum',
+ );
+ assert.equal(nearestUrl.searchParams.get('limit'), '100');
+});
+
+test('graph calls without a token fail fast as keyRequired without a request', async () => {
+ const { calls, fetchImpl } = fakeFetch(() => jsonResponse({}));
+ const source = createMapillarySource({ token: '', fetchImpl });
+ await assert.rejects(
+ () => source.getImage('123'),
+ (error) =>
+ error instanceof MapillarySourceError && error.keyRequired === true,
+ );
+ assert.equal(calls.length, 0);
+});
+
+test('graph API errors carry the upstream message and status', async () => {
+ const { fetchImpl } = fakeFetch(() =>
+ jsonResponse({ error: { message: 'Invalid OAuth 2.0 Access Token' } }, 400),
+ );
+ const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
+ await assert.rejects(
+ () => source.getMapFeature('999'),
+ (error) =>
+ error instanceof MapillarySourceError &&
+ error.status === 400 &&
+ /Invalid OAuth/.test(error.message),
+ );
+});
+
+test('feature queries stream NDJSON records and resolve with the done record', async () => {
+ const lines = [
+ JSON.stringify({ type: 'start', tiles: 2 }),
+ JSON.stringify({
+ type: 'tile',
+ rows: [[-121.5, 38.5, 'a', 'object--bench', 0, 0]],
+ }),
+ JSON.stringify({ type: 'done', total: 1 }),
+ ].join('\n');
+ const { calls, fetchImpl } = fakeFetch(
+ () =>
+ new Response(lines, {
+ status: 200,
+ headers: { 'content-type': 'application/x-ndjson' },
+ }),
+ );
+ const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
+ const seen = [];
+ const done = await source.queryFeatures(
+ {
+ bbox: [-121.5, 38.5, -121.4, 38.6],
+ layer: 'points',
+ values: ['object--bench'],
+ },
+ (record) => seen.push(record.type),
+ );
+ assert.deepEqual(seen, ['start', 'tile', 'done']);
+ assert.equal(done.total, 1);
+ assert.equal(calls[0].url, '/api/mapillary/features');
+ assert.equal(calls[0].init.method, 'POST');
+});
+
+test('sprite URLs are proxied per taxonomy value', () => {
+ const source = createMapillarySource({ token: 'MLY|1|abc' });
+ assert.equal(
+ source.spriteUrl('object--fire-hydrant'),
+ '/api/mapillary/sprite/object--fire-hydrant.svg',
+ );
+});
diff --git a/src/mapillaryMarkup.test.mjs b/src/mapillaryMarkup.test.mjs
new file mode 100644
index 000000000..cdc8d7bed
--- /dev/null
+++ b/src/mapillaryMarkup.test.mjs
@@ -0,0 +1,92 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import test from 'node:test';
+import { fileURLToPath } from 'node:url';
+import { expandApplicationHtml } from '../build/application-html.js';
+import { readStylesheet } from './testSupport/readStylesheet.mjs';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const read = (file) => fs.readFileSync(path.join(ROOT, file), 'utf8');
+
+const html = expandApplicationHtml(read('index.html'));
+const css = readStylesheet(path.join(ROOT, 'style.css'));
+const panelChrome = read('src/ui/panelChrome.js');
+const layoutController = read('src/ui/panelLayoutController.js');
+const mapillaryCss = read('src/ui/styles/mapillary.css');
+const controls = read('src/ui/mapillaryControls.js');
+
+test('Street Level is an ordinary collapsible GEV panel that starts collapsed', () => {
+ assert.match(
+ html,
+ /
/,
+ );
+ assert.match(
+ html,
+ /