mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
732 lines
24 KiB
JavaScript
732 lines
24 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 request and the coverage
|
||
* tile route from fixtures, so nothing reaches Mapillary; the server still
|
||
* needs a (dummy) 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';
|
||
|
||
const VIEWPORT = { width: 1440, height: 900 };
|
||
const PHONE = { width: 390, height: 844 };
|
||
/** 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
|
||
*/
|
||
export 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 });
|
||
}
|
||
|
||
/**
|
||
* Answer the page's Mapillary 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/*' },
|
||
],
|
||
});
|
||
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;
|
||
}
|
||
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 },
|
||
);
|
||
|
||
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 } };
|
||
}
|
||
return null;
|
||
},
|
||
prefix,
|
||
box,
|
||
step,
|
||
);
|
||
|
||
/** Park the camera over the photo line; a late startup flight can move it. */
|
||
async function park(page) {
|
||
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
|
||
);
|
||
}, PARK);
|
||
if (stayed) return;
|
||
}
|
||
}
|
||
|
||
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 step = async (label, fn) => {
|
||
await fn();
|
||
console.log(`ok ${++count} ${label}`);
|
||
};
|
||
const skip = (reason, label) => {
|
||
skips.push(reason);
|
||
console.log(`skip (${reason}) ${++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}`,
|
||
),
|
||
);
|
||
|
||
try {
|
||
const fixture = newFixture();
|
||
const 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 step('the panel starts as a collapsed right-rail strip', async () => {
|
||
const box = await page.evaluate(() => {
|
||
const panel = document.getElementById('street-level-panel');
|
||
const r = panel.getBoundingClientRect();
|
||
return {
|
||
collapsed: panel.classList.contains('collapsed'),
|
||
inRail: panel.parentElement.id === 'right-context-rail',
|
||
body: getComputedStyle(document.getElementById('sl-body')).display,
|
||
width: r.width,
|
||
right: r.right,
|
||
};
|
||
});
|
||
assert.equal(box.collapsed, true);
|
||
assert.equal(box.inRail, true);
|
||
assert.equal(box.body, 'none');
|
||
assert.ok(box.width <= 200 && box.right <= VIEWPORT.width);
|
||
});
|
||
|
||
await step('expanding the strip shows the body and legend', async () => {
|
||
await expandPanel(page);
|
||
assert.equal(
|
||
await page.$$eval('#sl-legend li', (items) => items.length),
|
||
2,
|
||
'Mapillary and Selected',
|
||
);
|
||
});
|
||
|
||
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 step('the header pill switches the layer off and on', async () => {
|
||
await press(page, '#sl-status');
|
||
await statusIs(page, 'OFF');
|
||
assert.equal(
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.dataManager.isEnabled('street-level'),
|
||
),
|
||
false,
|
||
);
|
||
assert.equal((await getUI(page)).coverage.count, 0);
|
||
await press(page, '#sl-status');
|
||
await waitForCoverage(page);
|
||
await statusIs(page, 'ON');
|
||
});
|
||
|
||
await step('the 360° filter narrows coverage', async () => {
|
||
const before = (await getUI(page)).coverage.count;
|
||
await press(page, '[data-sl-pano="pano"]');
|
||
await uiUntil(
|
||
page,
|
||
(u) => u.filter.pano === 'pano' && !u.coverage.loading,
|
||
);
|
||
const after = (await getUI(page)).coverage.count;
|
||
// Fixtures hold flat sequences too, so the filter must drop some.
|
||
assert.ok(
|
||
fixtures ? after < before : after <= before,
|
||
`${after} vs ${before}`,
|
||
);
|
||
await press(page, '[data-sl-pano="all"]');
|
||
await uiUntil(
|
||
page,
|
||
(u, n) => u.filter.pano === 'all' && u.coverage.count === n,
|
||
before,
|
||
);
|
||
});
|
||
|
||
await step(
|
||
'the SINCE slider narrows coverage and names its cut-off',
|
||
async () => {
|
||
const before = (await getUI(page)).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(
|
||
page,
|
||
(u) => u.filter.sinceDays === 365 && !u.coverage.loading,
|
||
);
|
||
const after = (await getUI(page)).coverage.count;
|
||
assert.ok(
|
||
fixtures ? after < before : after <= before,
|
||
`${after} vs ${before}`,
|
||
);
|
||
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(
|
||
page,
|
||
(u, n) => u.filter.sinceDays === 0 && u.coverage.count === n,
|
||
before,
|
||
);
|
||
},
|
||
);
|
||
|
||
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');
|
||
},
|
||
);
|
||
|
||
let imageId = null;
|
||
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 findPick(
|
||
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,
|
||
);
|
||
const cone = await findPick(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}`);
|
||
imageId = street.imageId;
|
||
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 === imageId),
|
||
`fixture photo ${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 step('on a phone the whole photo fits in the panel', async () => {
|
||
// Width alone drives the phone layout; toggling isMobile would reload.
|
||
await page.setViewport(PHONE);
|
||
await page.waitForFunction((w) => innerWidth === w, {}, PHONE.width);
|
||
await expandPanel(page);
|
||
await page.evaluate((id) => {
|
||
void window.__godsEyeView.dataManager.layers
|
||
.get('street-level')
|
||
.module.openImage(id);
|
||
}, imageId);
|
||
await uiUntil(
|
||
page,
|
||
(u, id) => u.street.imageId === id && !u.street.loading,
|
||
imageId,
|
||
60_000,
|
||
);
|
||
// The panel settles its phone layout over a frame or two.
|
||
const fits = () => {
|
||
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),
|
||
height: Math.round(viewer.height),
|
||
overflowX: document.documentElement.scrollWidth > innerWidth,
|
||
};
|
||
};
|
||
await page
|
||
.waitForFunction(
|
||
`(${fits})().cut <= 1 && (${fits})().height >= 100`,
|
||
{ timeout: 10_000 },
|
||
)
|
||
.catch(() => {});
|
||
const fit = await page.evaluate(fits);
|
||
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(VIEWPORT);
|
||
});
|
||
|
||
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 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);
|
||
});
|
||
}
|