mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
On CI's SwiftShader the ground-clamped cones take a few frames after a sequence is selected; a single pick search found none. Co-Authored-By: Claude Opus 5.5 <[email protected]>
770 lines
25 KiB
JavaScript
770 lines
25 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Browser QA for the Street Level layer against a running server:
|
||
* `npm run qa:street-level -- --url http://localhost:4173`.
|
||
*
|
||
* `--fixtures` (CI) answers every *.mapillary.com, KartaView and Panoramax
|
||
* request and the coverage tile route from fixtures, so nothing reaches those
|
||
* providers; the server still needs a (dummy) Mapillary token because the
|
||
* bundle carries it. `--strict` fails on any
|
||
* skip other than "fixtures only". A failed run saves evidence under
|
||
* qa-artifacts/street-level/.
|
||
*/
|
||
import assert from 'node:assert/strict';
|
||
import { pathToFileURL } from 'node:url';
|
||
import { tileBounds } from '../src/layers/streetLevel/tileMath.js';
|
||
import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs';
|
||
import {
|
||
hookRenderErrors,
|
||
readRenderErrors,
|
||
saveFailureArtifacts,
|
||
watchPage,
|
||
} from './qa-browserEvidence.mjs';
|
||
import {
|
||
answerMapillaryRequest,
|
||
describeCall,
|
||
PHOTO_LINE,
|
||
PHOTO_SEQUENCE_ID,
|
||
photoImages,
|
||
} from './fixtures/street-level/mapillaryGraph.mjs';
|
||
import {
|
||
answerKartaViewRequest,
|
||
describeKartaViewCall,
|
||
KV_LINE,
|
||
KV_SEQUENCE_ID,
|
||
kartaViewPhotos,
|
||
resetKartaViewFixture,
|
||
} from './fixtures/street-level/kartaView.mjs';
|
||
import {
|
||
answerPanoramaxRequest,
|
||
describePanoramaxCall,
|
||
PNX_PARK,
|
||
panoramaxPictures,
|
||
resetPanoramaxFixture,
|
||
} from './fixtures/street-level/panoramax.mjs';
|
||
|
||
const VIEWPORT = { width: 1440, height: 900 };
|
||
/** Over downtown Sacramento, above the fixture photo line, looking down. */
|
||
const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 };
|
||
const DAY_MS = 86_400_000;
|
||
|
||
/**
|
||
* Coverage for any street-zoom tile: a grid of 360°/flat, recent/old
|
||
* sequences, plus the photo sequence where PHOTO_LINE crosses the tile.
|
||
* @returns {Uint8Array} empty below z11, which the layer never asks for
|
||
*/
|
||
function fixtureTile(z, x, y, now = Date.now()) {
|
||
if (z < 11) return new Uint8Array(0);
|
||
const { west, east, south, north } = tileBounds(x, y, z);
|
||
const at = (t, lo, hi) => lo + (hi - lo) * t;
|
||
const margin = (east - west) * 0.05;
|
||
const sequences = [];
|
||
for (let i = 1; i <= 4; i++) {
|
||
const t = i / 5;
|
||
const old = i > 2;
|
||
const lat = at(t, south, north);
|
||
const lon = at(t, west, east);
|
||
sequences.push(
|
||
{
|
||
id: `fx-${z}-${x}-${y}-h${i}`,
|
||
isPano: i % 2 === 0,
|
||
capturedAt: now - (old ? 1100 : 30) * DAY_MS,
|
||
parts: [
|
||
[
|
||
[west + margin, lat],
|
||
[east - margin, lat],
|
||
],
|
||
],
|
||
},
|
||
{
|
||
id: `fx-${z}-${x}-${y}-v${i}`,
|
||
isPano: i % 2 === 1,
|
||
capturedAt: now - (old ? 30 : 1100) * DAY_MS,
|
||
parts: [
|
||
[
|
||
[lon, south + margin],
|
||
[lon, north - margin],
|
||
],
|
||
],
|
||
},
|
||
);
|
||
}
|
||
const lineSouth = Math.max(PHOTO_LINE.south, south);
|
||
const lineNorth = Math.min(PHOTO_LINE.north, north);
|
||
if (PHOTO_LINE.lon >= west && PHOTO_LINE.lon < east && lineSouth < lineNorth)
|
||
sequences.push({
|
||
id: PHOTO_SEQUENCE_ID,
|
||
isPano: false,
|
||
capturedAt: now - 30 * DAY_MS,
|
||
parts: [
|
||
[
|
||
[PHOTO_LINE.lon, lineSouth],
|
||
[PHOTO_LINE.lon, lineNorth],
|
||
],
|
||
],
|
||
});
|
||
return encodeCoverageTile({ x, y, z }, { sequences });
|
||
}
|
||
|
||
/** Fixture answers and call names per keyless provider host. */
|
||
const KEYLESS_HOSTS = [
|
||
{
|
||
host: /(^|\.)(kartaview|openstreetcam)\.org$/,
|
||
describe: describeKartaViewCall,
|
||
answer: answerKartaViewRequest,
|
||
},
|
||
{
|
||
host: /^(api\.panoramax\.xyz|panoramax\.openstreetmap\.fr)$/,
|
||
describe: describePanoramaxCall,
|
||
answer: answerPanoramaxRequest,
|
||
},
|
||
];
|
||
|
||
/**
|
||
* Answer the page's Mapillary, KartaView and Panoramax requests from
|
||
* `fixture`, which steps may change mid-run. `configured: undefined` lets the
|
||
* server's real status route answer.
|
||
*/
|
||
async function serveFixtures(page, fixture) {
|
||
// CDP Fetch on just these URLs: page-wide interception stalls Cesium workers.
|
||
const cdp = await page.createCDPSession();
|
||
await cdp.send('Fetch.enable', {
|
||
patterns: [
|
||
{ urlPattern: '*://*.mapillary.com/*' },
|
||
{ urlPattern: '*/api/mapillary/*' },
|
||
{ urlPattern: '*://api.kartaview.org/*' },
|
||
{ urlPattern: '*://cdn.kartaview.org/*' },
|
||
{ urlPattern: '*://*.openstreetcam.org/*' },
|
||
{ urlPattern: '*://api.panoramax.xyz/*' },
|
||
{ urlPattern: '*://panoramax.openstreetmap.fr/*' },
|
||
],
|
||
});
|
||
const json = (status, payload) => ({
|
||
status,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const answerFor = (method, address) => {
|
||
if (/(^|\.)mapillary\.com$/.test(address.hostname)) {
|
||
const call = describeCall(method, address);
|
||
const answer = answerMapillaryRequest({
|
||
method,
|
||
url: address.href,
|
||
images: fixture.images,
|
||
});
|
||
fixture.calls.add(call);
|
||
if (!answer.known) fixture.unknown.push(call);
|
||
return answer;
|
||
}
|
||
const keyless = KEYLESS_HOSTS.find(({ host }) =>
|
||
host.test(address.hostname),
|
||
);
|
||
if (keyless) {
|
||
const call = keyless.describe(method, address);
|
||
const answer = keyless.answer({ method, url: address.href });
|
||
fixture.calls.add(call);
|
||
if (!answer.known) fixture.unknown.push(call);
|
||
return answer;
|
||
}
|
||
if (address.pathname === '/api/mapillary/status')
|
||
return fixture.configured === undefined
|
||
? null
|
||
: json(200, { configured: fixture.configured });
|
||
const tile = address.pathname.match(
|
||
/^\/api\/mapillary\/tiles\/coverage\/(\d+)\/(\d+)\/(\d+)$/,
|
||
);
|
||
if (!tile) return null;
|
||
if (fixture.configured === false)
|
||
return json(503, { error: 'no_key', keyRequired: true });
|
||
if (fixture.rejected)
|
||
return json(403, {
|
||
error: 'Mapillary rejected the access token',
|
||
keyRejected: true,
|
||
});
|
||
const bytes = fixtureTile(...tile.slice(1).map(Number));
|
||
return bytes.length
|
||
? { status: 200, contentType: 'application/x-protobuf', body: bytes }
|
||
: { status: 204, body: '' };
|
||
};
|
||
cdp.on('Fetch.requestPaused', ({ requestId, request }) => {
|
||
const answer = answerFor(request.method, new URL(request.url));
|
||
const done = answer
|
||
? cdp.send('Fetch.fulfillRequest', {
|
||
requestId,
|
||
responseCode: answer.status,
|
||
responseHeaders: Object.entries({
|
||
...answer.headers,
|
||
...(answer.contentType
|
||
? { 'Content-Type': answer.contentType }
|
||
: {}),
|
||
}).map(([name, value]) => ({ name, value: String(value) })),
|
||
body: Buffer.from(answer.body ?? '').toString('base64'),
|
||
})
|
||
: cdp.send('Fetch.continueRequest', { requestId });
|
||
// A page that navigated or closed meanwhile has no request to answer.
|
||
done.catch(() => {});
|
||
});
|
||
}
|
||
|
||
function newFixture(overrides = {}) {
|
||
return {
|
||
configured: undefined,
|
||
rejected: false,
|
||
images: photoImages(),
|
||
calls: new Set(),
|
||
unknown: [],
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
/** Load the app and dismiss the first-run dialog. */
|
||
async function boot(page, url) {
|
||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||
await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), {
|
||
timeout: 150_000,
|
||
});
|
||
await page.evaluate(() =>
|
||
document.querySelector('.first-run-explore')?.click(),
|
||
);
|
||
await page.keyboard.press('Escape');
|
||
await page.evaluate(() => {
|
||
for (const el of document.querySelectorAll(
|
||
'#first-run-launcher, [class*=first-run]',
|
||
))
|
||
el.remove();
|
||
});
|
||
}
|
||
|
||
/** Run `test(uiState, arg)` in the page until it is true. */
|
||
const uiUntil = (page, test, arg = null, timeout = 30_000) =>
|
||
page.waitForFunction(
|
||
`(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`,
|
||
{ timeout, polling: 100 },
|
||
);
|
||
|
||
/** `id` is the one provider lit. */
|
||
const litAlone = (u, id) =>
|
||
u.providers.find((p) => p.id === id)?.on === true &&
|
||
u.providers.filter((p) => p.on).length === 1;
|
||
|
||
const getUI = (page) =>
|
||
page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.getUIState(),
|
||
);
|
||
|
||
const statusIs = (page, text) =>
|
||
page.waitForFunction(
|
||
(want) => document.getElementById('sl-status').textContent === want,
|
||
{ timeout: 30_000 },
|
||
text,
|
||
);
|
||
|
||
const waitForCoverage = (page) =>
|
||
uiUntil(
|
||
page,
|
||
(u) =>
|
||
u.enabled &&
|
||
u.coverage.count > 0 &&
|
||
!u.coverage.loading &&
|
||
!u.keyRequired,
|
||
null,
|
||
90_000,
|
||
);
|
||
|
||
/**
|
||
* Press a panel control through the DOM: the rail animates panel heights, so
|
||
* a pointer aimed from one frame's layout can miss in the next.
|
||
*/
|
||
const press = (page, selector) =>
|
||
page.$eval(selector, (control) => control.click());
|
||
|
||
/** Expand the strip; a panel state stored by an earlier page may have done so. */
|
||
async function expandPanel(page) {
|
||
const collapsed = await page.$eval('#street-level-panel', (node) =>
|
||
node.classList.contains('collapsed'),
|
||
);
|
||
if (collapsed)
|
||
await press(
|
||
page,
|
||
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
|
||
);
|
||
await page.waitForFunction(
|
||
() =>
|
||
getComputedStyle(document.getElementById('sl-body')).display === 'flex',
|
||
{ timeout: 10_000 },
|
||
);
|
||
}
|
||
|
||
async function assertKeyRequired(page) {
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||
origin: 'user',
|
||
}),
|
||
);
|
||
await statusIs(page, 'KEY REQUIRED');
|
||
const shown = await page.evaluate(() => ({
|
||
disabled: document.getElementById('sl-controls').disabled,
|
||
hint: document.getElementById('sl-error').hidden
|
||
? ''
|
||
: document.getElementById('sl-error').textContent,
|
||
}));
|
||
assert.equal(shown.disabled, true, 'controls are gated');
|
||
assert.match(shown.hint, /MAPILLARY_CLIENT_TOKEN/, 'names the key to add');
|
||
}
|
||
|
||
/**
|
||
* Client coordinates of the first thing drawn whose pick id starts with
|
||
* `prefix`, scanning `box` ({x, y, w, h} in canvas pixels; default: a row
|
||
* across the middle) every `step` px; null when nothing matches.
|
||
*/
|
||
const findPick = (page, prefix, { box = null, step = 3 } = {}) =>
|
||
page.evaluate(
|
||
(want, area, stride) => {
|
||
const { scene } = window.__godsEyeView.viewer;
|
||
const rect = scene.canvas.getBoundingClientRect();
|
||
const { x, y, w, h } = area || {
|
||
x: 0,
|
||
y: Math.round(rect.height / 2),
|
||
w: rect.width,
|
||
h: 1,
|
||
};
|
||
for (let py = y; py < y + h; py += stride)
|
||
for (let px = x; px < x + w; px += stride) {
|
||
const id = scene.pick({ x: px, y: py })?.id;
|
||
if (typeof id === 'string' && id.startsWith(want))
|
||
return {
|
||
x: rect.left + px,
|
||
y: rect.top + py,
|
||
local: { x: px, y: py },
|
||
id,
|
||
};
|
||
}
|
||
return null;
|
||
},
|
||
prefix,
|
||
box,
|
||
step,
|
||
);
|
||
|
||
/**
|
||
* `findPick`, retried until a hit or `timeout`: draped lines build in a
|
||
* worker after their tile decoded, so a scan right after LOADING clears can
|
||
* come too early on a slow machine.
|
||
*/
|
||
async function findPickUntil(
|
||
page,
|
||
prefix,
|
||
{ timeout = 30_000, box = null, step } = {},
|
||
) {
|
||
const deadline = Date.now() + timeout;
|
||
for (;;) {
|
||
const hit = await findPick(page, prefix, { box, step });
|
||
if (hit || Date.now() >= deadline) return hit;
|
||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||
}
|
||
}
|
||
|
||
/** Park the camera at `at`; a late startup flight can move it. */
|
||
async function park(page, at = PARK) {
|
||
for (let attempt = 0; attempt < 5; attempt++) {
|
||
const stayed = await page.evaluate(async (at) => {
|
||
const v = window.__godsEyeView.viewer;
|
||
v.camera.cancelFlight?.();
|
||
const C = v.camera.positionCartographic.constructor;
|
||
v.camera.setView({
|
||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||
C.fromDegrees(at.lon, at.lat, at.height),
|
||
),
|
||
orientation: { heading: 0, pitch: at.pitch, roll: 0 },
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||
const c = v.camera.positionCartographic;
|
||
return (
|
||
Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 &&
|
||
Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001
|
||
);
|
||
}, at);
|
||
if (stayed) return;
|
||
}
|
||
throw new Error(`the camera would not stay parked at ${JSON.stringify(at)}`);
|
||
}
|
||
|
||
async function main() {
|
||
const { default: puppeteer } = await import('puppeteer');
|
||
const args = process.argv.slice(2);
|
||
const urlIndex = args.indexOf('--url');
|
||
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
|
||
const fixtures = args.includes('--fixtures');
|
||
const strict = args.includes('--strict');
|
||
const browser = await puppeteer.launch({
|
||
headless: true,
|
||
executablePath:
|
||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||
(await puppeteer.executablePath()),
|
||
defaultViewport: VIEWPORT,
|
||
protocolTimeout: 300_000,
|
||
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
|
||
});
|
||
let count = 0;
|
||
const skips = [];
|
||
const skip = (reason, label) => {
|
||
skips.push(reason);
|
||
console.log(`skip (${reason}) ${++count} ${label}`);
|
||
};
|
||
// `fn` may call `skipped(reason)` when a live run has nothing to test.
|
||
const step = async (label, fn) => {
|
||
let reason = null;
|
||
await fn({ skipped: (why) => (reason = why) });
|
||
if (reason) skip(reason, label);
|
||
else console.log(`ok ${++count} ${label}`);
|
||
};
|
||
const fixtureStep = (label, fn) =>
|
||
fixtures ? step(label, fn) : skip('fixtures only', label);
|
||
const errors = [];
|
||
const monitors = [];
|
||
// The layer only warns on listener exceptions, so count those as errors.
|
||
const open = async (name, fixture) => {
|
||
const page = await browser.newPage();
|
||
monitors.push(
|
||
watchPage(page, {
|
||
name,
|
||
errors,
|
||
isError: (text) => /listener error/i.test(text),
|
||
}),
|
||
);
|
||
await hookRenderErrors(page);
|
||
if (fixture) await serveFixtures(page, fixture);
|
||
await boot(page, url);
|
||
return page;
|
||
};
|
||
const collectRenderErrors = async (page, name) =>
|
||
errors.push(
|
||
...(await readRenderErrors(page)).map(
|
||
(text) => `[${name}] scene.renderError: ${text}`,
|
||
),
|
||
);
|
||
|
||
/**
|
||
* A keyless provider: lighting its chip darkens the others and draws its
|
||
* coverage with its credit; a click on a line opens a photo from it; ×
|
||
* closes it. Leaves that provider lit.
|
||
*/
|
||
const keylessProviderStep = ({
|
||
id,
|
||
name,
|
||
at,
|
||
linePrefix,
|
||
box,
|
||
reset,
|
||
check,
|
||
}) =>
|
||
step(
|
||
`${name}: its chip draws keyless coverage and its credit, a line opens its photo, × closes it`,
|
||
async ({ skipped }) => {
|
||
reset();
|
||
await park(page, at);
|
||
await press(page, `#sl-provider-chips [data-chip-id="${id}"]`);
|
||
await uiUntil(page, litAlone, id);
|
||
await waitForCoverage(page);
|
||
await page.waitForFunction(
|
||
(credit) => document.body.innerHTML.includes(credit),
|
||
{ timeout: 15_000 },
|
||
`${name}</a> contributors`,
|
||
);
|
||
const area =
|
||
box === 'column'
|
||
? await page.evaluate(() => {
|
||
const { height, width } =
|
||
window.__godsEyeView.viewer.scene.canvas.getBoundingClientRect();
|
||
return { x: Math.round(width * 0.3), y: 0, w: 1, h: height };
|
||
})
|
||
: null;
|
||
const line = await findPickUntil(page, linePrefix, { box: area });
|
||
if (!line && !fixtures) return skipped(`no ${name} line in view`);
|
||
assert.ok(line, `a ${name} line in view`);
|
||
await page.mouse.click(line.x, line.y);
|
||
await uiUntil(
|
||
page,
|
||
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
|
||
null,
|
||
90_000,
|
||
);
|
||
const { street } = await getUI(page);
|
||
if (!fixtures && /No street-level imagery/.test(street.error || ''))
|
||
return skipped(`no ${name} photo near the line`);
|
||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||
assert.equal(street.providerId, id);
|
||
assert.equal(
|
||
await page.$eval('#sl-viewer-wrap', (node) => node.hidden),
|
||
false,
|
||
);
|
||
if (fixtures)
|
||
check(street, line.id.slice(linePrefix.length).split('~')[0]);
|
||
await press(page, '#sl-viewer-close');
|
||
await uiUntil(
|
||
page,
|
||
(u) => !u.street.open && u.sequence.selectedId === null,
|
||
);
|
||
assert.equal(
|
||
await page.$eval('#sl-viewer-wrap', (node) => node.hidden),
|
||
true,
|
||
);
|
||
},
|
||
);
|
||
let page = null;
|
||
|
||
try {
|
||
const fixture = newFixture();
|
||
page = await open('main', fixtures && fixture);
|
||
const { configured } = await page.evaluate(() =>
|
||
fetch('/api/mapillary/status').then((res) => res.json()),
|
||
);
|
||
if (fixtures)
|
||
assert.equal(
|
||
configured,
|
||
true,
|
||
'fixture runs need a server started with MAPILLARY_CLIENT_TOKEN (CI uses a dummy one)',
|
||
);
|
||
|
||
await expandPanel(page);
|
||
if (!configured) {
|
||
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
||
assertKeyRequired(page),
|
||
);
|
||
skip('no Mapillary key', 'the keyed steps');
|
||
} else {
|
||
await park(page);
|
||
await step(
|
||
'enabling draws coverage and the Mapillary credit',
|
||
async () => {
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||
origin: 'user',
|
||
}),
|
||
);
|
||
await waitForCoverage(page);
|
||
await statusIs(page, 'ON');
|
||
assert.equal(
|
||
await page.$eval('#sl-controls', (node) => node.disabled),
|
||
false,
|
||
);
|
||
await page.waitForFunction(
|
||
() =>
|
||
document.body.innerHTML.includes('Mapillary</a> contributors'),
|
||
{ timeout: 15_000 },
|
||
);
|
||
},
|
||
);
|
||
|
||
await fixtureStep(
|
||
'a rejected key reads KEY REJECTED and clears when switched off',
|
||
async () => {
|
||
fixture.rejected = true;
|
||
await press(page, '#sl-status');
|
||
await statusIs(page, 'OFF');
|
||
await press(page, '#sl-status');
|
||
await statusIs(page, 'KEY REJECTED');
|
||
const shown = await page.evaluate(() => ({
|
||
error: document.getElementById('sl-error').textContent,
|
||
hidden: document.getElementById('sl-error').hidden,
|
||
disabled: document.getElementById('sl-controls').disabled,
|
||
}));
|
||
assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/);
|
||
assert.equal(shown.hidden, false);
|
||
assert.equal(shown.disabled, true);
|
||
await press(page, '#sl-status');
|
||
await statusIs(page, 'OFF');
|
||
assert.equal(
|
||
await page.$eval(
|
||
'#sl-error',
|
||
(node) => node.hidden || !node.textContent,
|
||
),
|
||
true,
|
||
'no stale error while off',
|
||
);
|
||
fixture.rejected = false;
|
||
await press(page, '#sl-status');
|
||
await waitForCoverage(page);
|
||
await statusIs(page, 'ON');
|
||
},
|
||
);
|
||
|
||
await park(page);
|
||
await waitForCoverage(page);
|
||
await step(
|
||
'a line selects its sequence, and a cone opens its photo with a caption',
|
||
async () => {
|
||
// Fixtures answer lookups for the photo sequence only.
|
||
const line = await findPickUntil(
|
||
page,
|
||
fixtures ? `mly:seq:${PHOTO_SEQUENCE_ID}` : 'mly:seq:',
|
||
);
|
||
assert.ok(line, 'a sequence line across the middle of the view');
|
||
await page.mouse.click(line.x, line.y);
|
||
await uiUntil(
|
||
page,
|
||
(u) => u.sequence.selectedId && !u.sequence.loading,
|
||
);
|
||
// Cones clamp to the ground: on a slow GPU (CI's SwiftShader) they
|
||
// can take a few frames after the selection to become pickable.
|
||
const cone = await findPickUntil(page, 'mly:img:', {
|
||
box: {
|
||
x: Math.max(0, line.local.x - 150),
|
||
y: Math.max(0, line.local.y - 150),
|
||
w: 300,
|
||
h: 300,
|
||
},
|
||
step: 8,
|
||
});
|
||
assert.ok(cone, 'an image cone near the selected line');
|
||
await page.mouse.click(cone.x, cone.y);
|
||
await uiUntil(
|
||
page,
|
||
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
|
||
null,
|
||
90_000,
|
||
);
|
||
const { street } = await getUI(page);
|
||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||
await page.waitForSelector('#sl-viewer .mapillary-dom', {
|
||
timeout: 60_000,
|
||
});
|
||
await page.waitForFunction(
|
||
() =>
|
||
document.getElementById('sl-image-when').textContent.trim()
|
||
.length > 0,
|
||
{ timeout: 30_000 },
|
||
);
|
||
const view = await page.evaluate(() => ({
|
||
hidden: document.getElementById('sl-viewer-wrap').hidden,
|
||
width: document.getElementById('sl-viewer').getBoundingClientRect()
|
||
.width,
|
||
link: document.getElementById('sl-image-link').href,
|
||
}));
|
||
assert.equal(view.hidden, false);
|
||
assert.ok(view.width > 200, `viewer width ${view.width}`);
|
||
assert.match(view.link, /mapillary\.com\/app\/\?pKey=/);
|
||
if (fixtures) {
|
||
assert.ok(
|
||
fixture.images.some((image) => image.id === street.imageId),
|
||
`fixture photo ${street.imageId}`,
|
||
);
|
||
assert.equal(street.sequenceId, PHOTO_SEQUENCE_ID);
|
||
}
|
||
// Opening selects the photo's sequence, so its cones are drawn.
|
||
await uiUntil(
|
||
page,
|
||
(u) => u.sequence.selectedId === u.street.sequenceId,
|
||
);
|
||
},
|
||
);
|
||
|
||
await step('× closes the photo and deselects its sequence', async () => {
|
||
await press(page, '#sl-viewer-close');
|
||
await uiUntil(
|
||
page,
|
||
(u) => !u.street.open && u.sequence.selectedId === null,
|
||
);
|
||
assert.equal(
|
||
await page.$eval('#sl-viewer-wrap', (node) => node.hidden),
|
||
true,
|
||
);
|
||
});
|
||
|
||
await keylessProviderStep({
|
||
id: 'kartaview',
|
||
name: 'KartaView',
|
||
at: PARK,
|
||
linePrefix: 'kv:line:',
|
||
// A row across the middle crosses the drive, west of the old street.
|
||
box: null,
|
||
reset: resetKartaViewFixture,
|
||
check: (street) => {
|
||
assert.equal(street.sequenceId, KV_SEQUENCE_ID);
|
||
assert.ok(
|
||
kartaViewPhotos().some((photo) => photo.id === street.imageId),
|
||
`fixture photo ${street.imageId}`,
|
||
);
|
||
},
|
||
});
|
||
await keylessProviderStep({
|
||
id: 'panoramax',
|
||
name: 'Panoramax',
|
||
at: PNX_PARK,
|
||
linePrefix: 'pnx:seq:',
|
||
// The street runs east-west: scan a column left of the middle.
|
||
box: 'column',
|
||
reset: resetPanoramaxFixture,
|
||
check: (street, lineId) => {
|
||
assert.equal(street.sequenceId, lineId, 'the line clicked');
|
||
assert.ok(
|
||
panoramaxPictures().some((photo) => photo.id === street.imageId),
|
||
`fixture picture ${street.imageId}`,
|
||
);
|
||
},
|
||
});
|
||
// Back to Mapillary, where the next page and a reload expect it.
|
||
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
|
||
await uiUntil(page, litAlone, 'mapillary');
|
||
await park(page);
|
||
|
||
await fixtureStep(
|
||
'keyless server (second page): KEY REQUIRED gates the controls',
|
||
async () => {
|
||
const keyless = await open(
|
||
'keyless',
|
||
newFixture({ configured: false }),
|
||
);
|
||
await expandPanel(keyless);
|
||
await assertKeyRequired(keyless);
|
||
await collectRenderErrors(keyless, 'keyless');
|
||
await keyless.close();
|
||
},
|
||
);
|
||
|
||
await fixtureStep(
|
||
'every Mapillary, KartaView and Panoramax request was answered from a fixture',
|
||
() => {
|
||
console.log(` (answered: ${[...fixture.calls].sort().join(' | ')})`);
|
||
assert.deepEqual(fixture.unknown, []);
|
||
},
|
||
);
|
||
}
|
||
|
||
await step('no page errors and no Cesium render-loop errors', async () => {
|
||
await collectRenderErrors(page, 'main');
|
||
assert.deepEqual(errors, []);
|
||
});
|
||
if (strict)
|
||
await step('strict: no skips other than "fixtures only"', () =>
|
||
assert.deepEqual(
|
||
skips.filter((reason) => reason !== 'fixtures only'),
|
||
[],
|
||
),
|
||
);
|
||
console.log(
|
||
`${count} steps: ${count - skips.length} ok, ${skips.length} skipped`,
|
||
);
|
||
} catch (error) {
|
||
const dir = await saveFailureArtifacts({
|
||
dir: 'qa-artifacts/street-level',
|
||
browser,
|
||
monitors,
|
||
error,
|
||
}).catch(() => null);
|
||
if (dir) console.error(`failure evidence saved in ${dir}/`);
|
||
throw error;
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
}
|
||
|
||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||
main().catch((error) => {
|
||
console.error(error);
|
||
process.exit(1);
|
||
});
|
||
}
|