mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge remote-tracking branch 'origin/main' into fix/realtime-debug-log-hardening
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
4fd168ff3c
332 files changed
+56273
-6359
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import test from 'node:test';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdtemp,
|
||||
@@ -93,3 +94,92 @@ test(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('runtime discovery includes new nested modules and respects Git and formatter exclusions', async (t) => {
|
||||
const root = await fixture(t);
|
||||
execFileSync('git', ['init', '-q'], { cwd: root });
|
||||
await mkdir(path.join(root, 'src/nested'), { recursive: true });
|
||||
await mkdir(path.join(root, 'vendor'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(root, 'scripts/format-runtime.json'),
|
||||
JSON.stringify({ roots: ['src'] }),
|
||||
);
|
||||
await writeFile(path.join(root, '.gitignore'), 'src/ignored.js\n');
|
||||
await writeFile(path.join(root, '.prettierignore'), 'src/generated.js\n');
|
||||
const source = 'export const value="yes"';
|
||||
for (const name of [
|
||||
'src/nested/new.js',
|
||||
'src/module.mjs',
|
||||
'src/check.test.mjs',
|
||||
'src/ignored.js',
|
||||
'src/generated.js',
|
||||
'vendor/other.js',
|
||||
])
|
||||
await writeFile(path.join(root, name), source);
|
||||
const expected = ['adopted.js', 'src/module.mjs', 'src/nested/new.js'];
|
||||
assert.deepEqual(
|
||||
(await formatAdoptedFiles(root, '--check')).changed,
|
||||
expected,
|
||||
);
|
||||
await formatAdoptedFiles(root, '--write');
|
||||
assert.deepEqual((await formatAdoptedFiles(root, '--check')).changed, []);
|
||||
for (const name of [
|
||||
'src/check.test.mjs',
|
||||
'src/ignored.js',
|
||||
'src/generated.js',
|
||||
'vendor/other.js',
|
||||
])
|
||||
assert.equal(await readFile(path.join(root, name), 'utf8'), source);
|
||||
});
|
||||
|
||||
test('runtime scope rejects unsafe roots before formatting existing entries', async (t) => {
|
||||
for (const roots of [
|
||||
[],
|
||||
['src', 'src'],
|
||||
['../outside'],
|
||||
['/absolute'],
|
||||
['.'],
|
||||
['src/*'],
|
||||
]) {
|
||||
const root = await fixture(t);
|
||||
await writeFile(
|
||||
path.join(root, 'scripts/format-runtime.json'),
|
||||
JSON.stringify({ roots }),
|
||||
);
|
||||
await assert.rejects(
|
||||
formatAdoptedFiles(root, '--write'),
|
||||
/Runtime formatting roots/,
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(path.join(root, 'adopted.js'), 'utf8'),
|
||||
'const value="yes"\r\n',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'discovered runtime symlinks cannot escape the repository',
|
||||
{ skip: process.platform === 'win32' },
|
||||
async (t) => {
|
||||
const root = await fixture(t);
|
||||
const other = await fixture(t);
|
||||
execFileSync('git', ['init', '-q'], { cwd: root });
|
||||
await mkdir(path.join(root, 'src'));
|
||||
await writeFile(
|
||||
path.join(root, 'scripts/format-runtime.json'),
|
||||
JSON.stringify({ roots: ['src'] }),
|
||||
);
|
||||
await symlink(
|
||||
path.join(other, 'adopted.js'),
|
||||
path.join(root, 'src/outside.js'),
|
||||
);
|
||||
await assert.rejects(
|
||||
formatAdoptedFiles(root, '--write'),
|
||||
/repository files/,
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(path.join(root, 'adopted.js'), 'utf8'),
|
||||
'const value="yes"\r\n',
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
} from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { analyzeModule } from '../../scripts/module-analysis.mjs';
|
||||
import { checkImportDirections } from '../../scripts/check-import-directions.mjs';
|
||||
|
||||
function fixture(t, exports = {}) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'gev-directions-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
execFileSync('git', ['init', '--quiet'], { cwd: root });
|
||||
const write = (name, source = '') => {
|
||||
mkdirSync(path.dirname(path.join(root, name)), { recursive: true });
|
||||
writeFileSync(path.join(root, name), source);
|
||||
};
|
||||
write(
|
||||
'package.json',
|
||||
JSON.stringify({ name: '@gods-eye-view/core', exports }),
|
||||
);
|
||||
write('src/data/feedState.js');
|
||||
return { root, write };
|
||||
}
|
||||
|
||||
test('module analysis parses dependencies and platform references without reading comments or property labels', () => {
|
||||
assert.deepEqual(
|
||||
analyzeModule(
|
||||
"// import('node:fs'); document\nimport x from 'one'; export { x } from 'two'; export * from 'three'; import('four'); const data = { document: 1 }; data.window;",
|
||||
),
|
||||
{ imports: ['one', 'two', 'three', 'four'], browser: [] },
|
||||
);
|
||||
assert.deepEqual(
|
||||
analyzeModule(
|
||||
"const data = { element: document }; globalThis['navigator'];",
|
||||
).browser,
|
||||
['document', 'navigator'],
|
||||
);
|
||||
assert.throws(() => analyzeModule('import(name)'), /Computed/);
|
||||
assert.throws(() => analyzeModule("require('node:fs')"), /ES imports/);
|
||||
});
|
||||
|
||||
test('portable sources can share records, and standalone can assemble rendering', (t) => {
|
||||
const { root, write } = fixture(t, {
|
||||
'./layers/demo/source': './src/layers/demo/source.js',
|
||||
});
|
||||
write('src/layers/demo/source.js', "export { read } from './records.js';");
|
||||
write('src/layers/demo/records.js', 'export const read = (value) => value;');
|
||||
write('src/standalone/catalog.js', "import '../layers/demo/index.js';");
|
||||
write('src/layers/demo/index.js', "import * as Cesium from 'cesium';");
|
||||
assert.equal(checkImportDirections(root).modules, 5);
|
||||
});
|
||||
|
||||
for (const [name, files, pattern] of [
|
||||
[
|
||||
'unreachable source imports rendering',
|
||||
{
|
||||
'src/layers/demo/source.js': "import './rendering.js';",
|
||||
'src/layers/demo/rendering.js': '',
|
||||
},
|
||||
/Source imports rendering/,
|
||||
],
|
||||
[
|
||||
'source reaches a platform through a record helper',
|
||||
{
|
||||
'src/layers/demo/source.js': "import './records.js';",
|
||||
'src/layers/demo/records.js': "import 'cesium';",
|
||||
},
|
||||
/reaches platform\/rendering/,
|
||||
],
|
||||
[
|
||||
'source reaches a DOM helper',
|
||||
{
|
||||
'src/sources/demo.js': "import '../data/helper.js';",
|
||||
'src/data/helper.js': 'export const view = () => globalThis.document;',
|
||||
},
|
||||
/reaches browser globals/,
|
||||
],
|
||||
[
|
||||
'browser imports Node without prefix',
|
||||
{ 'src/unused.mjs': "import 'fs/promises';" },
|
||||
/Node builtin/,
|
||||
],
|
||||
[
|
||||
'browser hides Node outside src',
|
||||
{
|
||||
'src/unused.js': "import '../shared/helper.js';",
|
||||
'shared/helper.js': "export * from 'node:fs';",
|
||||
},
|
||||
/Browser graph reaches Node/,
|
||||
],
|
||||
[
|
||||
'browser imports server',
|
||||
{
|
||||
'src/data/demo.js': "import '../../server/helper.js';",
|
||||
'server/helper.js': '',
|
||||
},
|
||||
/Browser imports server/,
|
||||
],
|
||||
[
|
||||
'reusable module selects standalone',
|
||||
{
|
||||
'src/app/demo.js': "import '../standalone/catalog.js';",
|
||||
'src/standalone/catalog.js': '',
|
||||
},
|
||||
/Reusable module imports standalone/,
|
||||
],
|
||||
[
|
||||
'provider selects application setup',
|
||||
{
|
||||
'server/providers/demo.js': "import '../standalone/key-setup.js';",
|
||||
'server/standalone/key-setup.js': '',
|
||||
},
|
||||
/Provider imports application/,
|
||||
],
|
||||
[
|
||||
'voice controls reach protocol indirectly',
|
||||
{
|
||||
'src/voice/sessionCommands.js': "import './helper.js';",
|
||||
'src/voice/helper.js': "import './realtimeSession.js';",
|
||||
'src/voice/realtimeSession.js': '',
|
||||
},
|
||||
/Common voice controls import protocol/,
|
||||
],
|
||||
[
|
||||
'dynamic import cannot evade direction',
|
||||
{ 'src/app/demo.js': "const target = './module.js'; import(target);" },
|
||||
/Computed/,
|
||||
],
|
||||
[
|
||||
'browser imports test helper',
|
||||
{
|
||||
'src/app/demo.js': "import '../testSupport/helper.js';",
|
||||
'src/testSupport/helper.js': '',
|
||||
},
|
||||
/non-runtime owner/,
|
||||
],
|
||||
]) {
|
||||
test(`rejects ${name}`, (t) => {
|
||||
const { root, write } = fixture(t);
|
||||
for (const [file, body] of Object.entries(files)) write(file, body);
|
||||
assert.throws(() => checkImportDirections(root), pattern);
|
||||
});
|
||||
}
|
||||
|
||||
test('self package imports cannot evade portable ownership', (t) => {
|
||||
const { root, write } = fixture(t, { './view': './src/ui/view.js' });
|
||||
write('src/sources/demo.js', "import '@gods-eye-view/core/view';");
|
||||
write('src/ui/view.js');
|
||||
assert.throws(() => checkImportDirections(root), /Source imports rendering/);
|
||||
});
|
||||
|
||||
test('compatibility exceptions are limited to the two existing composition entries', (t) => {
|
||||
const { root, write } = fixture(t);
|
||||
write('src/ui.js', "import './standalone/catalog.js';");
|
||||
write('src/standalone/catalog.js');
|
||||
write('server/providers/local.js', "import '../standalone/key-setup.js';");
|
||||
write('server/standalone/key-setup.js');
|
||||
assert.doesNotThrow(() => checkImportDirections(root));
|
||||
write('src/ui/new.js', "import '../standalone/catalog.js';");
|
||||
assert.throws(
|
||||
() => checkImportDirections(root),
|
||||
/Reusable module imports standalone/,
|
||||
);
|
||||
});
|
||||
|
||||
test('symlink aliases do not hide renderer ownership', (t) => {
|
||||
const { root, write } = fixture(t);
|
||||
write('src/sources/demo.js', "import './helper/view.js';");
|
||||
write('src/ui/view.js');
|
||||
// Directory junctions exercise the same resolved edge without Windows symlink privileges.
|
||||
symlinkSync(
|
||||
path.join(root, 'src/ui'),
|
||||
path.join(root, 'src/sources/helper'),
|
||||
'junction',
|
||||
);
|
||||
assert.throws(() => checkImportDirections(root), /rendering|symlinks/);
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
// THE LAST-RESORT PLACE SEARCH ROUTE — what it is allowed to send upstream.
|
||||
//
|
||||
// The public instance permits one request per second. One request per second
|
||||
// and an unbounded queue are incompatible: a burst of searches would keep the
|
||||
// upstream busy long after everyone who asked has given up. These cases pin the
|
||||
// queue bound, the shared answer for identical searches, the cancellation path,
|
||||
// and the chain order that only reaches here when nothing else answered.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
fetchNominatimSearch,
|
||||
geocodeProxy,
|
||||
NOMINATIM_MAX_PENDING,
|
||||
} from '../../server/providers/regional/place.js';
|
||||
import { fetchRegionalJson } from '../../server/providers/regional/http.js';
|
||||
import { localProviderPlugins } from '../../server/providers/local.js';
|
||||
import { createPlaceSearch, createGoogleGeocoder } from '../search/index.js';
|
||||
import { createPhotonGeocoder } from '../keylessGeocoder.js';
|
||||
|
||||
const HIT = [
|
||||
{
|
||||
lat: '43.1731',
|
||||
lon: '-79.0384',
|
||||
display_name: 'Niagara Falls, Ontario, Canada',
|
||||
name: 'Niagara Falls',
|
||||
addresstype: 'city',
|
||||
boundingbox: ['43.05', '43.15', '-79.15', '-79.0'],
|
||||
},
|
||||
];
|
||||
|
||||
/** Mount the geocode route the way the dev server does. */
|
||||
let clientCounter = 0;
|
||||
|
||||
function mountGeocode(
|
||||
install = (server) => geocodeProxy().configureServer(server),
|
||||
) {
|
||||
// A fresh client address per mount: the per-client rate limiter is module
|
||||
// state shared by every case in this file, and one case's burst must not
|
||||
// decide another case's outcome.
|
||||
clientCounter += 1;
|
||||
const remoteAddress = `10.0.0.${clientCounter % 250}`;
|
||||
const routes = new Map();
|
||||
install({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
return async function request(path) {
|
||||
const requested = new URL(path, 'http://localhost');
|
||||
const handler = routes.get('/api/geocode');
|
||||
if (!handler) throw new Error('no /api/geocode route');
|
||||
// connect strips the mount prefix before the handler sees the request.
|
||||
const rest =
|
||||
requested.pathname.slice('/api/geocode'.length) + requested.search;
|
||||
const req = {
|
||||
method: 'GET',
|
||||
url: rest[0] === '/' ? rest : `/${rest}`,
|
||||
headers: {},
|
||||
socket: { remoteAddress },
|
||||
on() {},
|
||||
};
|
||||
return await new Promise((resolve, reject) => {
|
||||
let status = 200;
|
||||
let headers = {};
|
||||
const res = {
|
||||
writableEnded: false,
|
||||
on() {},
|
||||
writeHead(code, sent) {
|
||||
status = code;
|
||||
headers = sent || {};
|
||||
return res;
|
||||
},
|
||||
end(body = '') {
|
||||
res.writableEnded = true;
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = body ? JSON.parse(String(body)) : null;
|
||||
} catch {
|
||||
parsed = String(body);
|
||||
}
|
||||
resolve({ status, headers, body: parsed });
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Answer the upstream from a stub, counting and optionally stalling calls. */
|
||||
async function withUpstream(respond, run) {
|
||||
const previous = globalThis.fetch;
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push({ url: String(url), options });
|
||||
return await respond(String(url), options, calls.length);
|
||||
};
|
||||
try {
|
||||
return await run(calls);
|
||||
} finally {
|
||||
globalThis.fetch = previous;
|
||||
}
|
||||
}
|
||||
|
||||
const jsonResponse = (value) =>
|
||||
new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
test('the route is part of the application on both dev and preview', () => {
|
||||
const plugin = localProviderPlugins().find(
|
||||
(entry) => entry.name === 'geocode-proxy',
|
||||
);
|
||||
assert.ok(plugin, 'the application must compose the place-search route');
|
||||
assert.equal(typeof plugin.configureServer, 'function');
|
||||
assert.equal(typeof plugin.configurePreviewServer, 'function');
|
||||
});
|
||||
|
||||
test('a search reaches the upstream identified, and the answer is cached', async () => {
|
||||
const request = mountGeocode();
|
||||
await withUpstream(
|
||||
async () => jsonResponse(HIT),
|
||||
async (calls) => {
|
||||
const query = `niagara-${Date.now()}`;
|
||||
const first = await request(`/api/geocode?q=${query}`);
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(first.body.status, 'OK');
|
||||
assert.deepEqual(first.body.results[0].geometry.location, {
|
||||
lat: 43.1731,
|
||||
lng: -79.0384,
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
const headers = calls[0].options.headers;
|
||||
assert.match(
|
||||
String(headers['User-Agent']),
|
||||
/gods-eye-view\/\d/,
|
||||
'the policy asks for a User-Agent identifying the application',
|
||||
);
|
||||
assert.ok(headers.Referer, 'a Referer is sent alongside it');
|
||||
assert.equal(
|
||||
calls[0].options.redirect,
|
||||
'error',
|
||||
'a fixed API endpoint may not be redirected elsewhere',
|
||||
);
|
||||
|
||||
// The policy asks that results be cached; a repeat costs no request.
|
||||
const repeat = await request(`/api/geocode?q=${query}`);
|
||||
assert.equal(repeat.status, 200);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
1,
|
||||
'the second identical search is served from cache',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('identical searches in flight share one upstream call', async () => {
|
||||
const request = mountGeocode();
|
||||
const query = `coalesce-${Date.now()}`;
|
||||
await withUpstream(
|
||||
async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
return jsonResponse(HIT);
|
||||
},
|
||||
async (calls) => {
|
||||
const answers = await Promise.all([
|
||||
request(`/api/geocode?q=${query}`),
|
||||
request(`/api/geocode?q=${query}`),
|
||||
request(`/api/geocode?q=${query}`),
|
||||
]);
|
||||
for (const answer of answers) assert.equal(answer.status, 200);
|
||||
assert.equal(calls.length, 1, 'one upstream call serves all three');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a burst past the queue bound is refused rather than queued for minutes', async () => {
|
||||
const request = mountGeocode();
|
||||
const stamp = Date.now();
|
||||
await withUpstream(
|
||||
async () => jsonResponse([]),
|
||||
async (calls) => {
|
||||
const answers = await Promise.all(
|
||||
Array.from({ length: 20 }, (_unused, index) =>
|
||||
request(`/api/geocode?q=burst-${stamp}-${index}`),
|
||||
),
|
||||
);
|
||||
const refused = answers.filter((answer) => answer.status === 429);
|
||||
assert.ok(
|
||||
refused.length > 0,
|
||||
'a burst this size must be refused somewhere',
|
||||
);
|
||||
for (const answer of refused)
|
||||
assert.equal(answer.headers['Retry-After'], '5');
|
||||
assert.ok(
|
||||
calls.length <= NOMINATIM_MAX_PENDING + 1,
|
||||
`the upstream saw ${calls.length} of 20 searches; the queue bound is ${NOMINATIM_MAX_PENDING}`,
|
||||
);
|
||||
assert.equal(
|
||||
answers.length,
|
||||
20,
|
||||
'every caller is answered, none is left hanging',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a query that is missing or too long never reaches the upstream', async () => {
|
||||
const request = mountGeocode();
|
||||
await withUpstream(
|
||||
async () => jsonResponse(HIT),
|
||||
async (calls) => {
|
||||
for (const path of [
|
||||
'/api/geocode',
|
||||
'/api/geocode?q=',
|
||||
'/api/geocode?q=%20%20',
|
||||
`/api/geocode?q=${'x'.repeat(201)}`,
|
||||
]) {
|
||||
const answer = await request(path);
|
||||
assert.equal(answer.status, 400, path);
|
||||
}
|
||||
assert.deepEqual(calls, []);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('an upstream failure is a service answer, not a crash', async () => {
|
||||
const request = mountGeocode();
|
||||
await withUpstream(
|
||||
async () => new Response('upstream down', { status: 502 }),
|
||||
async () => {
|
||||
const answer = await request(`/api/geocode?q=down-${Date.now()}`);
|
||||
assert.equal(answer.status, 503);
|
||||
assert.equal(answer.headers['Cache-Control'], 'no-store');
|
||||
assert.match(String(answer.body.error), /temporarily unavailable/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a search with no hit is a clean empty answer', async () => {
|
||||
const request = mountGeocode();
|
||||
await withUpstream(
|
||||
async () => jsonResponse([]),
|
||||
async () => {
|
||||
const answer = await request(`/api/geocode?q=nowhere-${Date.now()}`);
|
||||
assert.equal(answer.status, 200);
|
||||
assert.equal(answer.body.status, 'ZERO_RESULTS');
|
||||
assert.deepEqual(answer.body.results, []);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('the chain reaches this route only when Google and Photon do not answer', async () => {
|
||||
const asked = [];
|
||||
const request = mountGeocode();
|
||||
await withUpstream(
|
||||
async (url) => {
|
||||
if (url.includes('photon.komoot.io')) {
|
||||
asked.push('photon');
|
||||
return new Response('photon is down', { status: 503 });
|
||||
}
|
||||
asked.push('nominatim');
|
||||
return jsonResponse(HIT);
|
||||
},
|
||||
async () => {
|
||||
const search = createPlaceSearch({
|
||||
providers: [
|
||||
createGoogleGeocoder({
|
||||
request() {
|
||||
asked.push('google');
|
||||
// An unconfigured provider contributes no verdict.
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
createPhotonGeocoder({
|
||||
fetchImpl: (...args) => globalThis.fetch(...args),
|
||||
}),
|
||||
createGoogleGeocoder({
|
||||
request(query) {
|
||||
return request(
|
||||
`/api/geocode?q=${encodeURIComponent(query)}`,
|
||||
).then(
|
||||
(answer) =>
|
||||
new Response(JSON.stringify(answer.body), {
|
||||
status: answer.status,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const outcome = await search.geocode(`chain-${Date.now()}`);
|
||||
assert.deepEqual(
|
||||
asked,
|
||||
['google', 'photon', 'nominatim'],
|
||||
`Google first, then Photon, then this route (saw ${asked.join(' → ')})`,
|
||||
);
|
||||
assert.equal(outcome.place.lat, 43.1731);
|
||||
assert.equal(outcome.fallbackUsed, true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a search whose caller gave up is dropped before it costs a slot', async () => {
|
||||
const abandoned = new AbortController();
|
||||
abandoned.abort();
|
||||
await withUpstream(
|
||||
async () => jsonResponse(HIT),
|
||||
async (calls) => {
|
||||
await assert.rejects(
|
||||
fetchNominatimSearch(`cancelled-${Date.now()}`, null, {
|
||||
signal: abandoned.signal,
|
||||
}),
|
||||
(error) => error.code === 'NOMINATIM_ABANDONED',
|
||||
);
|
||||
assert.deepEqual(calls, [], 'a search nobody is waiting for is not sent');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('the upstream deadline covers the body, not just the headers', async () => {
|
||||
// A response whose headers arrive and whose body then never does. Before the
|
||||
// deadline covered the body, the abort timer was already cleared by the time
|
||||
// the first byte was due, so this read had no deadline at all.
|
||||
const stalled = () => {
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream({
|
||||
pull() {
|
||||
return new Promise(() => {});
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, { status: 200 }),
|
||||
wasCancelled: () => cancelled,
|
||||
};
|
||||
};
|
||||
|
||||
const previous = globalThis.fetch;
|
||||
const { response, wasCancelled } = stalled();
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
// The same signal must reach the body read, or aborting stops nothing.
|
||||
options.signal.addEventListener('abort', () => {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
});
|
||||
return response;
|
||||
};
|
||||
try {
|
||||
const started = Date.now();
|
||||
await assert.rejects(
|
||||
fetchRegionalJson('https://example.test/stalls', { timeoutMs: 300 }),
|
||||
'a stalled body must fail rather than hang',
|
||||
);
|
||||
const elapsed = Date.now() - started;
|
||||
assert.ok(elapsed < 5000, `gave up after ${elapsed} ms`);
|
||||
assert.ok(wasCancelled(), 'the abort reached the body read');
|
||||
} finally {
|
||||
globalThis.fetch = previous;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import { transitProxy } from 'gods-eye-view/server/providers/transit';
|
||||
import {
|
||||
TRANSIT_BACKOFF_LADDER_MS,
|
||||
TRANSIT_PROXY_TTL_MS,
|
||||
} from 'gods-eye-view/sources/transit';
|
||||
|
||||
/** Mount the plugin and return a caller for its single route. */
|
||||
function install(plugin, mode = 'configureServer') {
|
||||
const routes = new Map();
|
||||
plugin[mode]({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(routes.size, 1);
|
||||
return async (url = '/', method = 'GET') => {
|
||||
const res = {
|
||||
headersSent: false,
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, {
|
||||
status,
|
||||
headers: new Headers(headers),
|
||||
headersSent: true,
|
||||
});
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await [...routes.values()][0]({ url, method }, res);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
/** One GTFS-RT FeedMessage with the given header and vehicles. */
|
||||
function feedBytes({
|
||||
incrementality = 0,
|
||||
timestamp = 1_700_000_000,
|
||||
vehicles = [],
|
||||
}) {
|
||||
const writer = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeStringField(1, '2.0');
|
||||
header.writeVarintField(2, incrementality);
|
||||
header.writeVarintField(3, timestamp);
|
||||
writer.writeBytesField(1, header.finish());
|
||||
for (const item of vehicles) {
|
||||
const entity = new PbfWriter();
|
||||
entity.writeStringField(1, item.id);
|
||||
const vehicle = new PbfWriter();
|
||||
const position = new PbfWriter();
|
||||
position.writeFloatField(1, item.lat);
|
||||
position.writeFloatField(2, item.lon);
|
||||
vehicle.writeBytesField(2, position.finish());
|
||||
vehicle.writeVarintField(5, item.timestamp ?? timestamp);
|
||||
const descriptor = new PbfWriter();
|
||||
descriptor.writeStringField(1, item.id);
|
||||
vehicle.writeBytesField(8, descriptor.finish());
|
||||
entity.writeBytesField(4, vehicle.finish());
|
||||
writer.writeBytesField(2, entity.finish());
|
||||
}
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
function upstreamResponse({ status = 200, headers = {}, bytes = null }) {
|
||||
const map = new Map(
|
||||
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),
|
||||
);
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
headers: { get: (name) => map.get(String(name).toLowerCase()) ?? null },
|
||||
body: null,
|
||||
async arrayBuffer() {
|
||||
const view = bytes || new Uint8Array(0);
|
||||
return view.buffer.slice(
|
||||
view.byteOffset,
|
||||
view.byteOffset + view.byteLength,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A scripted upstream that records every request it is actually given. */
|
||||
function scriptedUpstream(steps) {
|
||||
const calls = [];
|
||||
let index = 0;
|
||||
const fetchImpl = async (url, init) => {
|
||||
calls.push({ url, headers: init?.headers || {}, redirect: init?.redirect });
|
||||
const step = steps[Math.min(index, steps.length - 1)];
|
||||
index += 1;
|
||||
if (typeof step === 'function') return step(url, init);
|
||||
return step;
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
const FULL = feedBytes({
|
||||
vehicles: [{ id: 'bus-1', lat: 42.36, lon: -71.06 }],
|
||||
});
|
||||
|
||||
test('a 304 revalidation is a success, not a redirect with a missing Location', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
|
||||
const { fetchImpl, calls } = scriptedUpstream([
|
||||
upstreamResponse({ headers: { etag: 'W/"v1"' }, bytes: FULL }),
|
||||
upstreamResponse({ status: 304, headers: { etag: 'W/"v1"' } }),
|
||||
upstreamResponse({ status: 304, headers: { etag: 'W/"v1"' } }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl }));
|
||||
|
||||
const first = await call('/vehicles/mbta');
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(first.headers.get('X-GEV-Cache'), 'MISS');
|
||||
assert.equal(JSON.parse(first.body).count, 1);
|
||||
|
||||
// Past the freshness window the proxy asks again, conditionally.
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1_000);
|
||||
const second = await call('/vehicles/mbta');
|
||||
assert.equal(calls[1].headers['If-None-Match'], 'W/"v1"');
|
||||
assert.equal(second.status, 200, 'an unchanged feed is not a failure');
|
||||
// MISS, not STALE-ERROR: the refresh SUCCEEDED. Treating 304 as a redirect
|
||||
// made this a failure that happened to be masked by the serve-stale path.
|
||||
assert.equal(second.headers.get('X-GEV-Cache'), 'MISS');
|
||||
assert.equal(JSON.parse(second.body).count, 1);
|
||||
|
||||
// And the third one too: a 304 must not have started a backoff ladder.
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1_000);
|
||||
const third = await call('/vehicles/mbta');
|
||||
assert.equal(third.status, 200);
|
||||
assert.equal(third.headers.get('X-GEV-Cache'), 'MISS');
|
||||
assert.equal(calls.length, 3, 'every poll past the TTL reached the operator');
|
||||
assert.equal(third.headers.get('Retry-After'), null);
|
||||
assert.equal(
|
||||
third.headers.get('X-Transit-Backoff'),
|
||||
null,
|
||||
'no ladder was started',
|
||||
);
|
||||
});
|
||||
|
||||
test('a differential feed condemns the snapshot that preceded it', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
|
||||
const differential = feedBytes({
|
||||
incrementality: 1,
|
||||
vehicles: [{ id: 'bus-1', lat: 42.36, lon: -71.06 }],
|
||||
});
|
||||
const { fetchImpl } = scriptedUpstream([
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
upstreamResponse({ bytes: differential }),
|
||||
upstreamResponse({ bytes: differential }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl }));
|
||||
|
||||
assert.equal((await call('/vehicles/mbta')).status, 200);
|
||||
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1_000);
|
||||
const refused = await call('/vehicles/mbta');
|
||||
assert.equal(refused.status, 502);
|
||||
assert.match(JSON.parse(refused.body).error, /differential/);
|
||||
|
||||
// The next caller arrives INSIDE the cooldown the failure just started, which
|
||||
// is precisely the path that used to hand back the condemned snapshot as a
|
||||
// 200 because it was still inside the serve-stale window.
|
||||
t.mock.timers.setTime(Date.now() + 2_000);
|
||||
const after = await call('/vehicles/mbta');
|
||||
assert.notEqual(
|
||||
after.status,
|
||||
200,
|
||||
`served ${after.status} with ${after.body}`,
|
||||
);
|
||||
assert.equal(after.headers.get('X-GEV-Cache'), 'NONE');
|
||||
assert.equal(after.headers.get('X-Transit-Backoff'), 'cooldown');
|
||||
});
|
||||
|
||||
test('a failing operator is put on a cooldown instead of being re-asked every poll', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
|
||||
const { fetchImpl, calls } = scriptedUpstream([
|
||||
upstreamResponse({ status: 500 }),
|
||||
upstreamResponse({ status: 500 }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl }));
|
||||
|
||||
const failed = await call('/vehicles/mbta');
|
||||
assert.equal(failed.status, 502);
|
||||
assert.equal(
|
||||
failed.headers.get('Retry-After'),
|
||||
String(TRANSIT_BACKOFF_LADDER_MS[0] / 1000),
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
|
||||
// Inside the cooldown the operator is not contacted at all.
|
||||
t.mock.timers.setTime(Date.now() + 1_000);
|
||||
const held = await call('/vehicles/mbta');
|
||||
assert.equal(held.status, 503);
|
||||
assert.equal(held.headers.get('X-Transit-Backoff'), 'cooldown');
|
||||
assert.equal(calls.length, 1, 'the operator was left alone');
|
||||
|
||||
// Past the first rung it is tried again, and a second failure waits longer.
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_BACKOFF_LADDER_MS[0]);
|
||||
const retried = await call('/vehicles/mbta');
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(
|
||||
retried.headers.get('Retry-After'),
|
||||
String(TRANSIT_BACKOFF_LADDER_MS[1] / 1000),
|
||||
);
|
||||
});
|
||||
|
||||
test('a redirect is followed only within the feed origin, and never contacted first', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
|
||||
const sameOrigin = scriptedUpstream([
|
||||
upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: '/files/current.pb' },
|
||||
}),
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl: sameOrigin.fetchImpl }));
|
||||
const ok = await call('/vehicles/capmetro-austin');
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(sameOrigin.calls.length, 2);
|
||||
assert.equal(
|
||||
sameOrigin.calls[1].url,
|
||||
'https://data.texas.gov/files/current.pb',
|
||||
);
|
||||
assert.equal(sameOrigin.calls[0].redirect, 'manual');
|
||||
|
||||
const offOrigin = scriptedUpstream([
|
||||
upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://cdn.elsewhere.test/current.pb' },
|
||||
}),
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
]);
|
||||
const refuse = install(transitProxy({ fetchImpl: offOrigin.fetchImpl }));
|
||||
const blocked = await refuse('/vehicles/capmetro-austin');
|
||||
assert.equal(blocked.status, 504);
|
||||
assert.equal(
|
||||
offOrigin.calls.length,
|
||||
1,
|
||||
'the disallowed host was never contacted, only its Location read',
|
||||
);
|
||||
});
|
||||
|
||||
test('only registered feed ids reach an operator', async (t) => {
|
||||
const { fetchImpl, calls } = scriptedUpstream([
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl }));
|
||||
for (const path of [
|
||||
'/vehicles/not-a-feed',
|
||||
'/vehicles/..%2F..%2Fetc',
|
||||
'/vehicles/',
|
||||
'/elsewhere',
|
||||
]) {
|
||||
const missing = await call(path);
|
||||
assert.equal(missing.status, 404, `${path} is not a door to an upstream`);
|
||||
}
|
||||
assert.equal(calls.length, 0, 'no operator was contacted for any of them');
|
||||
const catalog = JSON.parse((await call('/feeds')).body);
|
||||
assert.ok(catalog.feeds.length > 0);
|
||||
for (const feed of catalog.feeds) {
|
||||
assert.equal('url' in feed, false);
|
||||
assert.equal('headers' in feed, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('a 304 reports that the operator answered, not that the body is new', async (t) => {
|
||||
// The body a revalidated feed keeps serving carries the fetch time of the
|
||||
// payload it holds, which stops advancing the moment the operator's file
|
||||
// stops changing. That is the right answer for "how old are these positions"
|
||||
// and the wrong one for "when did we last hear from them" — read as the
|
||||
// latter it aged a feed whose every request succeeded into silence.
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
|
||||
const firstAt = Date.now();
|
||||
const { fetchImpl } = scriptedUpstream([
|
||||
upstreamResponse({ headers: { etag: 'W/"v1"' }, bytes: FULL }),
|
||||
upstreamResponse({ status: 304, headers: { etag: 'W/"v1"' } }),
|
||||
upstreamResponse({ status: 304, headers: { etag: 'W/"v1"' } }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl }));
|
||||
|
||||
const first = await call('/vehicles/mbta');
|
||||
assert.equal(Number(first.headers.get('X-Transit-Contact')), firstAt);
|
||||
assert.equal(JSON.parse(first.body).fetchedAt, firstAt);
|
||||
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1_000);
|
||||
const revalidatedAt = Date.now();
|
||||
const second = await call('/vehicles/mbta');
|
||||
assert.equal(
|
||||
JSON.parse(second.body).fetchedAt,
|
||||
firstAt,
|
||||
'the positions are as old as they are',
|
||||
);
|
||||
assert.equal(
|
||||
Number(second.headers.get('X-Transit-Contact')),
|
||||
revalidatedAt,
|
||||
'but the operator answered just now',
|
||||
);
|
||||
|
||||
// Served from the fresh cache, the contact time is the last real contact —
|
||||
// not the moment this request happened to arrive.
|
||||
const third = await call('/vehicles/mbta');
|
||||
assert.equal(third.headers.get('X-GEV-Cache'), 'HIT');
|
||||
assert.equal(Number(third.headers.get('X-Transit-Contact')), revalidatedAt);
|
||||
});
|
||||
|
||||
test('a feed served from cache during an outage does not claim fresh contact', async (t) => {
|
||||
// The other half: STALE-ERROR must carry the LAST SUCCESSFUL contact, so the
|
||||
// browser can tell a revalidated feed from a dead one.
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
|
||||
const contactedAt = Date.now();
|
||||
const { fetchImpl } = scriptedUpstream([
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
upstreamResponse({ status: 500 }),
|
||||
upstreamResponse({ status: 500 }),
|
||||
]);
|
||||
const call = install(transitProxy({ fetchImpl }));
|
||||
await call('/vehicles/mbta');
|
||||
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1_000);
|
||||
const during = await call('/vehicles/mbta');
|
||||
assert.equal(during.headers.get('X-GEV-Cache'), 'STALE-ERROR');
|
||||
assert.equal(
|
||||
Number(during.headers.get('X-Transit-Contact')),
|
||||
contactedAt,
|
||||
'silence does not count as an answer',
|
||||
);
|
||||
});
|
||||
|
||||
// Include the provider-local storage contract in the repository's src test discovery.
|
||||
import '../../server/providers/transitHistory.test.mjs';
|
||||
|
||||
test('history reads never fetch upstream, and 304/cache/failure replays never append', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1700000000000 });
|
||||
const { calls, fetchImpl } = scriptedUpstream([
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
upstreamResponse({ status: 304 }),
|
||||
upstreamResponse({ status: 503 }),
|
||||
]);
|
||||
const plugin = transitProxy({ fetchImpl });
|
||||
t.after(() => plugin.closeBundle());
|
||||
const call = install(plugin);
|
||||
const read = () => call('/trail/mbta/bus-1');
|
||||
assert.equal(JSON.parse((await read()).body).fixes.length, 0);
|
||||
assert.equal(calls.length, 0);
|
||||
await call('/vehicles/mbta');
|
||||
const initial = JSON.parse((await read()).body);
|
||||
assert.equal(initial.fixes.length, 1);
|
||||
await call('/vehicles/mbta');
|
||||
assert.equal(calls.length, 1);
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1);
|
||||
await call('/vehicles/mbta');
|
||||
assert.deepEqual(JSON.parse((await read()).body), initial);
|
||||
t.mock.timers.setTime(Date.now() + TRANSIT_PROXY_TTL_MS + 1);
|
||||
await call('/vehicles/mbta');
|
||||
assert.deepEqual(JSON.parse((await read()).body), initial);
|
||||
assert.equal(calls.length, 3);
|
||||
for (const path of [
|
||||
'/trail/mbta/%2Fescape',
|
||||
'/trail/mbta/abc%00bad',
|
||||
'/trail/mbta/%E0%A4%A',
|
||||
`/trail/mbta/${'a'.repeat(257)}`,
|
||||
'/trail/capmetro-austin/bus-1',
|
||||
])
|
||||
assert.equal((await call(path)).status, 404);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
3,
|
||||
'malformed and non-retained reads cannot reach an operator',
|
||||
);
|
||||
});
|
||||
test('history read admission is separate from upstream admission and closes with the server', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1700000000000 });
|
||||
const { calls, fetchImpl } = scriptedUpstream([
|
||||
upstreamResponse({ bytes: FULL }),
|
||||
]);
|
||||
const plugin = transitProxy({ fetchImpl });
|
||||
t.after(() => plugin.closeBundle());
|
||||
const call = install(plugin);
|
||||
for (let i = 0; i < 30; i++)
|
||||
assert.equal((await call('/trail/mbta/bus-1')).status, 200);
|
||||
assert.equal((await call('/trail/mbta/bus-1')).status, 429);
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal((await call('/vehicles/mbta')).status, 200);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('development and preview mount the same transit service and close it on shutdown', async () => {
|
||||
for (const mode of ['configureServer', 'configurePreviewServer']) {
|
||||
let close;
|
||||
let handler;
|
||||
const plugin = transitProxy({
|
||||
fetchImpl: async () => {
|
||||
throw new Error('unexpected upstream');
|
||||
},
|
||||
});
|
||||
plugin[mode]({
|
||||
middlewares: {
|
||||
use(path, fn) {
|
||||
assert.equal(path, '/api/transit');
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
httpServer: {
|
||||
once(event, fn) {
|
||||
assert.equal(event, 'close');
|
||||
close = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
async function call(method) {
|
||||
const res = {
|
||||
writeHead(status) {
|
||||
this.status = status;
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
await handler({ url: '/feeds', method }, res);
|
||||
return res.status;
|
||||
}
|
||||
assert.equal(await call('GET'), 200);
|
||||
assert.equal(await call('TRACE'), 405);
|
||||
close();
|
||||
assert.equal(await call('GET'), 503);
|
||||
plugin.closeBundle();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,631 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
chooseLiveCities,
|
||||
selectStyle,
|
||||
detectOn,
|
||||
} from '../../scripts/qa-transit-controls.mjs';
|
||||
import { createStyleParameters } from '../ui/styleParameters.js';
|
||||
import { bindDisplayControls } from '../ui/displayControls.js';
|
||||
import { thermalShader } from '../styles/thermal.js';
|
||||
import {
|
||||
cycleMode,
|
||||
setMode,
|
||||
getMode,
|
||||
setDetectionTuning,
|
||||
} from '../data/detection.js';
|
||||
|
||||
const pause = async () => {};
|
||||
class Element extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.children = [];
|
||||
this.attributes = new Map();
|
||||
this.classList = { contains: () => false };
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name);
|
||||
}
|
||||
append(...children) {
|
||||
this.children.push(...children);
|
||||
}
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
}
|
||||
replaceChildren() {
|
||||
this.children = [];
|
||||
}
|
||||
scrollIntoView() {}
|
||||
getBoundingClientRect() {
|
||||
return { width: 100 };
|
||||
}
|
||||
}
|
||||
function pageFixture(t) {
|
||||
const prior = { document: globalThis.document, window: globalThis.window };
|
||||
const elements = new Map();
|
||||
const document = {
|
||||
documentElement: { dataset: { gevStyle: 'normal' } },
|
||||
createElement: () => new Element(),
|
||||
getElementById: (id) => elements.get(id),
|
||||
querySelector: () => null,
|
||||
};
|
||||
globalThis.document = document;
|
||||
globalThis.window = {};
|
||||
t.after(() => {
|
||||
for (const [name, value] of Object.entries(prior)) {
|
||||
if (value === undefined) delete globalThis[name];
|
||||
else globalThis[name] = value;
|
||||
}
|
||||
});
|
||||
const find = (selector) => {
|
||||
if (selector.startsWith('#param-sliders ')) {
|
||||
const label = selector.match(/aria-label="([^"]+)"/)[1];
|
||||
return elements
|
||||
.get('param-sliders')
|
||||
.children.flatMap((row) => row.children)
|
||||
.find((el) => el.getAttribute('aria-label') === label);
|
||||
}
|
||||
return elements.get(selector.slice(1));
|
||||
};
|
||||
const page = {
|
||||
evaluate: async (fn, ...args) => fn(...args),
|
||||
waitForFunction: async (fn, options, ...args) =>
|
||||
assert.ok(fn(...args), 'UI condition must hold'),
|
||||
waitForSelector: async (selector) =>
|
||||
assert.ok(find(selector), `missing ${selector}`),
|
||||
$eval: async (selector, fn, ...args) => {
|
||||
const el = find(selector);
|
||||
assert.ok(el, `missing ${selector}`);
|
||||
return fn(el, ...args);
|
||||
},
|
||||
click: async (selector) =>
|
||||
find(selector)?.dispatchEvent(new Event('click')),
|
||||
};
|
||||
return { page, elements, document };
|
||||
}
|
||||
|
||||
test('live visual cities follow local operating hours in summer and winter', () => {
|
||||
for (const date of ['2026-09-15T06:30:00Z', '2026-01-15T06:30:00Z']) {
|
||||
assert.equal(chooseLiveCities(new Date(date))[0].id, 'helsinki');
|
||||
}
|
||||
assert.deepEqual(
|
||||
chooseLiveCities(new Date('2026-09-15T17:00:00Z')).map((c) => c.id),
|
||||
['boston', 'austin'],
|
||||
);
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
const now = new Date(`2026-09-15T${String(hour).padStart(2, '0')}:00:00Z`);
|
||||
const cities = chooseLiveCities(now);
|
||||
assert.ok(cities.length >= 1);
|
||||
for (const city of cities) {
|
||||
const local = Number(
|
||||
new Intl.DateTimeFormat('en', {
|
||||
timeZone: city.zone,
|
||||
hour: 'numeric',
|
||||
hourCycle: 'h23',
|
||||
}).format(now),
|
||||
);
|
||||
assert.ok(local >= 6 && local < 22);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('thermal controls wait for activation and use the real generated parameter rows', async (t) => {
|
||||
const { page, document, elements } = pageFixture(t);
|
||||
const container = new Element();
|
||||
container.ownerDocument = document;
|
||||
elements.set('param-sliders', container);
|
||||
const parameters = createStyleParameters({ container });
|
||||
t.after(() => parameters.destroy());
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(thermalShader.uniforms).map(([key, meta]) => [
|
||||
key,
|
||||
meta.default,
|
||||
]),
|
||||
);
|
||||
window.__godsEyeView = {
|
||||
styleManager: {
|
||||
setPanelCollapsed() {},
|
||||
setStyle(name) {
|
||||
document.documentElement.dataset.gevStyle = name;
|
||||
parameters.render({
|
||||
uniforms: thermalShader.uniforms,
|
||||
readValue: (key) => values[key],
|
||||
writeValue: (key, value) => {
|
||||
values[key] = value;
|
||||
},
|
||||
onChange() {},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
// A swallowed click resolves normally; the helper must notice no activation.
|
||||
page.$eval = async (selector, fn, ...args) => {
|
||||
if (selector.startsWith('.style-btn')) return fn(new Element(), ...args);
|
||||
const label = selector.match(/aria-label="([^"]+)"/)[1];
|
||||
const slider = container.children
|
||||
.flatMap((row) => row.children)
|
||||
.find((el) => el.getAttribute('aria-label') === label);
|
||||
assert.ok(slider);
|
||||
return fn(slider, ...args);
|
||||
};
|
||||
const how = await selectStyle(
|
||||
page,
|
||||
'thermal',
|
||||
{ 'WHOT/BHOT': 1, Ironbow: 1 },
|
||||
pause,
|
||||
);
|
||||
assert.match(how, /button did not activate/);
|
||||
assert.equal(values.mode, 1);
|
||||
assert.equal(values.palette, 1);
|
||||
});
|
||||
|
||||
test('DETECT uses the real OFF/restore button and density input to reach DENSE', async (t) => {
|
||||
const { page, elements } = pageFixture(t);
|
||||
const button = new Element(),
|
||||
slider = new Element();
|
||||
elements.set('detection-toggle', button);
|
||||
elements.set('detection-density-slider', slider);
|
||||
button.getAttribute = (name) =>
|
||||
name === 'aria-pressed'
|
||||
? String(getMode() !== 'OFF')
|
||||
: `Detection overlay: ${getMode().toLowerCase()}`;
|
||||
setMode('BALANCED');
|
||||
setMode('OFF');
|
||||
const modes = [];
|
||||
const controls = bindDisplayControls({
|
||||
elements: { detectionButton: button, densitySlider: slider },
|
||||
actions: {
|
||||
cycleDetection() {
|
||||
cycleMode();
|
||||
modes.push(getMode());
|
||||
},
|
||||
setDensity(value) {
|
||||
setDetectionTuning({ densityPct: Number(value) });
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
setMode('OFF');
|
||||
});
|
||||
assert.equal(await detectOn(page, pause), 'DENSE');
|
||||
assert.deepEqual(modes, ['BALANCED', 'OFF', 'DENSE']);
|
||||
const template = readFileSync(
|
||||
new URL('../ui/templates/display-controls.html', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(template, /id="detection-density-slider"/);
|
||||
assert.match(template, /id="detection-toggle"/);
|
||||
});
|
||||
|
||||
test('city motion checks load deterministic playback before reading rendered samples', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../../scripts/qa-transit.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const fixture = source.indexOf('_loadTransitFleetForTest(16');
|
||||
assert.ok(fixture >= 0);
|
||||
assert.ok(
|
||||
source.indexOf('const motionFirst = await sampleVisible(page)', fixture) >
|
||||
fixture,
|
||||
);
|
||||
assert.ok(
|
||||
source.indexOf('const glideA = await sampleVisible(page)', fixture) >
|
||||
fixture,
|
||||
);
|
||||
assert.ok(
|
||||
source.indexOf('const headingA = await sampleScreen(page)', fixture) >
|
||||
fixture,
|
||||
);
|
||||
assert.match(source, /scene.renderError.addEventListener/);
|
||||
});
|
||||
|
||||
const { boundPageEvaluations, restoreRetainedTransit, sampleTransitPixels } =
|
||||
await import('../../scripts/qa-transit-browser.mjs');
|
||||
|
||||
test('page promise deadline rejects a stuck enable or teardown and preserves ordinary results', async () => {
|
||||
const page = { evaluate: async (fn, ...args) => fn(...args) };
|
||||
boundPageEvaluations(page, 15);
|
||||
assert.equal(await page.evaluate((a, b) => a + b, 3, 4), 7);
|
||||
await assert.rejects(
|
||||
page.evaluate(() => new Promise(() => {})),
|
||||
/Transit QA page wait timed out after 15 ms/,
|
||||
);
|
||||
});
|
||||
|
||||
test('reload picks a surviving primed vehicle and reports unavailable snapshots as unexercised', async (t) => {
|
||||
const previous = globalThis.window;
|
||||
t.after(() => {
|
||||
globalThis.window = previous;
|
||||
});
|
||||
let selected;
|
||||
const entry = { key: 'mbta:survivor', record: { lon: -71.06, lat: 42.36 } };
|
||||
const vehicles = new Map([[entry.key, entry]]);
|
||||
globalThis.window = {
|
||||
__godsEyeView: {
|
||||
viewer: {
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
constructor: { fromDegrees: (...v) => v, toCartesian: (v) => v },
|
||||
},
|
||||
setView() {},
|
||||
},
|
||||
},
|
||||
dataManager: {
|
||||
setEnabled: async () => {},
|
||||
layers: new Map([
|
||||
[
|
||||
'transit',
|
||||
{
|
||||
module: {
|
||||
update: async () => {},
|
||||
_transitStateForTest: () => ({ _vehicles: vehicles }),
|
||||
_transitPartsForTest: () => ({
|
||||
selection: {
|
||||
selectVehicle: (key) => {
|
||||
selected = key;
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
const primed = [
|
||||
{ vehicleId: 'departed', oldestT: 10 },
|
||||
{ vehicleId: 'survivor', oldestT: 20 },
|
||||
];
|
||||
assert.deepEqual(await restoreRetainedTransit(primed), {
|
||||
key: entry.key,
|
||||
oldest: 20,
|
||||
});
|
||||
assert.equal(selected, entry.key);
|
||||
vehicles.clear();
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const layer = manager.layers.get('transit').module;
|
||||
layer._transitStateForTest = () => ({
|
||||
_vehicles: vehicles,
|
||||
_activeFeeds: new Map([['mbta', {}]]),
|
||||
});
|
||||
manager.setEnabled = async () => {
|
||||
setTimeout(() => vehicles.set(entry.key, entry), 5);
|
||||
};
|
||||
assert.equal(
|
||||
(await restoreRetainedTransit(primed)).key,
|
||||
entry.key,
|
||||
'wait for discovery instead of declaring every vehicle departed',
|
||||
);
|
||||
manager.setEnabled = async () => {};
|
||||
assert.equal(
|
||||
(
|
||||
await restoreRetainedTransit([
|
||||
{ ...primed[1], feedId: 'mbta', primedAt: Date.now() - 60000 },
|
||||
])
|
||||
).unexercised,
|
||||
true,
|
||||
'expired priming cannot pass',
|
||||
);
|
||||
layer._transitStateForTest = () => ({ _vehicles: vehicles });
|
||||
vehicles.clear();
|
||||
assert.equal((await restoreRetainedTransit(primed)).unexercised, true);
|
||||
assert.equal((await restoreRetainedTransit([])).unexercised, true);
|
||||
});
|
||||
|
||||
test('sensor sampler waits for postRender and reads framebuffer pixels; stopped rendering rejects and removes listener', async (t) => {
|
||||
const previousWindow = globalThis.window,
|
||||
previousDocument = globalThis.document,
|
||||
previousImage = globalThis.Image;
|
||||
t.after(() => {
|
||||
globalThis.window = previousWindow;
|
||||
globalThis.document = previousDocument;
|
||||
globalThis.Image = previousImage;
|
||||
});
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
let listener,
|
||||
reads = 0,
|
||||
render = true;
|
||||
const gl = {
|
||||
RGBA: 1,
|
||||
UNSIGNED_BYTE: 2,
|
||||
readPixels(x, y, w, h, format, type, buffer) {
|
||||
reads++;
|
||||
buffer.set([255, 255, 255, 255]);
|
||||
},
|
||||
};
|
||||
const scene = {
|
||||
camera: { viewMatrix: identity, frustum: { projectionMatrix: identity } },
|
||||
canvas: {
|
||||
width: 400,
|
||||
height: 400,
|
||||
clientWidth: 400,
|
||||
clientHeight: 400,
|
||||
getContext: () => gl,
|
||||
},
|
||||
postRender: {
|
||||
addEventListener(fn) {
|
||||
listener = fn;
|
||||
return () => {
|
||||
listener = null;
|
||||
};
|
||||
},
|
||||
},
|
||||
requestRender() {
|
||||
if (render) queueMicrotask(() => listener?.());
|
||||
},
|
||||
};
|
||||
const raster = new Uint8ClampedArray(16 * 16 * 4).fill(255);
|
||||
globalThis.Image = class {
|
||||
width = 16;
|
||||
height = 16;
|
||||
async decode() {}
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById: () => null,
|
||||
createElement: () => ({
|
||||
getContext: () => ({
|
||||
drawImage() {},
|
||||
getImageData: () => ({ data: raster }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
globalThis.window = {
|
||||
__godsEyeView: {
|
||||
viewer: { scene },
|
||||
dataManager: {
|
||||
layers: new Map([
|
||||
[
|
||||
'transit',
|
||||
{
|
||||
module: {
|
||||
_transitStateForTest: () => ({
|
||||
_vehicles: new Map([
|
||||
[
|
||||
'bus',
|
||||
{
|
||||
key: 'bus',
|
||||
mode: 'bus',
|
||||
marker: {
|
||||
show: true,
|
||||
image: 'raster',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
const page = { evaluate: async (fn, ...args) => fn(...args) };
|
||||
const pixels = await sampleTransitPixels(page, {});
|
||||
assert.ok(
|
||||
reads >= 13,
|
||||
'centre, rings and background come from WebGL readPixels',
|
||||
);
|
||||
assert.ok(Math.abs(pixels[0].centre - 1) < 1e-6);
|
||||
assert.equal(listener, null);
|
||||
scene.pick = () => ({ id: 'overlapping-label' });
|
||||
assert.equal(
|
||||
(await sampleTransitPixels(page, {})).length,
|
||||
0,
|
||||
'a foreign pick across the sampled core disqualifies an obscured sprite',
|
||||
);
|
||||
scene.pick = ({ x }) => ({ id: x < 200 ? 'overlapping-sprite' : 'bus' });
|
||||
assert.equal(
|
||||
(await sampleTransitPixels(page, {})).length,
|
||||
0,
|
||||
'a clear centre cannot qualify a partially obscured core',
|
||||
);
|
||||
delete scene.pick;
|
||||
const module = window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||
const getState = module._transitStateForTest;
|
||||
module._transitStateForTest = () => {
|
||||
const state = getState();
|
||||
state._stylePreset = 'noir';
|
||||
state._vehicles.get('bus').marker.position.x = 0.65;
|
||||
return state;
|
||||
};
|
||||
assert.equal(
|
||||
(await sampleTransitPixels(page, {})).length,
|
||||
0,
|
||||
'the intentional noir edge vignette is outside unobscured-core acceptance',
|
||||
);
|
||||
module._transitStateForTest = getState;
|
||||
// The fixture returns fresh wrapper objects, so exercise raster and DOM
|
||||
// eligibility independently of framebuffer brightness.
|
||||
raster.fill(0);
|
||||
assert.equal((await sampleTransitPixels(page, {})).length, 0);
|
||||
raster.fill(255);
|
||||
document.elementFromPoint = () => ({ tagName: 'DIV', closest: () => null });
|
||||
assert.equal((await sampleTransitPixels(page, {})).length, 0);
|
||||
delete document.elementFromPoint;
|
||||
document.getElementById = () => ({
|
||||
width: 400,
|
||||
clientWidth: 400,
|
||||
getContext: () => ({
|
||||
getImageData: () => ({ data: new Uint8ClampedArray(144).fill(255) }),
|
||||
}),
|
||||
});
|
||||
assert.equal((await sampleTransitPixels(page, {})).length, 0);
|
||||
document.getElementById = () => null;
|
||||
window.__godsEyeView.styleManager = {
|
||||
getDetectionDiagnostics: () => ({
|
||||
calloutRects: [{ x: 205, y: 195, w: 10, h: 10, alpha: 1 }],
|
||||
}),
|
||||
};
|
||||
assert.equal(
|
||||
(await sampleTransitPixels(page, {})).length,
|
||||
0,
|
||||
'plate over the edge excludes the whole sprite even with a clear centre',
|
||||
);
|
||||
delete window.__godsEyeView.styleManager;
|
||||
document.getElementById = () => ({
|
||||
width: 400,
|
||||
clientWidth: 400,
|
||||
getContext: () => ({
|
||||
getImageData: (x, y, w = 1, h = 1) => ({
|
||||
data:
|
||||
w > 1
|
||||
? new Uint8ClampedArray(w * h * 4)
|
||||
: new Uint8ClampedArray(
|
||||
x === 191 && [190, 193, 196].includes(y)
|
||||
? [20, 30, 40, 255]
|
||||
: x === 191 && y === 207
|
||||
? [94, 240, 138, 255]
|
||||
: [0, 0, 0, 0],
|
||||
),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(
|
||||
(await sampleTransitPixels(page, {}))[0].bracket,
|
||||
[94, 240, 138],
|
||||
'stroke centre is sampled away from the top label plate',
|
||||
);
|
||||
document.getElementById = () => null;
|
||||
render = false;
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const pending = sampleTransitPixels(page, {});
|
||||
const rejected = assert.rejects(pending, /no postRender within 5000 ms/);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(5000);
|
||||
await rejected;
|
||||
assert.equal(listener, null);
|
||||
});
|
||||
|
||||
test('preset baseline clears a previous city selection before comparing fleet sizes', async (t) => {
|
||||
const { readTransitFleetStyle } =
|
||||
await import('../../scripts/qa-transit-browser.mjs');
|
||||
const prior = globalThis.window;
|
||||
t.after(() => {
|
||||
globalThis.window = prior;
|
||||
});
|
||||
const entry = {
|
||||
key: 'mbta:tram',
|
||||
marker: {
|
||||
show: true,
|
||||
width: 33.75,
|
||||
image: 'raster-selected',
|
||||
color: { toCssColorString: () => 'rgb(255,194,74)' },
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
_selectedKey: entry.key,
|
||||
_vehicles: new Map([[entry.key, entry]]),
|
||||
};
|
||||
globalThis.window = {
|
||||
__godsEyeView: {
|
||||
dataManager: {
|
||||
layers: new Map([
|
||||
[
|
||||
'transit',
|
||||
{
|
||||
module: {
|
||||
_transitStateForTest: () => state,
|
||||
_transitPartsForTest: () => ({
|
||||
selection: {
|
||||
clearSelection() {
|
||||
state._selectedKey = null;
|
||||
entry.marker.width = 22.5;
|
||||
entry.marker.image = 'raster-fleet';
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
const baseline = readTransitFleetStyle({ resetSelection: true });
|
||||
assert.equal(baseline.width, 22.5);
|
||||
assert.equal(baseline.selected, false);
|
||||
assert.deepEqual(readTransitFleetStyle({ key: entry.key }), baseline);
|
||||
});
|
||||
|
||||
test('allocation accounting includes Cesium render aliases and excludes QA helper subtrees', async () => {
|
||||
const { allocationTotals } =
|
||||
await import('../../scripts/qa-transit-scenes.mjs');
|
||||
const node = (functionName, url, selfSize, children = []) => ({
|
||||
callFrame: { functionName, url },
|
||||
selfSize,
|
||||
children,
|
||||
});
|
||||
const profile = {
|
||||
head: node('(root)', '', 0, [
|
||||
node('render2', 'cesium.js', 0, [
|
||||
node('Scene4.render', 'cesium.js', 10, [
|
||||
node('_drawOverlay', '/src/data/detection.js', 100),
|
||||
node('', '/src/layers/transit/testing.js', 200, [
|
||||
node('hypot', '', 300),
|
||||
node('onPreRender', '/src/layers/transit/rendering.js', 20, [
|
||||
node('sampleAt', '/src/data/contactPlayback.js', 30),
|
||||
node('style', '/src/data/transitPresetStyle.js', 10),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
};
|
||||
const { topAllocators, ...totals } = allocationTotals(profile);
|
||||
assert.equal(topAllocators[0].bytes, 100);
|
||||
assert.deepEqual(totals, {
|
||||
frameBytes: 170,
|
||||
layerFrameBytes: 60,
|
||||
sharedOverlayBytes: 100,
|
||||
qaBytes: 500,
|
||||
});
|
||||
});
|
||||
|
||||
test('public transit documentation uses courtesy attribution and the approved ground sentence', () => {
|
||||
const data = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const rows = data
|
||||
.split('\n')
|
||||
.filter((line) =>
|
||||
/^\| \*\*(MBTA|CapMetro|Metro Transit|OVapi|Entur|TransLink|HSL)/.test(
|
||||
line,
|
||||
),
|
||||
);
|
||||
assert.equal(rows.length, 7);
|
||||
for (const row of rows) {
|
||||
assert.doesNotMatch(
|
||||
row,
|
||||
/licen[cs]e|unlicensed|unstated|grants|permits|public.domain|CC BY|NLOD|terms|trademarks/i,
|
||||
);
|
||||
assert.match(row, /open realtime vehicle data/);
|
||||
assert.match(row, /published for developer use and not against their use/);
|
||||
}
|
||||
const readme = readFileSync(
|
||||
new URL('../../README.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes(
|
||||
'Fifteen layers and map sources. **Thirteen have a keyless path.**',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes(
|
||||
'**Sits on the real ground.** Entity heights are aligned to work with Google 3D tiles, so aircraft park on aprons and cameras stand on street corners instead of floating.',
|
||||
),
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
readme,
|
||||
/geoid-aware|sampled against the _rendered_ terrain mesh/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTransitService } from 'gods-eye-view/sources/transit-service';
|
||||
import { createTransitSource } from 'gods-eye-view/layers/transit/source';
|
||||
|
||||
const request = (path, method = 'GET') => ({
|
||||
url: `https://example.test${path}`,
|
||||
method,
|
||||
});
|
||||
|
||||
test('portable service confines requests to registered feeds and GET', async (t) => {
|
||||
let calls = 0;
|
||||
const service = createTransitService({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
});
|
||||
t.after(service.close);
|
||||
for (const path of [
|
||||
'/other',
|
||||
'/api/transit/vehicles/unknown',
|
||||
'/api/transit/vehicles/https%3A%2F%2Fexample.test',
|
||||
]) {
|
||||
assert.equal((await service.handle(request(path))).status, 404);
|
||||
}
|
||||
for (const method of ['POST', 'HEAD', 'TRACE']) {
|
||||
assert.equal(
|
||||
(await service.handle(request('/api/transit/feeds', method))).status,
|
||||
405,
|
||||
);
|
||||
}
|
||||
const response = await service.handle(request('/api/transit/feeds'));
|
||||
assert.ok(response instanceof Response);
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok((await response.json()).feeds.some((feed) => feed.id === 'mbta'));
|
||||
assert.equal(calls, 0);
|
||||
service.close();
|
||||
assert.equal(
|
||||
(await service.handle(request('/api/transit/feeds'))).status,
|
||||
503,
|
||||
);
|
||||
});
|
||||
|
||||
test('source sends snapshots and bounded history through the supplied transport', async () => {
|
||||
const calls = [];
|
||||
const controller = new AbortController();
|
||||
const payload = {
|
||||
version: 1,
|
||||
feedId: 'mbta',
|
||||
vehicleId: 'bus 1',
|
||||
fixes: [],
|
||||
epochs: [],
|
||||
};
|
||||
const source = createTransitSource({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return Response.json(url.includes('/trail/') ? payload : { count: 0 });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await (
|
||||
await source.requestSnapshot('mbta', { signal: controller.signal })
|
||||
).json(),
|
||||
{ count: 0 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await source.getHistory('mbta', 'bus 1', { signal: controller.signal }),
|
||||
payload,
|
||||
);
|
||||
assert.deepEqual(
|
||||
calls.map(({ url }) => url),
|
||||
['/api/transit/vehicles/mbta', '/api/transit/trail/mbta/bus%201'],
|
||||
);
|
||||
assert.ok(calls.every(({ init }) => init.signal === controller.signal));
|
||||
controller.abort();
|
||||
assert.throws(
|
||||
() => source.requestSnapshot('mbta', { signal: controller.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
assert.throws(
|
||||
() => source.getHistory('mbta', 'bus 1', { signal: controller.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test('source retains history validation and never retries through another transport', async () => {
|
||||
let calls = 0;
|
||||
const source = createTransitSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json({
|
||||
version: 1,
|
||||
feedId: 'wrong',
|
||||
vehicleId: 'bus',
|
||||
fixes: [],
|
||||
epochs: [],
|
||||
});
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getHistory('mbta', 'bus'),
|
||||
/Invalid transit history response/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
await assert.rejects(
|
||||
source.getHistory('mbta', '../bus'),
|
||||
/Invalid transit vehicle identifier/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('snapshot cancellation covers headers and body parsing', async () => {
|
||||
for (const phase of ['headers', 'body']) {
|
||||
const controller = new AbortController();
|
||||
const source = createTransitSource({
|
||||
fetchImpl: async () => {
|
||||
if (phase === 'headers') controller.abort();
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: async () => {
|
||||
controller.abort();
|
||||
return { vehicles: [] };
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
const response = await source.requestSnapshot('mbta', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
await response.json();
|
||||
},
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user