Files
gods-eye-view/scripts/qa-gbfs-names.mjs
T

607 lines
20 KiB
JavaScript

#!/usr/bin/env node
/**
* Keyless station-name regression against the real globe and bike-share tool.
* Run with a dev or QA preview server and an explicitly assigned --url.
* GBFS documents are synthetic; no operator feed or provider key is required.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
import { watchW1Page, redact } from './qa-browserEvidence.mjs';
const urlIndex = process.argv.indexOf('--url');
if (urlIndex < 0) throw new Error('An explicit --url is required');
const appUrl = process.argv[urlIndex + 1];
const engineName = new URL(appUrl).searchParams.get('engine') || 'cesium';
const extended = process.argv.includes('--w2');
const reportIndex = process.argv.indexOf('--report');
const reportPath =
reportIndex < 0 ? null : path.resolve(process.argv[reportIndex + 1]);
const shots = reportPath
? reportPath.replace(/\.json$/, '-shots')
: path.resolve(`qa-shots/gbfs-names/${engineName}`);
fs.mkdirSync(shots, { recursive: true });
const report = { engine: engineName, checks: [], poses: {}, browserErrors: [] };
const information = {
version: '3.0',
data: {
stations: [
{
station_id: 'english',
name: [
{ language: 'hu', text: 'Magyar tér' },
{ language: 'en', text: 'QA English Square' },
],
},
{
station_id: 'short',
name: [null, { language: 'en', text: ' ' }],
short_name: [{ language: 'en-GB', text: 'QA Short Name' }],
},
{ station_id: 'unnamed', name: [null, {}, { language: 42, text: [] }] },
{ station_id: 'legacy', name: ' QA Legacy Station ' },
{
station_id: 'fallback',
name: [null, { language: 'hu', text: 'QA Magyar Station' }],
},
].map((station, index) => ({
...station,
lat: 30.2681 + index * 0.001,
lon: -97.7469 + index * 0.001,
capacity: 13,
})),
},
};
const status = {
data: {
stations: information.data.stations.map(({ station_id }) => ({
station_id,
num_bikes_available: 4,
num_docks_available: 9,
is_renting: true,
is_returning: true,
})),
},
};
const browser = await puppeteer.launch({
headless: 'new',
...(process.env.PUPPETEER_EXECUTABLE_PATH
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
: {}),
args:
process.platform === 'darwin'
? ['--use-angle=metal']
: ['--use-gl=angle', '--use-angle=swiftshader'],
});
let checks = 0;
const check = (name, actual, expected) => {
report.checks.push({ name, actual, expected });
assert.deepEqual(actual, expected, name);
checks++;
console.log(`[PASS] ${name}`);
};
async function open(mode = 'normal') {
const page = await browser.newPage();
await page.bringToFront();
const errors = [];
if (extended)
watchW1Page(page, report, {
name: mode,
baseUrl: appUrl,
preview: process.argv.includes('--preview'),
});
page.on('pageerror', (error) => errors.push(error.message));
await page.setViewport({ width: 1440, height: 900 });
await page.evaluateOnNewDocument(
({ information, status, mode }) => {
localStorage.clear();
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.__qaPainted = [];
const paint = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function (text, ...args) {
if (
String(text).startsWith('QA ') ||
String(text) === 'Station unnamed'
) {
window.__qaPainted.push(String(text));
if (window.__qaPainted.length > 100) window.__qaPainted.shift();
}
return paint.call(this, text, ...args);
};
const original = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url = String(
typeof input === 'string' || input instanceof URL ? input : input.url,
);
if (url.includes('/api/gbfs/')) {
if (mode === 'failure') return new Response('{}', { status: 503 });
const payload =
mode === 'empty'
? { data: { stations: [] } }
: decodeURIComponent(url).includes('station_information')
? information
: status;
return new Response(JSON.stringify(payload), {
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/flights')) return new Response('{"states":[]}');
if (url.includes('/api/military')) return new Response('{"ac":[]}');
if (url.includes('/api/vessels'))
return new Response('{"connected":false,"vessels":[]}');
return original(input, init);
};
},
{ information, status, mode },
);
await page.goto(appUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__godsEyeView?.dataManager && window.__godsEyeView?.voiceCommands,
{ timeout: 60000 },
);
await page.waitForFunction(
() =>
!document.getElementById('loading-screen') ||
(document.getElementById('loading-screen').classList.contains('hidden') &&
getComputedStyle(document.getElementById('loading-screen')).opacity ===
'0'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const { dataManager, engine } = window.__godsEyeView;
await window.__godsEyeView.styleManager.initialRestorePromise;
window.__godsEyeView.styleManager.orbitController.stop();
for (const [id, layer] of dataManager.layers) {
if (layer.enabled)
await dataManager.setEnabled(id, false, { origin: 'user' });
}
const geo = await import('/src/geo/index.js');
engine.camera.cancelFlight();
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
window.__godsEyeView.requestRender('gbfs-name-qa');
});
return { page, errors };
}
async function nextFrame(page) {
await page.evaluate(
() =>
new Promise((resolve) => {
const engine = window.__godsEyeView.engine;
const remove = engine.frames.postRender.addEventListener(() => {
remove();
resolve();
});
engine.render.request('gbfs-raster-qa');
}),
);
}
async function settledTiles(page) {
await page.evaluate(async () => {
const engine = window.__godsEyeView.engine;
const start = performance.now();
let since = null;
while (performance.now() - start < 90000) {
if (engine.surface.tilesLoaded()) since ??= performance.now();
else since = null;
if (since !== null && performance.now() - since > 1500) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error('Map tiles did not settle for raster sampling');
});
}
async function w2Checks(page) {
await page.evaluate(async () => {
const app = window.__godsEyeView;
const detection = await import('/src/data/detection.js');
detection.setMode('OFF');
const { appClock } = await import('/src/time/clock.js');
appClock.freeze(Date.parse('2026-10-28T19:00:00Z'));
const canvas = app.engine.host.canvas;
const style = document.createElement('style');
style.textContent =
'.gbfs-raster-hidden { visibility: hidden !important; }';
document.head.append(style);
for (const element of document.body.querySelectorAll('*'))
if (element !== canvas && !element.contains(canvas))
element.classList.add('gbfs-raster-hidden');
});
for (const [name, height] of [
['city', 10000],
['close', 3000],
['global', 100000],
]) {
await page.evaluate(async (height) => {
const app = window.__godsEyeView;
await app.dataManager.setEnabled('bikeshare', false);
const geo = await import('/src/geo/index.js');
app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, height),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}, height);
await settledTiles(page);
await nextFrame(page);
const off = await page.screenshot({
path: path.join(shots, `${name}-off.png`),
});
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
);
await page.waitForFunction(
(count) =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().count === count,
{},
name === 'global' ? 0 : 5,
);
await nextFrame(page);
const controls = await page.evaluate(() => {
const app = window.__godsEyeView;
const rect = app.engine.host.canvas.getBoundingClientRect();
return app.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.map((record) => {
const p = app.engine.projector.worldToWindow(record.position);
return { id: record.sourceId, x: p.x + rect.left, y: p.y + rect.top };
});
});
const on = await page.screenshot({
path: path.join(shots, `${name}-on.png`),
});
const a = await sharp(off)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const b = await sharp(on).ensureAlpha().raw().toBuffer();
for (const control of controls) {
let pixels = 0;
for (
let y = Math.max(0, Math.floor(control.y) - 16);
y < Math.min(a.info.height, control.y + 16);
y++
)
for (
let x = Math.max(0, Math.floor(control.x) - 16);
x < Math.min(a.info.width, control.x + 16);
x++
) {
const i = (y * a.info.width + x) * 4;
if (
Math.max(
...[0, 1, 2].map((c) => Math.abs(a.data[i + c] - b[i + c])),
) > 3
)
pixels++;
}
control.changedPixels = pixels;
}
report.poses[name] = controls;
check(
`${name} expected station population`,
controls.length,
name === 'global' ? 0 : 5,
);
if (controls.length)
check(
`${name} five nonempty marker raster controls`,
controls.every((c) => c.changedPixels > 0),
true,
);
}
await page.evaluate(() => {
for (const element of document.querySelectorAll('.gbfs-raster-hidden'))
element.classList.remove('gbfs-raster-hidden');
});
await page.evaluate(async () => {
const app = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await app.dataManager.setEnabled('bikeshare', false);
});
for (let i = 0; i < 10; i++) {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
);
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().count === 5,
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', false),
);
}
check(
'ten cycles clear all records',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().count,
),
0,
);
const idle = await page.evaluate(async () => {
const app = window.__godsEyeView;
let frames = 0,
last = performance.now();
const remove = app.engine.frames.postRender.addEventListener(() => {
frames++;
last = performance.now();
});
const start = performance.now();
while (
performance.now() - start < 60000 &&
(performance.now() - last < 2000 || !app.engine.surface.tilesLoaded())
)
await new Promise((resolve) => setTimeout(resolve, 100));
const ready =
performance.now() - last >= 2000 && app.engine.surface.tilesLoaded();
frames = 0;
await new Promise((resolve) => setTimeout(resolve, 2000));
remove();
return { ready, frames, governor: app.getRenderGovernorDiagnostics() };
});
report.idle = idle;
check('camera and tiles settle before idle sample', idle.ready, true);
check(
'disabled static scene renders zero frames over two seconds',
idle.frames,
0,
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
);
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
.count === 5,
);
}
try {
const { page, errors } = await open();
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
.count === 5,
{ timeout: 15000 },
);
await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
const rendered = new Promise((resolve) => {
const remove = engine.frames.postRender.addEventListener(() => {
remove();
resolve();
});
});
window.__godsEyeView.requestRender('gbfs-name-pick-qa');
await rendered;
});
const records = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getAnalystRecords(),
);
check(
'the layer retains all five stations and normalized names',
records.map((r) => r.name),
[
'QA English Square',
'QA Short Name',
null,
'QA Legacy Station',
'QA Magyar Station',
],
);
const tool = await page.evaluate(() =>
window.__godsEyeView.voiceCommands.runner('get_bike_share', {
area: { lat: 30.2701, lon: -97.7449, radius_km: 2 },
}),
);
check('the real bike-share tool succeeds', tool.ok, true);
check(
'the tool preserves localized, short, unnamed, legacy and non-English names',
tool.data.rows.map((r) => r.name).sort(),
[
null,
'QA English Square',
'QA Short Name',
'QA Legacy Station',
'QA Magyar Station',
].sort(),
);
check(
'availability remains attached to every station',
tool.data.rows.every(
(r) => r.bikes_available === 4 && r.docks_available === 9,
),
true,
);
if (extended) await w2Checks(page);
const clickPoint = await page.evaluate(() => {
window.__qaPainted = [];
const { engine } = window.__godsEyeView;
const station = window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.find(({ sourceId }) => sourceId === 'austin-capmetro:english');
const point = engine.projector.worldToWindow(station.position);
const rect = engine.host.canvas.getBoundingClientRect();
const picked = engine.picker.pick(point);
return {
x: rect.left + point.x,
y: rect.top + point.y,
pickedId: typeof picked?.id === 'string' ? picked.id : null,
primitiveId:
typeof picked?.primitive?.id === 'string' ? picked.primitive.id : null,
};
});
check(
'the scene pick resolves the first stable station key',
clickPoint.pickedId || clickPoint.primitiveId,
'austin-capmetro:english',
);
await page.mouse.click(clickPoint.x, clickPoint.y);
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.some((record) => record.skipLabel),
{ timeout: 10000 },
);
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
);
check(
'a real pointer click selects the station with its localized name',
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
true,
);
await page.keyboard.press('Escape');
for (const [id, title] of [
['english', 'QA English Square'],
['short', 'QA Short Name'],
['unnamed', 'Station unnamed'],
]) {
await page.evaluate((id) => {
window.__qaPainted = [];
const app = window.__godsEyeView;
app.dataManager.layers
.get('bikeshare')
.module._selectBikeshareStationForTest(`austin-capmetro:${id}`);
app.requestRender('gbfs-name-selection-qa');
}, id);
await page.waitForFunction(
(title) => window.__qaPainted.includes(title),
{ timeout: 10000 },
title,
);
check(
`the selected ${id} card paints the expected title`,
await page.evaluate((title) => window.__qaPainted.includes(title), title),
true,
);
await page.screenshot({ path: path.join(shots, `${id}.png`) });
await page.keyboard.press('Escape');
await page.waitForFunction(() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.every((record) => !record.skipLabel),
);
check(
`Escape clears the ${id} card`,
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.filter((record) => record.skipLabel).length,
),
0,
);
}
await page.setViewport({ width: 390, height: 844 });
await page.evaluate(async () => {
window.__qaPainted = [];
const app = window.__godsEyeView;
// Use the supported clean view to inspect the mobile card clear of panels.
app.styleManager.toggleCleanView(true);
const geo = await import('/src/geo/index.js');
app.engine.camera.cancelFlight();
app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
app.dataManager.layers
.get('bikeshare')
.module._selectBikeshareStationForTest('austin-capmetro:english');
app.requestRender('gbfs-name-mobile-qa');
});
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
);
check(
'the mobile selected card paints its normalized name',
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
true,
);
await page.screenshot({ path: path.join(shots, 'mobile.png') });
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', false, {
origin: 'user',
}),
);
check(
'disabling clears selected cards and analyst records',
await page.evaluate(() => ({
count: window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getAnalystRecords().length,
selected: window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.filter((record) => record.skipLabel).length,
})),
{ count: 0, selected: 0 },
);
check('normal fixtures produce no page exceptions', errors, []);
await page.close();
for (const mode of ['empty', 'failure']) {
const { page, errors } = await open(mode);
await page.waitForFunction(
() =>
!window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().loading,
);
check(
`${mode} leaves no rendered stations`,
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().count,
),
0,
);
check(`${mode} produces no page exceptions`, errors, []);
await page.close();
}
if (extended)
check(
'all pages have no unexpected runtime errors',
report.browserErrors,
[],
);
report.pass = true;
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
} catch (error) {
report.pass = false;
report.failure = redact(error.message);
throw error;
} finally {
if (reportPath)
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n');
await browser.close();
}