mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Merge branch 'main' into codex/overpass-admission-review-fixes
This commit is contained in:
1407 files changed
+118138
-22562
No files matched your search
@@ -1,22 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { expandApplicationHtml, APPLICATION_TEMPLATES } from '../../build/application-html.js';
|
||||
import { APPLICATION_VIEWS } from '../ui/views/index.js';
|
||||
import { readApplicationHtml } from '../testSupport/applicationHtml.mjs';
|
||||
|
||||
test('the standalone document expands every component once and preserves unique element ids', () => {
|
||||
const source = readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
|
||||
const html = expandApplicationHtml(source);
|
||||
assert.equal([...source.matchAll(/gev:template /g)].length, APPLICATION_TEMPLATES.length);
|
||||
assert.doesNotMatch(html, /gev:template/);
|
||||
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map(match => match[1]);
|
||||
const source = readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
|
||||
|
||||
test('the standalone document hosts every view once and keeps element ids unique', async () => {
|
||||
const hosts = [...source.matchAll(/data-gev-view="([^"]+)"/g)].map((match) => match[1]);
|
||||
// Order is stacking order for fixed elements that share a z-index.
|
||||
assert.deepEqual(hosts, ['scene-chrome', 'cockpit', 'display-controls', 'command-dock', 'layer-panels', 'context', 'welcome', 'provider-settings', 'intel-hud']);
|
||||
assert.deepEqual([...hosts].sort(), Object.keys(APPLICATION_VIEWS).sort());
|
||||
const html = await readApplicationHtml();
|
||||
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
assert.match(html, /id="cesiumContainer"/);
|
||||
assert.match(html, /type="module" src="\/src\/main.js"/);
|
||||
});
|
||||
|
||||
test('component selection includes only requested markup and refuses filesystem traversal', () => {
|
||||
const html = expandApplicationHtml('<!-- gev:template welcome -->\n');
|
||||
assert.match(html, /id="first-run-launcher"/);
|
||||
assert.doesNotMatch(html, /id="cesiumContainer"/);
|
||||
assert.throws(() => expandApplicationHtml('<!-- gev:template ../../.env -->'), /Unknown application template/);
|
||||
test('the loading screen is static markup, so it paints before any script runs', () => {
|
||||
const loading = source.indexOf('id="loading-screen"');
|
||||
assert.ok(loading > 0);
|
||||
assert.ok(loading < source.indexOf('type="module" src="/src/main.js"'));
|
||||
assert.match(source, /class="loader-status" data-i18n>Initializing photorealistic world\.\.\.</);
|
||||
assert.doesNotMatch(source, /gev:template/);
|
||||
});
|
||||
@@ -6,6 +6,12 @@ import { tomtomProxy } from 'gods-eye-view/server/providers/traffic';
|
||||
import { firmsProxy } from 'gods-eye-view/server/providers/firms';
|
||||
import { gbfsProxy } from 'gods-eye-view/server/providers/gbfs';
|
||||
import { localProviderPlugins } from '../../server/providers/local.js';
|
||||
import { makeFixtureRoot } from './fixtureRoot.mjs';
|
||||
|
||||
const nominatimStorageDir = await makeFixtureRoot(
|
||||
'gev-environment-composition-',
|
||||
);
|
||||
test.after(() => fsp.rm(nominatimStorageDir, { recursive: true, force: true }));
|
||||
|
||||
function install(plugin, hook = 'configureServer') {
|
||||
const routes = new Map();
|
||||
@@ -88,7 +94,7 @@ test('standalone composition mounts every extracted provider exactly once withou
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
throw Error('construction must not fetch');
|
||||
});
|
||||
const plugins = localProviderPlugins();
|
||||
const plugins = localProviderPlugins({ nominatimStorageDir });
|
||||
for (const factory of [
|
||||
terrainHeightsProxy,
|
||||
tomtomProxy,
|
||||
@@ -411,3 +417,156 @@ test('terrain middleware retains successful chunks around a failure and retries
|
||||
);
|
||||
assert.equal(requests.length, 4);
|
||||
});
|
||||
|
||||
test('after a 429 the terrain proxy leaves the upstream alone and logs once', async (t) => {
|
||||
isolate(t);
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return new Response('', { status: 429 });
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
|
||||
const request = install(terrainHeightsProxy());
|
||||
// The retries' backoff runs on mocked time.
|
||||
let settled = false;
|
||||
const first = request('/?points=1,2').then(() => {
|
||||
settled = true;
|
||||
});
|
||||
for (let step = 0; step < 60 && !settled; step += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(1_000);
|
||||
}
|
||||
assert.ok(settled, 'the first request settles within its retry budget');
|
||||
await first;
|
||||
const afterFirst = calls;
|
||||
assert.ok(afterFirst >= 1);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /HTTP 429; pausing upstream for 60 s/);
|
||||
// New points during the pause: no upstream call, no new log line.
|
||||
const paused = await request('/?points=3,4');
|
||||
assert.equal(calls, afterFirst);
|
||||
assert.equal(paused.status, 502);
|
||||
assert.equal(warnings.length, 1);
|
||||
});
|
||||
|
||||
test('a far-off Retry-After pauses the terrain upstream for at most 10 minutes', async (t) => {
|
||||
isolate(t);
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
|
||||
let limited = true;
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
limited
|
||||
? new Response('', { status: 429, headers: { 'retry-after': '86400' } })
|
||||
: Response.json({ results: [{ ellipsoid: 100 }] }),
|
||||
);
|
||||
const request = install(terrainHeightsProxy());
|
||||
// The first request spends its retry budget on 429s, then the pause begins.
|
||||
let settled = false;
|
||||
const first = request('/?points=1,2').then((res) => {
|
||||
settled = true;
|
||||
return res;
|
||||
});
|
||||
// Bounded: the retry budget is 10 s, so 60 simulated seconds is plenty.
|
||||
for (let step = 0; step < 60 && !settled; step += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(1_000);
|
||||
}
|
||||
assert.ok(settled, 'the first request settles within its retry budget');
|
||||
assert.equal((await first).status, 502);
|
||||
limited = false;
|
||||
const pausedAt = Date.now();
|
||||
t.mock.timers.setTime(pausedAt + 9 * 60_000);
|
||||
assert.equal((await request('/?points=3,4')).status, 502, 'still paused');
|
||||
t.mock.timers.setTime(pausedAt + 10 * 60_000 + 1);
|
||||
assert.equal((await request('/?points=3,4')).status, 200, 'pause over');
|
||||
});
|
||||
|
||||
/** Drive mocked timers until `promise` settles; the retry budget is 10 s. */
|
||||
async function settleOnMockedTime(t, promise) {
|
||||
let settled = false;
|
||||
const tracked = promise.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
for (let step = 0; step < 60 && !settled; step += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(1_000);
|
||||
}
|
||||
assert.ok(settled, 'the request settles within its retry budget');
|
||||
return tracked;
|
||||
}
|
||||
|
||||
test('during a terrain 429 pause fully cached points, even stale ones, are still served', async (t) => {
|
||||
isolate(t);
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
|
||||
let calls = 0;
|
||||
let limited = false;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return limited
|
||||
? new Response('', { status: 429, headers: { 'retry-after': '300' } })
|
||||
: Response.json({ results: [{ ellipsoid: 100 }] });
|
||||
});
|
||||
const request = install(terrainHeightsProxy());
|
||||
assert.equal((await request('/?points=5,6')).status, 200);
|
||||
// Past the 30-day TTL, so 5,6 is stale and would normally be refreshed.
|
||||
t.mock.timers.setTime(Date.now() + 31 * 24 * 3600_000);
|
||||
limited = true;
|
||||
const first = await settleOnMockedTime(t, request('/?points=1,2'));
|
||||
assert.equal(first.status, 502);
|
||||
assert.equal(calls, 2, 'a Retry-After past the budget is not retried');
|
||||
const stale = await request('/?points=5,6');
|
||||
assert.equal(stale.status, 200);
|
||||
assert.deepEqual(json(stale).results, [{ ellipsoid: 100 }]);
|
||||
assert.equal((await request('/?points=5,6;7,8')).status, 502);
|
||||
assert.equal(calls, 2, 'no upstream call during the pause');
|
||||
});
|
||||
|
||||
test('a terrain upstream failure other than 429 does not pause later requests', async (t) => {
|
||||
isolate(t);
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return new Response('', { status: 503 });
|
||||
});
|
||||
const request = install(terrainHeightsProxy());
|
||||
const first = await settleOnMockedTime(t, request('/?points=1,2'));
|
||||
assert.equal(first.status, 502);
|
||||
const afterFirst = calls;
|
||||
const second = await settleOnMockedTime(t, request('/?points=3,4'));
|
||||
assert.equal(second.status, 502);
|
||||
assert.ok(calls > afterFirst, 'new points still go upstream after a 503');
|
||||
});
|
||||
|
||||
test('a shorter concurrent Retry-After never cuts a terrain 429 pause short', async (t) => {
|
||||
isolate(t);
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async (raw) => {
|
||||
calls += 1;
|
||||
const points = new URL(raw).searchParams.get('points');
|
||||
if (points.startsWith('0.'))
|
||||
return Response.json({ results: [{ ellipsoid: 1 }] });
|
||||
const retryAfter = points.startsWith('1.') ? '300' : '30';
|
||||
return new Response('', {
|
||||
status: 429,
|
||||
headers: { 'retry-after': retryAfter },
|
||||
});
|
||||
});
|
||||
const request = install(terrainHeightsProxy());
|
||||
// Load the disk cache first so both requests below reach the upstream in
|
||||
// order: the 300 s answer lands first, the 30 s answer after it.
|
||||
assert.equal((await request('/?points=0,0')).status, 200);
|
||||
const [long, short] = await settleOnMockedTime(
|
||||
t,
|
||||
Promise.all([request('/?points=1,2'), request('/?points=3,4')]),
|
||||
);
|
||||
assert.equal(long.status, 502);
|
||||
assert.equal(short.status, 502);
|
||||
const afterBoth = calls;
|
||||
t.mock.timers.setTime(Date.now() + 120_000);
|
||||
assert.equal((await request('/?points=5,6')).status, 502);
|
||||
assert.equal(calls, afterBoth, 'still paused by the 300 s Retry-After');
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '../../server/providers/space/launch-library.js';
|
||||
import { keySetupStatus } from '../keySetupCore.mjs';
|
||||
import { initKeySetup } from '../keySetup.js';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useProviderSettingsStore } from '../ui/stores/providerSettings.js';
|
||||
|
||||
test('LL2 requests from the same UTC minute share a cache key without mutating callers', () => {
|
||||
const first = new Date('2026-10-17T16:20:00.001Z');
|
||||
@@ -57,60 +59,31 @@ test('LL2 headers identify the app and preserve optional server-only authorizati
|
||||
});
|
||||
|
||||
test('actual setup rendering distinguishes optional paid LL2 from unrelated free keys', async () => {
|
||||
const created = [];
|
||||
const node = () => {
|
||||
const value = {
|
||||
dataset: {},
|
||||
children: [],
|
||||
append(...children) {
|
||||
this.children.push(...children);
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
setAttribute() {},
|
||||
remove() {},
|
||||
};
|
||||
created.push(value);
|
||||
return value;
|
||||
};
|
||||
const rows = node();
|
||||
const root = node();
|
||||
root.querySelector = (selector) =>
|
||||
selector === '[data-key-setup-rows]' ? rows : null;
|
||||
const chip = node();
|
||||
const store = useProviderSettingsStore(createPinia());
|
||||
const status = keySetupStatus({});
|
||||
status.keys = status.keys.filter((key) =>
|
||||
['launch-library', 'mapillary'].includes(key.id),
|
||||
);
|
||||
const setup = await initKeySetup({
|
||||
documentRef: {
|
||||
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
|
||||
createElement: node,
|
||||
getElementById: (id) => (id === 'key-setup' ? { dataset: {} } : null),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
},
|
||||
store,
|
||||
fetchImpl: async () => ({ ok: true, json: async () => status }),
|
||||
});
|
||||
assert.ok(setup);
|
||||
try {
|
||||
const ll2 = rows.children.find(
|
||||
(row) => row.dataset.keyId === 'launch-library',
|
||||
const row = (id) => store.view.rows.find((key) => key.id === id);
|
||||
const ll2 = row('launch-library');
|
||||
assert.match(ll2.tierTitle, /optional paid token/i);
|
||||
assert.match(ll2.unlocks, /leave blank/i);
|
||||
assert.match(ll2.unlocks, /no key/i);
|
||||
assert.equal(
|
||||
row('mapillary').tierTitle,
|
||||
'Free key — register, paste, done',
|
||||
);
|
||||
const free = rows.children.find((row) => row.dataset.keyId === 'mapillary');
|
||||
const tier = (row) =>
|
||||
row.children[0].children.find(
|
||||
(child) => child.className === 'key-setup-tier',
|
||||
);
|
||||
assert.match(tier(ll2).title, /optional paid token/i);
|
||||
assert.match(ll2.children[1].textContent, /leave blank/i);
|
||||
assert.match(ll2.children[1].textContent, /no key/i);
|
||||
assert.equal(tier(free).title, 'Free key — register, paste, done');
|
||||
const serialized = JSON.stringify(status);
|
||||
assert.ok(!serialized.includes('owned-fixture-token'));
|
||||
} finally {
|
||||
|
||||
@@ -729,6 +729,29 @@ test('Realtime service configuration selects compatible endpoint/model without f
|
||||
assert.doesNotMatch(response.body, /server-fixture|voice\.example/);
|
||||
});
|
||||
|
||||
test('the HUD summary status says whether a summary is configured, without calling OpenAI', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('the status probe must not reach OpenAI');
|
||||
});
|
||||
const handler = install(openAiRealtimeProxy()).get('/api/openai/hud-summary');
|
||||
for (const [key, configured] of [
|
||||
[undefined, false],
|
||||
[' ', false],
|
||||
['fixture-key', true],
|
||||
]) {
|
||||
env(t, 'OPENAI_API_KEY', key);
|
||||
const response = await request(handler, { url: '/status' });
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(response.json(), { configured });
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
assert.equal(
|
||||
(await request(handler, { url: '/' })).status,
|
||||
405,
|
||||
'only the status path answers GET',
|
||||
);
|
||||
});
|
||||
|
||||
test('OpenAI routes answer generically when the upstream or the request fails', async (t) => {
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
|
||||
@@ -6,6 +6,10 @@ import path from 'node:path';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
import { radioBrowserProxy } from '../../server/providers/radio.js';
|
||||
import { localProviderPlugins } from '../../server/providers/local.js';
|
||||
import { makeFixtureRoot } from './fixtureRoot.mjs';
|
||||
|
||||
const nominatimStorageDir = await makeFixtureRoot('gev-media-composition-');
|
||||
test.after(() => rmSync(nominatimStorageDir, { recursive: true, force: true }));
|
||||
|
||||
function install(plugin, hook = 'configureServer') {
|
||||
let handler;
|
||||
@@ -97,7 +101,7 @@ test('CCTV instances resolve their own application source root and isolate catal
|
||||
|
||||
test('composition creates exactly one CCTV and radio provider without acquisition', (t) => {
|
||||
isolate(t);
|
||||
const plugins = localProviderPlugins();
|
||||
const plugins = localProviderPlugins({ nominatimStorageDir });
|
||||
for (const factory of [cctvProxy, radioBrowserProxy]) {
|
||||
assert.equal(plugins.filter((p) => p.name === factory().name).length, 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,994 @@
|
||||
// Review regressions for the Nominatim gate: state that survives restarts and
|
||||
// sweeps, several processes sharing one count, long refusals, stalled bodies,
|
||||
// request-equivalent cache keys, and stricter outline selection.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
createGateStateStore,
|
||||
createNominatimGate,
|
||||
resolveNominatimSettings,
|
||||
PUBLIC_NOMINATIM_SEARCH,
|
||||
} from '../../server/providers/regional/nominatimGate.js';
|
||||
import { createNominatimCache } from '../../server/providers/regional/nominatimCache.js';
|
||||
import {
|
||||
outlineBias,
|
||||
selectOutlineResult,
|
||||
} from '../../server/providers/regional/outlineSelect.js';
|
||||
import {
|
||||
createNominatimOutlineProvider,
|
||||
createNominatimSearchProvider,
|
||||
geocodeProxy,
|
||||
} from '../../server/providers/regional/place.js';
|
||||
|
||||
const PUBLIC = {
|
||||
endpoint: PUBLIC_NOMINATIM_SEARCH,
|
||||
isPublic: true,
|
||||
dailyCap: 50,
|
||||
};
|
||||
const LOCAL = { ...PUBLIC, isPublic: false };
|
||||
const tempDir = (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-nominatim-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
};
|
||||
const quick = { sleep: async () => {}, minSpacingMs: 0 };
|
||||
const json = (value) => Response.json(value);
|
||||
const square = (lon, lat, d) => [
|
||||
[
|
||||
[lon, lat],
|
||||
[lon + d, lat],
|
||||
[lon + d, lat + d],
|
||||
[lon, lat + d],
|
||||
[lon, lat],
|
||||
],
|
||||
];
|
||||
|
||||
function mount(options) {
|
||||
let handler;
|
||||
geocodeProxy(options).configureServer({
|
||||
middlewares: { use: (_route, fn) => (handler = fn) },
|
||||
});
|
||||
return (url) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const res = {
|
||||
writableEnded: false,
|
||||
on() {},
|
||||
writeHead(status) {
|
||||
res.status = status;
|
||||
},
|
||||
end(body) {
|
||||
res.writableEnded = true;
|
||||
resolve({ status: res.status, body: JSON.parse(body) });
|
||||
},
|
||||
};
|
||||
Promise.resolve(
|
||||
handler(
|
||||
{
|
||||
method: 'GET',
|
||||
url,
|
||||
headers: {},
|
||||
socket: { remoteAddress: `10.9.${Math.random()}` },
|
||||
on() {},
|
||||
},
|
||||
res,
|
||||
),
|
||||
).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('the daily count survives cache sweeps and a restart of the real composition', async (t) => {
|
||||
const storageDir = tempDir(t);
|
||||
const fetchImpl = async () => json([]);
|
||||
const first = createNominatimGate({ settings: PUBLIC, fetchImpl, ...quick });
|
||||
const request = mount({ storageDir, gate: first });
|
||||
for (let i = 0; i < 3; i++)
|
||||
assert.equal((await request(`/?q=place-${i}`)).status, 200);
|
||||
assert.equal(first.stats().usedToday, 3);
|
||||
|
||||
// A foreign file in the answers directory and a full sweep leave the
|
||||
// state alone and the stranger untouched.
|
||||
const answers = path.join(storageDir, 'answers');
|
||||
fs.writeFileSync(path.join(answers, 'notes.json'), '{}');
|
||||
await createNominatimCache({ dir: answers, sweepEvery: 1 }).sweep();
|
||||
assert.ok(fs.existsSync(path.join(answers, 'notes.json')));
|
||||
assert.ok(fs.existsSync(path.join(storageDir, 'state.json')));
|
||||
|
||||
// Restart: a new gate composed on the same directory, which sweeps at start.
|
||||
const second = createNominatimGate({
|
||||
settings: { ...PUBLIC, dailyCap: 4 },
|
||||
fetchImpl,
|
||||
...quick,
|
||||
});
|
||||
const again = mount({ storageDir, gate: second });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(second.stats().usedToday, 3, 'the count came back');
|
||||
assert.equal((await again('/?q=place-new')).status, 200);
|
||||
const capped = await again('/?q=place-over');
|
||||
assert.equal(
|
||||
capped.status,
|
||||
429,
|
||||
'the cap still counts yesterday-restart usage',
|
||||
);
|
||||
assert.equal(capped.body.code, 'NOMINATIM_DAILY_CAP');
|
||||
});
|
||||
|
||||
test('a sweep removes expired and corrupt entries, never ones it could not read', async (t) => {
|
||||
const dir = tempDir(t);
|
||||
const file = (n) => path.join(dir, `${String(n).repeat(40)}.json`);
|
||||
const live = {
|
||||
key: 'k',
|
||||
payload: null,
|
||||
storedAt: 1,
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
fs.writeFileSync(file(1), JSON.stringify(live));
|
||||
fs.writeFileSync(file(2), JSON.stringify({ ...live, expiresAt: 1 }));
|
||||
fs.writeFileSync(file(3), '{not json');
|
||||
const cache = createNominatimCache({ dir });
|
||||
// Reads failing (a busy disk, a test double) must not empty the cache.
|
||||
const fsp = (await import('node:fs/promises')).default;
|
||||
const readFile = t.mock.method(fsp, 'readFile', async () => {
|
||||
throw Object.assign(new Error('EMFILE'), { code: 'EMFILE' });
|
||||
});
|
||||
await cache.sweep();
|
||||
assert.deepEqual(
|
||||
[1, 2, 3].map((n) => fs.existsSync(file(n))),
|
||||
[true, true, true],
|
||||
);
|
||||
readFile.mock.restore();
|
||||
await cache.sweep();
|
||||
assert.deepEqual(
|
||||
[1, 2, 3].map((n) => fs.existsSync(file(n))),
|
||||
[true, false, false],
|
||||
);
|
||||
});
|
||||
|
||||
test('processes sharing state space public sends and cap atomically', async (t) => {
|
||||
const dir = tempDir(t);
|
||||
const module = new URL(
|
||||
'../../server/providers/regional/nominatimGate.js',
|
||||
import.meta.url,
|
||||
).href;
|
||||
const run = (file, dailyCap) => {
|
||||
const script = `
|
||||
const { createGateStateStore } = await import(${JSON.stringify(module)});
|
||||
const store = createGateStateStore({ file: ${JSON.stringify(file)} });
|
||||
const latestStartAt = Date.now() + 10000;
|
||||
let sentAt = null;
|
||||
for (;;) {
|
||||
const result = store.dispatch(
|
||||
'2026-09-29',
|
||||
{ clock: Date.now, dailyCap: ${dailyCap}, minSpacingMs: 300, latestStartAt },
|
||||
() => {
|
||||
sentAt = Date.now();
|
||||
return Promise.resolve();
|
||||
},
|
||||
);
|
||||
if (result.status === 'wait') {
|
||||
await new Promise((resolve) => setTimeout(resolve, result.waitMs));
|
||||
continue;
|
||||
}
|
||||
console.log(JSON.stringify({ status: result.status, sentAt }));
|
||||
break;
|
||||
}
|
||||
`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
script,
|
||||
]);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => (stdout += chunk));
|
||||
child.stderr.on('data', (chunk) => (stderr += chunk));
|
||||
child.on('exit', (code) =>
|
||||
code === 0
|
||||
? resolve(JSON.parse(stdout))
|
||||
: reject(new Error(`exit ${code}: ${stderr}`)),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const spacedFile = path.join(dir, 'spaced.json');
|
||||
const sends = await Promise.all([
|
||||
run(spacedFile, 3),
|
||||
run(spacedFile, 3),
|
||||
run(spacedFile, 3),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
sends.map((result) => result.status),
|
||||
['started', 'started', 'started'],
|
||||
);
|
||||
// Spacing is measured between actual transport invocations.
|
||||
const sentAt = sends.map((result) => result.sentAt).sort((a, b) => a - b);
|
||||
for (let i = 1; i < sentAt.length; i += 1)
|
||||
assert.ok(
|
||||
sentAt[i] - sentAt[i - 1] >= 300,
|
||||
`${sentAt[i] - sentAt[i - 1]}ms apart`,
|
||||
);
|
||||
assert.equal(
|
||||
createGateStateStore({ file: spacedFile }).count('2026-09-29'),
|
||||
3,
|
||||
);
|
||||
|
||||
const cappedFile = path.join(dir, 'capped.json');
|
||||
const capped = await Promise.all([run(cappedFile, 1), run(cappedFile, 1)]);
|
||||
assert.deepEqual(capped.map((result) => result.status).sort(), [
|
||||
'cap',
|
||||
'started',
|
||||
]);
|
||||
assert.equal(
|
||||
createGateStateStore({ file: cappedFile }).count('2026-09-29'),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('independent public gates sharing state enforce spacing and cap', async (t) => {
|
||||
const dir = tempDir(t);
|
||||
const starts = [];
|
||||
const fetchImpl = async () => {
|
||||
starts.push(Date.now());
|
||||
return json([]);
|
||||
};
|
||||
const file = path.join(dir, 'spacing.json');
|
||||
const make = (options = {}) =>
|
||||
createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
fetchImpl,
|
||||
usage: createGateStateStore({ file: options.file || file }),
|
||||
minSpacingMs: options.minSpacingMs || 40,
|
||||
});
|
||||
await Promise.all([
|
||||
createNominatimSearchProvider({ gate: make() })('one', null),
|
||||
createNominatimSearchProvider({ gate: make() })('two', null),
|
||||
]);
|
||||
starts.sort((a, b) => a - b);
|
||||
assert.ok(starts[1] - starts[0] >= 35, `${starts[1] - starts[0]}ms apart`);
|
||||
|
||||
// A slow durable write must move the next admission relative to the actual
|
||||
// fetch invocation, not the timestamp reserved before that write.
|
||||
const stalledFile = path.join(dir, 'stalled.json');
|
||||
let clock = Date.UTC(2026, 8, 29, 12);
|
||||
const actualStarts = [];
|
||||
const renameSync = fs.renameSync;
|
||||
let stalled = false;
|
||||
const rename = t.mock.method(fs, 'renameSync', (...args) => {
|
||||
if (args[1] === stalledFile && !stalled) {
|
||||
stalled = true;
|
||||
clock += 1500;
|
||||
}
|
||||
return renameSync(...args);
|
||||
});
|
||||
const delayedGate = () =>
|
||||
createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
usage: createGateStateStore({ file: stalledFile }),
|
||||
now: () => clock,
|
||||
sleep: async (ms) => {
|
||||
clock += ms;
|
||||
},
|
||||
minSpacingMs: 1100,
|
||||
fetchImpl: async () => {
|
||||
actualStarts.push(clock);
|
||||
return json([]);
|
||||
},
|
||||
});
|
||||
try {
|
||||
await Promise.all([
|
||||
delayedGate().requestJson((endpoint) => `${endpoint}?q=delayed-one`),
|
||||
delayedGate().requestJson((endpoint) => `${endpoint}?q=delayed-two`),
|
||||
]);
|
||||
} finally {
|
||||
rename.mock.restore();
|
||||
}
|
||||
assert.equal(actualStarts.length, 2);
|
||||
assert.ok(actualStarts[1] - actualStarts[0] >= 1100);
|
||||
assert.equal(
|
||||
createGateStateStore({ file: stalledFile }).count('2026-09-29'),
|
||||
2,
|
||||
);
|
||||
|
||||
const capFile = path.join(dir, 'cap.json');
|
||||
let sent = 0;
|
||||
const capGate = () =>
|
||||
createNominatimGate({
|
||||
settings: { ...PUBLIC, dailyCap: 1 },
|
||||
fetchImpl: async () => {
|
||||
sent += 1;
|
||||
return json([]);
|
||||
},
|
||||
usage: createGateStateStore({ file: capFile }),
|
||||
minSpacingMs: 1,
|
||||
});
|
||||
const results = await Promise.allSettled([
|
||||
createNominatimSearchProvider({ gate: capGate() })('three', null),
|
||||
createNominatimSearchProvider({ gate: capGate() })('four', null),
|
||||
]);
|
||||
assert.equal(
|
||||
results.filter((result) => result.status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
results.find((result) => result.status === 'rejected').reason.code,
|
||||
'NOMINATIM_DAILY_CAP',
|
||||
);
|
||||
assert.equal(sent, 1);
|
||||
});
|
||||
|
||||
test('a persistence failure is reported and never lowers the count', (t) => {
|
||||
const dir = tempDir(t);
|
||||
const blocker = path.join(dir, 'not-a-dir');
|
||||
fs.writeFileSync(blocker, 'x');
|
||||
const errors = [];
|
||||
const store = createGateStateStore({
|
||||
file: path.join(blocker, 'state.json'),
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
store.increment('2026-09-29');
|
||||
store.increment('2026-09-29');
|
||||
assert.equal(store.count('2026-09-29'), 2);
|
||||
assert.equal(store.status().persisted, false);
|
||||
assert.ok(store.status().error);
|
||||
assert.equal(errors.length, 1, 'reported once, not per request');
|
||||
});
|
||||
|
||||
test('a persistence failure after fetch invocation aborts it and blocks restarts', async (t) => {
|
||||
const file = path.join(tempDir(t), 'state.json');
|
||||
let clock = Date.UTC(2026, 8, 29, 12);
|
||||
const markers = () =>
|
||||
fs
|
||||
.readdirSync(path.dirname(file))
|
||||
.filter((name) => name.startsWith('state.json.dispatch.'));
|
||||
const calls = [];
|
||||
const renameSync = fs.renameSync;
|
||||
let failStateWrite = false;
|
||||
const rename = t.mock.method(fs, 'renameSync', (...args) => {
|
||||
if (failStateWrite && args[1] === file) {
|
||||
failStateWrite = false;
|
||||
throw Object.assign(new Error('EIO'), { code: 'EIO' });
|
||||
}
|
||||
return renameSync(...args);
|
||||
});
|
||||
const make = (fetchImpl) =>
|
||||
createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
usage: createGateStateStore({ file, onError: () => {} }),
|
||||
now: () => clock,
|
||||
sleep: async (ms) => {
|
||||
clock += ms;
|
||||
},
|
||||
minSpacingMs: 1100,
|
||||
fetchImpl,
|
||||
});
|
||||
let durableAtInvocation;
|
||||
let requestSignal;
|
||||
const gate = make((url, { signal }) => {
|
||||
calls.push(url);
|
||||
durableAtInvocation = JSON.parse(fs.readFileSync(file, 'utf8')).count;
|
||||
requestSignal = signal;
|
||||
failStateWrite = true;
|
||||
return new Promise((_, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
gate.requestJson((endpoint) => `${endpoint}?q=x`),
|
||||
{
|
||||
code: 'NOMINATIM_STATE_UNAVAILABLE',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rename.mock.restore();
|
||||
}
|
||||
assert.equal(durableAtInvocation, 1, 'the count persisted before invocation');
|
||||
assert.ok(requestSignal.aborted);
|
||||
const orphan = markers();
|
||||
assert.equal(orphan.length, 1);
|
||||
assert.equal(gate.stats().persistence.dispatchPending, true);
|
||||
clock += 60_000;
|
||||
const restarted = make(async (url) => {
|
||||
calls.push(url);
|
||||
return json([]);
|
||||
});
|
||||
await assert.rejects(
|
||||
restarted.requestJson((endpoint) => `${endpoint}?q=y`),
|
||||
{
|
||||
code: 'NOMINATIM_STATE_UNAVAILABLE',
|
||||
},
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(markers(), orphan);
|
||||
assert.equal(createGateStateStore({ file }).count('2026-09-29'), 1);
|
||||
// Long past any request's timeout, the next lock owner reclaims it.
|
||||
clock += 10 * 60_000;
|
||||
const resumed = make(async (url) => {
|
||||
calls.push(url);
|
||||
return json([]);
|
||||
});
|
||||
assert.deepEqual(
|
||||
await resumed.requestJson((endpoint) => `${endpoint}?q=z`),
|
||||
[],
|
||||
);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(markers(), []);
|
||||
assert.equal(createGateStateStore({ file }).count('2026-09-29'), 2);
|
||||
});
|
||||
|
||||
test('a dispatch marker that names no start keeps refusing', async (t) => {
|
||||
const file = path.join(tempDir(t), 'state.json');
|
||||
fs.writeFileSync(`${file}.dispatch.123.456`, '');
|
||||
const gate = createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
usage: createGateStateStore({ file, onError: () => {} }),
|
||||
now: () => Date.UTC(2026, 8, 29, 12),
|
||||
sleep: async () => {},
|
||||
fetchImpl: async () =>
|
||||
assert.fail('no request while a dispatch is unfinished'),
|
||||
});
|
||||
await assert.rejects(
|
||||
gate.requestJson((endpoint) => `${endpoint}?q=x`),
|
||||
{
|
||||
code: 'NOMINATIM_STATE_UNAVAILABLE',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('parseable but invalid public usage state fails closed', async (t) => {
|
||||
const dir = tempDir(t);
|
||||
const file = path.join(dir, 'state.json');
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
day: '2026-09-30',
|
||||
count: '50',
|
||||
pausedUntil: '9999999999999',
|
||||
}),
|
||||
);
|
||||
let sent = 0;
|
||||
const gate = createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
fetchImpl: async () => {
|
||||
sent += 1;
|
||||
return json([]);
|
||||
},
|
||||
usage: createGateStateStore({ file, onError: () => {} }),
|
||||
...quick,
|
||||
});
|
||||
await assert.rejects(
|
||||
gate.requestJson((endpoint) => `${endpoint}?q=x`),
|
||||
{ code: 'NOMINATIM_STATE_UNAVAILABLE' },
|
||||
);
|
||||
assert.equal(sent, 0);
|
||||
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ day: '2026-09-30', count: 1, pausedUntil: 0 }),
|
||||
);
|
||||
const migrated = createGateStateStore({ file });
|
||||
assert.equal(
|
||||
migrated.dispatch(
|
||||
'2026-09-30',
|
||||
{ clock: () => 1000, dailyCap: 50, minSpacingMs: 1100 },
|
||||
() => Promise.resolve(),
|
||||
).status,
|
||||
'started',
|
||||
'the original state format without nextStartAt remains supported',
|
||||
);
|
||||
});
|
||||
|
||||
test('a long Retry-After is honoured in full and survives a restart', async (t) => {
|
||||
const file = path.join(tempDir(t), 'state.json');
|
||||
let clock = Date.UTC(2026, 8, 29, 12);
|
||||
const now = () => clock;
|
||||
const refused = createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
fetchImpl: async () =>
|
||||
new Response('', { status: 429, headers: { 'Retry-After': '86400' } }),
|
||||
usage: createGateStateStore({ file }),
|
||||
now,
|
||||
...quick,
|
||||
});
|
||||
await assert.rejects(
|
||||
createNominatimSearchProvider({ gate: refused })('x', null),
|
||||
{
|
||||
code: 'NOMINATIM_REFUSED',
|
||||
},
|
||||
);
|
||||
const calls = [];
|
||||
const restarted = createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
return json([]);
|
||||
},
|
||||
usage: createGateStateStore({ file }),
|
||||
now,
|
||||
...quick,
|
||||
});
|
||||
const search = createNominatimSearchProvider({ gate: restarted });
|
||||
clock += 7 * 3_600_000; // past the old six-hour ceiling
|
||||
await assert.rejects(search('y', null), (error) => {
|
||||
assert.equal(error.code, 'NOMINATIM_PAUSED');
|
||||
assert.equal(error.retryAfterMs, 17 * 3_600_000);
|
||||
return true;
|
||||
});
|
||||
assert.equal(calls.length, 0);
|
||||
clock += 17 * 3_600_000 + 1000;
|
||||
await search('y', null);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('an operator refusal pauses only the endpoint that refused', async () => {
|
||||
let active = {
|
||||
endpoint: 'https://a.example.test/search',
|
||||
isPublic: false,
|
||||
dailyCap: 50,
|
||||
};
|
||||
const calls = [];
|
||||
const gate = createNominatimGate({
|
||||
settings: () => active,
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(new URL(url).host);
|
||||
return calls.length === 1
|
||||
? new Response('', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '60' },
|
||||
})
|
||||
: json([]);
|
||||
},
|
||||
...quick,
|
||||
});
|
||||
const request = () => gate.requestJson((endpoint) => `${endpoint}?q=x`);
|
||||
await assert.rejects(request(), { code: 'NOMINATIM_REFUSED' });
|
||||
active = { ...active, endpoint: 'https://b.example.test/search' };
|
||||
assert.deepEqual(await request(), []);
|
||||
active = { ...active, endpoint: 'https://a.example.test/search' };
|
||||
await assert.rejects(request(), { code: 'NOMINATIM_PAUSED' });
|
||||
assert.deepEqual(calls, ['a.example.test', 'b.example.test']);
|
||||
});
|
||||
|
||||
test('a stalled body times out instead of holding the shared queue', async () => {
|
||||
let cancelled = 0;
|
||||
const stalled = (signal) => {
|
||||
const body = new ReadableStream({
|
||||
pull: () => new Promise(() => {}),
|
||||
cancel: () => {
|
||||
cancelled += 1;
|
||||
},
|
||||
});
|
||||
signal.addEventListener('abort', () => body.cancel().catch(() => {}));
|
||||
return new Response(body, { status: 200 });
|
||||
};
|
||||
let n = 0;
|
||||
const gate = createNominatimGate({
|
||||
settings: { ...PUBLIC, isPublic: false },
|
||||
fetchImpl: async (_url, { signal }) =>
|
||||
++n <= 2 ? stalled(signal) : json([]),
|
||||
timeoutMs: 150,
|
||||
});
|
||||
const search = createNominatimSearchProvider({ gate });
|
||||
const started = Date.now();
|
||||
await assert.rejects(search('stall', null), { code: 'NOMINATIM_TRANSPORT' });
|
||||
assert.ok(Date.now() - started < 3000);
|
||||
assert.ok(cancelled >= 1, 'the body read was cancelled');
|
||||
});
|
||||
|
||||
test('cache keys are request-equivalent: a miss for "Paris???" never answers "Paris"', async () => {
|
||||
const calls = [];
|
||||
const gate = createNominatimGate({
|
||||
settings: LOCAL,
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(new URL(url).searchParams.get('q'));
|
||||
return json(
|
||||
calls.length === 1
|
||||
? []
|
||||
: [
|
||||
{
|
||||
lat: '48.85',
|
||||
lon: '2.35',
|
||||
display_name: 'Paris, France',
|
||||
name: 'Paris',
|
||||
addresstype: 'city',
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
...quick,
|
||||
});
|
||||
const search = createNominatimSearchProvider({ gate });
|
||||
assert.equal((await search('Paris???', null)).status, 'ZERO_RESULTS');
|
||||
assert.equal((await search('Paris', null)).status, 'OK');
|
||||
assert.deepEqual(calls, ['Paris???', 'Paris']);
|
||||
// Whitespace-only differences are the same upstream request.
|
||||
assert.equal((await search(' Paris ', null)).cached, true);
|
||||
});
|
||||
|
||||
test('a malformed answer is a failure, not a cached "no result"', async () => {
|
||||
let shape = { error: 'Unexpected' };
|
||||
let calls = 0;
|
||||
const gate = createNominatimGate({
|
||||
settings: LOCAL,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return json(shape);
|
||||
},
|
||||
...quick,
|
||||
});
|
||||
const outline = createNominatimOutlineProvider({ gate });
|
||||
const search = createNominatimSearchProvider({ gate });
|
||||
await assert.rejects(outline({ query: 'Zilker Park', kind: 'landmark' }), {
|
||||
code: 'NOMINATIM_MALFORMED',
|
||||
});
|
||||
await assert.rejects(search('Zilker Park', null), {
|
||||
code: 'NOMINATIM_MALFORMED',
|
||||
});
|
||||
shape = [];
|
||||
assert.equal(
|
||||
(await outline({ query: 'Zilker Park', kind: 'landmark' })).status,
|
||||
'ZERO_RESULTS',
|
||||
);
|
||||
assert.equal(calls, 3, 'the malformed answers were not cached');
|
||||
});
|
||||
|
||||
test('cache entries are namespaced by endpoint', async () => {
|
||||
const cache = createNominatimCache();
|
||||
const hits = [];
|
||||
const gateFor = (endpoint) =>
|
||||
createNominatimGate({
|
||||
settings: { endpoint, isPublic: false, dailyCap: 50 },
|
||||
fetchImpl: async (url) => {
|
||||
hits.push(new URL(url).host);
|
||||
return json([]);
|
||||
},
|
||||
});
|
||||
await createNominatimSearchProvider({
|
||||
gate: gateFor('https://a.example.test/search'),
|
||||
cache,
|
||||
})('x', null);
|
||||
await createNominatimSearchProvider({
|
||||
gate: gateFor('https://b.example.test/search'),
|
||||
cache,
|
||||
})('x', null);
|
||||
assert.deepEqual(hits, ['a.example.test', 'b.example.test']);
|
||||
});
|
||||
|
||||
test('public-host detection ignores a trailing DNS dot', () => {
|
||||
assert.equal(
|
||||
resolveNominatimSettings({
|
||||
NOMINATIM_URL: 'https://nominatim.openstreetmap.org./search',
|
||||
}).isPublic,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('missing coordinates are no bias, never (0, 0)', () => {
|
||||
for (const [lat, lon] of [
|
||||
[null, null],
|
||||
['', ''],
|
||||
[undefined, 2],
|
||||
[48.8, null],
|
||||
])
|
||||
assert.equal(outlineBias('city', lat, lon), null, `${lat},${lon}`);
|
||||
assert.ok(outlineBias('city', 0, 0), 'a real (0, 0) is still a position');
|
||||
});
|
||||
|
||||
const boundary = (extra) => ({
|
||||
category: 'boundary',
|
||||
type: 'administrative',
|
||||
lat: '1',
|
||||
lon: '1',
|
||||
geojson: { type: 'Polygon', coordinates: square(1, 1, 0.1) },
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('administrative boundaries must be at the asked level', () => {
|
||||
const county = boundary({
|
||||
name: 'Travis County',
|
||||
addresstype: 'county',
|
||||
place_rank: 12,
|
||||
});
|
||||
const city = boundary({
|
||||
name: 'Austin',
|
||||
addresstype: 'city',
|
||||
place_rank: 16,
|
||||
});
|
||||
const state = boundary({
|
||||
name: 'Texas',
|
||||
addresstype: 'state',
|
||||
place_rank: 8,
|
||||
});
|
||||
assert.equal(
|
||||
selectOutlineResult([state], { kind: 'city', query: 'Texas' }).outline,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
selectOutlineResult([city], { kind: 'admin', query: 'Austin' }).outline,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
selectOutlineResult(
|
||||
[boundary({ name: 'Travis', addresstype: 'county', place_rank: 12 })],
|
||||
{ kind: 'city', query: 'Travis' },
|
||||
).outline,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
selectOutlineResult(
|
||||
[boundary({ name: 'Austin', addresstype: 'city', place_rank: 12 })],
|
||||
{ kind: 'admin', query: 'Austin' },
|
||||
).outline,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
selectOutlineResult([state], { kind: 'neighborhood', query: 'Texas' })
|
||||
.outline,
|
||||
null,
|
||||
);
|
||||
assert.ok(
|
||||
selectOutlineResult([county], { kind: 'admin', query: 'Travis County' })
|
||||
.outline,
|
||||
);
|
||||
assert.equal(
|
||||
selectOutlineResult([boundary({ name: 'Somewhere', place_rank: 16 })], {
|
||||
kind: 'city',
|
||||
query: 'Somewhere',
|
||||
}).outline,
|
||||
null,
|
||||
'rank alone is not administrative identity',
|
||||
);
|
||||
assert.equal(
|
||||
selectOutlineResult(
|
||||
[
|
||||
boundary({
|
||||
name: 'Somewhere',
|
||||
addresstype: 'locality',
|
||||
place_rank: 16,
|
||||
}),
|
||||
],
|
||||
{ kind: 'city', query: 'Somewhere' },
|
||||
).outline,
|
||||
null,
|
||||
'rank cannot replace an unrecognized address type',
|
||||
);
|
||||
assert.equal(
|
||||
selectOutlineResult([boundary({ name: 'Somewhere' })], {
|
||||
kind: 'city',
|
||||
query: 'Somewhere',
|
||||
}).outline,
|
||||
null,
|
||||
'no level evidence at all',
|
||||
);
|
||||
// An unsuitable early boundary does not block a suitable later one.
|
||||
const picked = selectOutlineResult(
|
||||
[
|
||||
state,
|
||||
boundary({
|
||||
name: 'Texas',
|
||||
addresstype: 'city',
|
||||
osm_type: 'relation',
|
||||
osm_id: 9,
|
||||
}),
|
||||
],
|
||||
{
|
||||
kind: 'city',
|
||||
query: 'Texas',
|
||||
},
|
||||
);
|
||||
assert.equal(picked.outline.osm, 'relation/9');
|
||||
});
|
||||
|
||||
test('outline selection does not publish child-owned admin geometry hooks', () => {
|
||||
for (const [name, addresstype] of [
|
||||
['Nepal', 'country', 'country'],
|
||||
['Bagmati Province', 'province', 'admin1'],
|
||||
['Province of Bagmati', 'province', 'admin1'],
|
||||
['Texas', 'state', 'admin1'],
|
||||
['Travis County', 'county', 'admin2'],
|
||||
['County of Travis', 'county', 'admin2'],
|
||||
['Kathmandu District', 'state_district', 'admin2'],
|
||||
]) {
|
||||
const { outline } = selectOutlineResult([boundary({ name, addresstype })], {
|
||||
kind: 'admin',
|
||||
query: name,
|
||||
});
|
||||
assert.equal('adminArea' in outline, false);
|
||||
assert.equal('adminLevel' in outline, false);
|
||||
}
|
||||
|
||||
for (const row of [
|
||||
boundary({ name: 'Northern Region', addresstype: 'region', place_rank: 8 }),
|
||||
boundary({
|
||||
name: 'Central District',
|
||||
addresstype: 'district',
|
||||
place_rank: 10,
|
||||
}),
|
||||
boundary({ name: 'Example State', addresstype: 'county' }),
|
||||
boundary({ name: 'County of Example', addresstype: 'state' }),
|
||||
boundary({ name: 'Province of Example', addresstype: 'county' }),
|
||||
boundary({ name: 'Example County', addresstype: 'state' }),
|
||||
boundary({ name: 'Example Province', addresstype: 'county' }),
|
||||
boundary({ name: 'County of Example Province', addresstype: 'county' }),
|
||||
]) {
|
||||
const { outline } = selectOutlineResult([row], {
|
||||
kind: 'admin',
|
||||
query: row.name,
|
||||
});
|
||||
assert.ok(outline, row.name);
|
||||
assert.equal(outline.adminArea, undefined, row.name);
|
||||
assert.equal(outline.adminLevel, undefined, row.name);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
selectOutlineResult(
|
||||
[boundary({ name: 'Mystery Province', place_rank: 8 })],
|
||||
{
|
||||
kind: 'admin',
|
||||
query: 'Mystery Province',
|
||||
},
|
||||
).outline,
|
||||
null,
|
||||
'rank-only metadata never creates an exact administrative outline',
|
||||
);
|
||||
|
||||
const retired = selectOutlineResult(
|
||||
[boundary({ name: 'Bagmati Zone', addresstype: 'state' })],
|
||||
{ kind: 'admin', query: 'Bagmati Zone' },
|
||||
).outline;
|
||||
assert.equal('adminArea' in retired, false);
|
||||
assert.equal('adminLevel' in retired, false);
|
||||
|
||||
const retiredPrefix = selectOutlineResult(
|
||||
[boundary({ name: 'Zone of Bagmati', addresstype: 'state' })],
|
||||
{ kind: 'admin', query: 'Zone of Bagmati' },
|
||||
).outline;
|
||||
assert.equal('adminArea' in retiredPrefix, false);
|
||||
assert.equal('adminLevel' in retiredPrefix, false);
|
||||
});
|
||||
|
||||
test('long admin names do not publish child-owned geometry hooks', () => {
|
||||
const longValidName = `Province of ${'Example '.repeat(24)}`.trim();
|
||||
const valid = selectOutlineResult(
|
||||
[boundary({ name: longValidName, addresstype: 'province' })],
|
||||
{ kind: 'admin', query: longValidName },
|
||||
).outline;
|
||||
assert.equal('adminArea' in valid, false);
|
||||
assert.equal('adminLevel' in valid, false);
|
||||
|
||||
const longContradictoryName = `${'Very Long Administrative Name '.repeat(7)}County`;
|
||||
assert.ok(longContradictoryName.length > 160);
|
||||
const contradictory = selectOutlineResult(
|
||||
[boundary({ name: longContradictoryName, addresstype: 'state' })],
|
||||
{ kind: 'admin', query: longContradictoryName },
|
||||
).outline;
|
||||
assert.ok(contradictory);
|
||||
assert.equal(contradictory.adminArea, undefined);
|
||||
assert.equal(contradictory.adminLevel, undefined);
|
||||
});
|
||||
|
||||
test('non-administrative outline kinds do not publish admin geometry hooks', () => {
|
||||
const city = selectOutlineResult(
|
||||
[
|
||||
boundary({
|
||||
name: 'County of Austin',
|
||||
addresstype: 'city',
|
||||
place_rank: 16,
|
||||
}),
|
||||
],
|
||||
{ kind: 'city', query: 'County of Austin' },
|
||||
).outline;
|
||||
const park = selectOutlineResult(
|
||||
[
|
||||
{
|
||||
category: 'leisure',
|
||||
type: 'park',
|
||||
name: 'Zilker Park',
|
||||
lat: '1',
|
||||
lon: '1',
|
||||
geojson: { type: 'Polygon', coordinates: square(1, 1, 0.01) },
|
||||
},
|
||||
],
|
||||
{ kind: 'landmark', query: 'Zilker Park' },
|
||||
).outline;
|
||||
for (const outline of [city, park]) {
|
||||
assert.equal(outline.adminArea, undefined);
|
||||
assert.equal(outline.adminLevel, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('building asks need building evidence', () => {
|
||||
const park = {
|
||||
category: 'tourism',
|
||||
type: 'theme_park',
|
||||
name: 'Big Top',
|
||||
lat: '1',
|
||||
lon: '1',
|
||||
geojson: { type: 'Polygon', coordinates: square(1, 1, 0.0001) },
|
||||
};
|
||||
const camp = { ...park, type: 'camp_site' };
|
||||
const campus = { ...park, category: 'amenity', type: 'university' };
|
||||
for (const row of [park, camp, campus])
|
||||
assert.equal(
|
||||
selectOutlineResult([row], { kind: 'building', query: 'Big Top' })
|
||||
.outline,
|
||||
null,
|
||||
row.type,
|
||||
);
|
||||
const hugeOffice = {
|
||||
...park,
|
||||
category: 'office',
|
||||
type: 'government',
|
||||
geojson: { type: 'Polygon', coordinates: square(1, 1, 0.01) },
|
||||
};
|
||||
assert.equal(
|
||||
selectOutlineResult([hugeOffice], { kind: 'building', query: 'Big Top' })
|
||||
.outline,
|
||||
null,
|
||||
);
|
||||
const office = {
|
||||
...hugeOffice,
|
||||
geojson: { type: 'Polygon', coordinates: square(1, 1, 0.001) },
|
||||
};
|
||||
assert.ok(
|
||||
selectOutlineResult([office], { kind: 'building', query: 'Big Top' })
|
||||
.outline,
|
||||
);
|
||||
const hall = { ...hugeOffice, category: 'building', type: 'yes' };
|
||||
assert.ok(
|
||||
selectOutlineResult([hall], { kind: 'building', query: 'Big Top' }).outline,
|
||||
);
|
||||
});
|
||||
|
||||
test('names match on whole words', () => {
|
||||
const row = (name) => ({
|
||||
category: 'leisure',
|
||||
type: 'park',
|
||||
name,
|
||||
lat: '1',
|
||||
lon: '1',
|
||||
geojson: { type: 'Polygon', coordinates: square(1, 1, 0.01) },
|
||||
});
|
||||
assert.equal(
|
||||
selectOutlineResult([row('Yorkshire Park')], {
|
||||
kind: 'landmark',
|
||||
query: 'York',
|
||||
}).outline,
|
||||
null,
|
||||
);
|
||||
assert.ok(
|
||||
selectOutlineResult([row('York Park')], { kind: 'landmark', query: 'York' })
|
||||
.outline,
|
||||
);
|
||||
assert.ok(
|
||||
selectOutlineResult([row('Zilker Metropolitan Park')], {
|
||||
kind: 'landmark',
|
||||
query: 'Zilker Park',
|
||||
}).outline,
|
||||
);
|
||||
assert.ok(
|
||||
selectOutlineResult(
|
||||
[{ ...row('新宿御苑'), namedetails: { name: '新宿御苑' } }],
|
||||
{ kind: 'landmark', query: '新宿御苑' },
|
||||
).outline,
|
||||
);
|
||||
assert.equal(
|
||||
selectOutlineResult([row('कुल मंदिर')], {
|
||||
kind: 'landmark',
|
||||
query: 'काली मंदिर',
|
||||
}).outline,
|
||||
null,
|
||||
);
|
||||
assert.ok(
|
||||
selectOutlineResult([row('काली मंदिर')], {
|
||||
kind: 'landmark',
|
||||
query: 'काली मंदिर',
|
||||
}).outline,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createGateStateStore } from '../../server/providers/regional/nominatimGate.js';
|
||||
|
||||
const moduleUrl = new URL(
|
||||
'../../server/providers/regional/nominatimGate.js',
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
function startModule(script) {
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const lines = [];
|
||||
const waiters = [];
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
const publish = (line) => {
|
||||
lines.push(line);
|
||||
for (const waiter of [...waiters]) {
|
||||
if (!line.startsWith(waiter.prefix)) continue;
|
||||
waiters.splice(waiters.indexOf(waiter), 1);
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(line);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
const parts = stdout.split('\n');
|
||||
stdout = parts.pop();
|
||||
for (const line of parts) publish(line);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
return {
|
||||
child,
|
||||
lines,
|
||||
waitFor(prefix, timeoutMs = 3000) {
|
||||
const existing = lines.find((line) => line.startsWith(prefix));
|
||||
if (existing) return Promise.resolve(existing);
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = { prefix, resolve, reject, timer: null };
|
||||
waiter.timer = setTimeout(() => {
|
||||
const index = waiters.indexOf(waiter);
|
||||
if (index >= 0) waiters.splice(index, 1);
|
||||
reject(
|
||||
new Error(
|
||||
`timed out waiting for ${prefix}; stderr: ${stderr.trim()}`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
waiters.push(waiter);
|
||||
});
|
||||
},
|
||||
exited: new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => resolve({ code, signal }));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function holderScript(lockFile) {
|
||||
return `
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
const database = new DatabaseSync(${JSON.stringify(lockFile)});
|
||||
database.exec('BEGIN IMMEDIATE');
|
||||
process.stdout.write('LOCKED\\n');
|
||||
process.stdin.once('data', () => {
|
||||
database.exec('ROLLBACK');
|
||||
database.close();
|
||||
process.stdout.write('RELEASED\\n');
|
||||
});
|
||||
process.stdin.resume();
|
||||
`;
|
||||
}
|
||||
|
||||
function contenderScript(stateFile, lockWaitMs) {
|
||||
return `
|
||||
const { createGateStateStore } = await import(${JSON.stringify(moduleUrl)});
|
||||
const store = createGateStateStore({
|
||||
file: ${JSON.stringify(stateFile)},
|
||||
lockWaitMs: ${lockWaitMs},
|
||||
onError: () => {},
|
||||
});
|
||||
process.stdout.write('ATTEMPTING\\n', () => {
|
||||
const result = store.dispatch(
|
||||
'2026-10-01',
|
||||
{ clock: () => 1000, dailyCap: 50, minSpacingMs: 1100 },
|
||||
() => Promise.resolve(),
|
||||
);
|
||||
delete result.value;
|
||||
process.stdout.write('RESULT:' + JSON.stringify(result) + '\\n');
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
test('a crashed lock owner cannot delete or bypass a successor lock', async (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-nominatim-lock-'));
|
||||
const stateFile = path.join(dir, 'state.json');
|
||||
const lockFile = `${stateFile}.lock.sqlite`;
|
||||
const children = [];
|
||||
t.after(() => {
|
||||
for (const process of children) {
|
||||
if (process.exitCode == null && process.signalCode == null)
|
||||
process.kill();
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const crashedOwner = startModule(holderScript(lockFile));
|
||||
children.push(crashedOwner.child);
|
||||
await crashedOwner.waitFor('LOCKED');
|
||||
const originalLock = fs.statSync(lockFile);
|
||||
|
||||
const blockedByCrashedOwner = startModule(contenderScript(stateFile, 0));
|
||||
children.push(blockedByCrashedOwner.child);
|
||||
const blockedResult = JSON.parse(
|
||||
(await blockedByCrashedOwner.waitFor('RESULT:')).slice('RESULT:'.length),
|
||||
);
|
||||
assert.equal(blockedResult.status, 'unavailable');
|
||||
assert.equal((await blockedByCrashedOwner.exited).code, 0);
|
||||
assert.equal(fs.existsSync(stateFile), false);
|
||||
|
||||
crashedOwner.child.kill();
|
||||
await crashedOwner.exited;
|
||||
|
||||
const successorOwner = startModule(holderScript(lockFile));
|
||||
children.push(successorOwner.child);
|
||||
await successorOwner.waitFor('LOCKED');
|
||||
assert.equal(fs.statSync(lockFile).ino, originalLock.ino);
|
||||
|
||||
const refused = startModule(contenderScript(stateFile, 0));
|
||||
children.push(refused.child);
|
||||
const refusedResult = JSON.parse(
|
||||
(await refused.waitFor('RESULT:')).slice('RESULT:'.length),
|
||||
);
|
||||
assert.equal(refusedResult.status, 'unavailable');
|
||||
assert.equal((await refused.exited).code, 0);
|
||||
assert.equal(
|
||||
createGateStateStore({ file: stateFile }).count('2026-10-01'),
|
||||
0,
|
||||
'a timed-out contender fails closed without spending a public slot',
|
||||
);
|
||||
assert.equal(fs.statSync(lockFile).ino, originalLock.ino);
|
||||
|
||||
successorOwner.child.stdin.end('release\n');
|
||||
await successorOwner.waitFor('RELEASED');
|
||||
assert.equal((await successorOwner.exited).code, 0);
|
||||
const released = createGateStateStore({ file: stateFile });
|
||||
const afterRelease = released.dispatch(
|
||||
'2026-10-01',
|
||||
{ clock: () => 2200, dailyCap: 50, minSpacingMs: 1100 },
|
||||
() => Promise.resolve(),
|
||||
);
|
||||
assert.equal(afterRelease.status, 'started');
|
||||
assert.equal(released.count('2026-10-01'), 1);
|
||||
assert.equal(fs.statSync(lockFile).ino, originalLock.ino);
|
||||
});
|
||||
|
||||
test('a public pause stays authoritative while another process owns the lock', async (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-nominatim-pause-'));
|
||||
const stateFile = path.join(dir, 'state.json');
|
||||
const lockFile = `${stateFile}.lock.sqlite`;
|
||||
const children = [];
|
||||
t.after(() => {
|
||||
for (const process of children) {
|
||||
if (process.exitCode == null && process.signalCode == null)
|
||||
process.kill();
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
stateFile,
|
||||
JSON.stringify({
|
||||
day: '2026-10-01',
|
||||
count: 1,
|
||||
pausedUntil: 0,
|
||||
nextStartAt: 0,
|
||||
}),
|
||||
);
|
||||
const owner = startModule(holderScript(lockFile));
|
||||
children.push(owner.child);
|
||||
await owner.waitFor('LOCKED');
|
||||
|
||||
const errors = [];
|
||||
const blockedWriter = createGateStateStore({
|
||||
file: stateFile,
|
||||
lockWaitMs: 0,
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
blockedWriter.pauseUntil(9000);
|
||||
assert.equal(errors.length, 1, 'the blocked JSON consolidation is reported');
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(stateFile, 'utf8')).pausedUntil,
|
||||
0,
|
||||
'the lock owner prevents an unsafe JSON overwrite',
|
||||
);
|
||||
|
||||
owner.child.stdin.end('release\n');
|
||||
await owner.waitFor('RELEASED');
|
||||
assert.equal((await owner.exited).code, 0);
|
||||
|
||||
const successor = createGateStateStore({ file: stateFile });
|
||||
let sent = 0;
|
||||
const send = () => {
|
||||
sent += 1;
|
||||
return Promise.resolve();
|
||||
};
|
||||
assert.deepEqual(
|
||||
successor.dispatch(
|
||||
'2026-10-01',
|
||||
{ clock: () => 1000, dailyCap: 50, minSpacingMs: 1100 },
|
||||
send,
|
||||
),
|
||||
{ status: 'paused', retryAfterMs: 8000 },
|
||||
'a fresh process observes the pending refusal pause',
|
||||
);
|
||||
assert.equal(sent, 0, 'a paused dispatch never starts the transport');
|
||||
const resumed = successor.dispatch(
|
||||
'2026-10-01',
|
||||
{ clock: () => 9001, dailyCap: 50, minSpacingMs: 1100 },
|
||||
send,
|
||||
);
|
||||
assert.equal(resumed.status, 'started');
|
||||
assert.equal(sent, 1);
|
||||
assert.equal(successor.count('2026-10-01'), 2);
|
||||
assert.equal(
|
||||
fs.readdirSync(dir).some((name) => name.startsWith('state.json.pause.')),
|
||||
false,
|
||||
'the next successful transaction consolidates and removes the marker',
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,16 +9,50 @@
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
fetchNominatimSearch,
|
||||
geocodeProxy,
|
||||
NOMINATIM_MAX_PENDING,
|
||||
} from '../../server/providers/regional/place.js';
|
||||
import { fetchRegionalJson } from '../../server/providers/regional/http.js';
|
||||
import {
|
||||
createNominatimGate,
|
||||
resolveNominatimSettings,
|
||||
PUBLIC_NOMINATIM_SEARCH,
|
||||
setSharedNominatimGate,
|
||||
} from '../../server/providers/regional/nominatimGate.js';
|
||||
import { localProviderPlugins } from '../../server/providers/local.js';
|
||||
import { standaloneConfig } from '../../server/standalone/vite.config.js';
|
||||
import { defaultSourceRoot } from '../../server/providers/common/source-root.js';
|
||||
import { createPlaceSearch, createGoogleGeocoder } from '../search/index.js';
|
||||
import { createPhotonGeocoder } from '../keylessGeocoder.js';
|
||||
|
||||
// Each case starts with a fresh shared gate: a pause after one case's
|
||||
// simulated outage must not decide the next case.
|
||||
// Pin the public-policy fixture independently of operator endpoint settings.
|
||||
// Each case's transport fails closed outside its active withUpstream block.
|
||||
let activeUpstream;
|
||||
test.beforeEach(() => {
|
||||
const transport = { fetch: null };
|
||||
activeUpstream = transport;
|
||||
setSharedNominatimGate(
|
||||
createNominatimGate({
|
||||
settings: resolveNominatimSettings({
|
||||
NOMINATIM_URL: PUBLIC_NOMINATIM_SEARCH,
|
||||
}),
|
||||
fetchImpl: (...args) => {
|
||||
if (!transport.fetch) throw new Error('No active test upstream');
|
||||
return transport.fetch(...args);
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
const storageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-search-route-'));
|
||||
test.after(() => fs.rmSync(storageRoot, { recursive: true, force: true }));
|
||||
|
||||
const HIT = [
|
||||
{
|
||||
lat: '43.1731',
|
||||
@@ -33,13 +67,16 @@ const HIT = [
|
||||
/** Mount the geocode route the way the dev server does. */
|
||||
let clientCounter = 0;
|
||||
|
||||
function mountGeocode(
|
||||
install = (server) => geocodeProxy().configureServer(server),
|
||||
) {
|
||||
function mountGeocode(install = null) {
|
||||
// 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;
|
||||
if (!install)
|
||||
install = (server) =>
|
||||
geocodeProxy({
|
||||
storageDir: path.join(storageRoot, `case-${clientCounter}`),
|
||||
}).configureServer(server);
|
||||
const remoteAddress = `10.0.0.${clientCounter % 250}`;
|
||||
const routes = new Map();
|
||||
install({
|
||||
@@ -93,14 +130,17 @@ function mountGeocode(
|
||||
/** Answer the upstream from a stub, counting and optionally stalling calls. */
|
||||
async function withUpstream(respond, run) {
|
||||
const previous = globalThis.fetch;
|
||||
const transport = activeUpstream;
|
||||
const previousUpstream = transport.fetch;
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
globalThis.fetch = transport.fetch = async (url, options) => {
|
||||
calls.push({ url: String(url), options });
|
||||
return await respond(String(url), options, calls.length);
|
||||
};
|
||||
try {
|
||||
return await run(calls);
|
||||
} finally {
|
||||
transport.fetch = previousUpstream;
|
||||
globalThis.fetch = previous;
|
||||
}
|
||||
}
|
||||
@@ -111,13 +151,55 @@ const jsonResponse = (value) =>
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
test('the route is part of the application on both dev and preview', () => {
|
||||
const plugin = localProviderPlugins().find(
|
||||
test('the real composition binds durable storage before dev or preview installation, independent of CWD', async () => {
|
||||
const cwd = process.cwd();
|
||||
const expected = path.join(defaultSourceRoot, '.gev-cache', 'nominatim');
|
||||
try {
|
||||
for (const suffix of ['one', 'two']) {
|
||||
const dir = path.join(storageRoot, `cwd-${suffix}`);
|
||||
fs.mkdirSync(dir);
|
||||
process.chdir(dir);
|
||||
const { NOMINATIM_CACHE_DIR } = await import(
|
||||
`../../server/providers/regional/place.js?cwd-${suffix}`
|
||||
);
|
||||
assert.equal(NOMINATIM_CACHE_DIR, expected);
|
||||
assert.equal(fs.existsSync(path.join(dir, '.gev-cache')), false);
|
||||
}
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
}
|
||||
const bound = [];
|
||||
setSharedNominatimGate({
|
||||
persistUsage: (file) => bound.push(file),
|
||||
});
|
||||
const storage = path.join(storageRoot, 'composition');
|
||||
const plugin = localProviderPlugins({ nominatimStorageDir: storage }).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');
|
||||
assert.deepEqual(bound, [path.join(storage, 'state.json')]);
|
||||
const standaloneStorage = path.join(storageRoot, 'standalone');
|
||||
const config = standaloneConfig(
|
||||
{ command: 'serve', mode: 'test' },
|
||||
{ nominatimStorageDir: standaloneStorage },
|
||||
);
|
||||
assert.ok(config.plugins.some((entry) => entry.name === 'geocode-proxy'));
|
||||
assert.deepEqual(bound, [
|
||||
path.join(storage, 'state.json'),
|
||||
path.join(standaloneStorage, 'state.json'),
|
||||
]);
|
||||
for (const value of [null, '', 'relative'])
|
||||
assert.throws(
|
||||
() => localProviderPlugins({ nominatimStorageDir: value }),
|
||||
/absolute directory/,
|
||||
);
|
||||
assert.equal(
|
||||
bound.length,
|
||||
2,
|
||||
'invalid bindings never construct policy state',
|
||||
);
|
||||
});
|
||||
|
||||
test('a search reaches the upstream identified, and the answer is cached', async () => {
|
||||
@@ -126,7 +208,9 @@ test('a search reaches the upstream identified, and the answer is cached', async
|
||||
async () => jsonResponse(HIT),
|
||||
async (calls) => {
|
||||
const query = `niagara-${Date.now()}`;
|
||||
const first = await request(`/api/geocode?q=${query}`);
|
||||
const first = await request(
|
||||
`/api/geocode?q=${query}&bounds=${encodeURIComponent('-20,170|-10,-171')}`,
|
||||
);
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(first.body.status, 'OK');
|
||||
assert.deepEqual(first.body.results[0].geometry.location, {
|
||||
@@ -134,6 +218,11 @@ test('a search reaches the upstream identified, and the answer is cached', async
|
||||
lng: -79.0384,
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(
|
||||
new URL(calls[0].url).searchParams.has('viewbox'),
|
||||
false,
|
||||
'a wrapped hint must not be coarsened into a Greenwich viewbox',
|
||||
);
|
||||
const headers = calls[0].options.headers;
|
||||
assert.match(
|
||||
String(headers['User-Agent']),
|
||||
@@ -147,7 +236,7 @@ test('a search reaches the upstream identified, and the answer is cached', async
|
||||
'a fixed API endpoint may not be redirected elsewhere',
|
||||
);
|
||||
|
||||
// The policy asks that results be cached; a repeat costs no request.
|
||||
// Dropping the invalid hint also shares the unbiased cache entry.
|
||||
const repeat = await request(`/api/geocode?q=${query}`);
|
||||
assert.equal(repeat.status, 200);
|
||||
assert.equal(
|
||||
|
||||
@@ -77,13 +77,15 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
|
||||
// against the code that decides them.
|
||||
const { PanelPositionControls } =
|
||||
await import('../ui/panelPositionControls.js');
|
||||
const { presentPanelLocally } = await import('../testSupport/panelState.mjs');
|
||||
const { readPanelState } = await import('../ui/rail/panelState.js');
|
||||
const html = await readFile(
|
||||
path.join(root, 'src/ui/templates/layer-panels.html'),
|
||||
path.join(root, 'src/ui/views/panels/CctvPanel.vue'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/<div id="cctv-panel" class="panel-collapsible collapsed"/,
|
||||
/<RailPanel id="cctv-panel"[^>]*\bstart-collapsed\b/,
|
||||
'the CCTV panel starts collapsed in the markup',
|
||||
);
|
||||
|
||||
@@ -110,7 +112,8 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
|
||||
};
|
||||
try {
|
||||
const controls = new PanelPositionControls({
|
||||
syncPanelCollapseButton: () => {},
|
||||
presentPanel: presentPanelLocally,
|
||||
shownPanel: readPanelState,
|
||||
layoutRightPanels: () => {},
|
||||
syncCctvPanelViewport: () => {},
|
||||
showToast: () => {},
|
||||
|
||||
@@ -7,10 +7,14 @@ import { localProviderPlugins } from '../../server/providers/local.js';
|
||||
import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js';
|
||||
import { makeFixtureRoot } from './fixtureRoot.mjs';
|
||||
|
||||
const nominatimStorageDir = await makeFixtureRoot('gev-preview-composition-');
|
||||
test.after(() => rm(nominatimStorageDir, { recursive: true, force: true }));
|
||||
|
||||
test('data providers have both hooks; credential editing stays development-only', () => {
|
||||
const plugins = localProviderPlugins();
|
||||
const plugins = localProviderPlugins({ nominatimStorageDir });
|
||||
assert.ok(plugins.some(({ name }) => name === 'fire-perimeters'));
|
||||
assert.ok(plugins.some(({ name }) => name === 'gnss-integrity'));
|
||||
assert.ok(plugins.some(({ name }) => name === 'ghostmaps'));
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.name === 'gev-key-setup') {
|
||||
assert.equal(plugin.configurePreviewServer, undefined);
|
||||
@@ -88,7 +92,10 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
|
||||
for (const isPreview of [false, true]) {
|
||||
const config = {
|
||||
...base,
|
||||
plugins: [...localProviderPlugins(), apiNotFoundPlugin()],
|
||||
plugins: [
|
||||
...localProviderPlugins({ nominatimStorageDir }),
|
||||
apiNotFoundPlugin(),
|
||||
],
|
||||
server: { host: '127.0.0.1', port: 0, hmr: false },
|
||||
preview: { host: '127.0.0.1', port: 0 },
|
||||
};
|
||||
@@ -105,6 +112,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
|
||||
['/api/firms/status', 200],
|
||||
['/api/fire-perimeters/inciweb/publication/invalid', 400],
|
||||
['/api/gnss-integrity?lat=invalid', 400],
|
||||
['/api/ghostmaps/unknown', 404],
|
||||
['/api/terrain/heights?points=invalid', 400],
|
||||
['/api/overpass', 405],
|
||||
['/api/overpass/status', 200],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
CUBE_FACES,
|
||||
@@ -24,7 +25,7 @@ test('painted-out planets leave no disc brighter than the sky around them', asyn
|
||||
}));
|
||||
for (const face of CUBE_FACES) {
|
||||
const { data, info } = await sharp(
|
||||
new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname,
|
||||
fileURLToPath(new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY)),
|
||||
)
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -8,7 +9,8 @@ import {
|
||||
detectOn,
|
||||
} from '../../scripts/qa-transit-controls.mjs';
|
||||
import { createStyleParameters } from '../ui/styleParameters.js';
|
||||
import { bindDisplayControls } from '../ui/displayControls.js';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useDisplayStore } from '../ui/stores/display.js';
|
||||
import { thermalShader } from '../styles/thermal.js';
|
||||
import {
|
||||
cycleMode,
|
||||
@@ -18,6 +20,9 @@ import {
|
||||
} from '../data/detection.js';
|
||||
|
||||
const pause = async () => {};
|
||||
// The page fixture swaps the globals for stand-ins; generated rows render
|
||||
// into a real element, so keep the registered document.
|
||||
const realDocument = globalThis.document;
|
||||
class Element extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
@@ -67,8 +72,7 @@ function pageFixture(t) {
|
||||
const label = selector.match(/aria-label="([^"]+)"/)[1];
|
||||
return elements
|
||||
.get('param-sliders')
|
||||
.children.flatMap((row) => row.children)
|
||||
.find((el) => el.getAttribute('aria-label') === label);
|
||||
.querySelector(`input.param-slider[aria-label="${label}"]`);
|
||||
}
|
||||
return elements.get(selector.slice(1));
|
||||
};
|
||||
@@ -128,8 +132,7 @@ test('live visual cities follow local operating hours in summer and winter', ()
|
||||
|
||||
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;
|
||||
const container = realDocument.createElement('div');
|
||||
elements.set('param-sliders', container);
|
||||
const parameters = createStyleParameters({ container });
|
||||
t.after(() => parameters.destroy());
|
||||
@@ -159,9 +162,7 @@ test('thermal controls wait for activation and use the real generated parameter
|
||||
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);
|
||||
const slider = container.querySelector(`[aria-label="${label}"]`);
|
||||
assert.ok(slider);
|
||||
return fn(slider, ...args);
|
||||
};
|
||||
@@ -189,26 +190,32 @@ test('DETECT uses the real OFF/restore button and density input to reach DENSE',
|
||||
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) });
|
||||
},
|
||||
// As DisplayPanel does: the toggle and the slider call the Display
|
||||
// controls' actions through the display store.
|
||||
const store = useDisplayStore(createPinia());
|
||||
const token = store.connect({
|
||||
cycleDetection() {
|
||||
cycleMode();
|
||||
modes.push(getMode());
|
||||
},
|
||||
setDensity(value) {
|
||||
setDetectionTuning({ densityPct: Number(value) });
|
||||
},
|
||||
});
|
||||
const onClick = () => store.act('cycleDetection');
|
||||
const onInput = () => store.act('setDensity', slider.value);
|
||||
button.addEventListener('click', onClick);
|
||||
slider.addEventListener('input', onInput);
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
button.removeEventListener('click', onClick);
|
||||
slider.removeEventListener('input', onInput);
|
||||
store.detach(token);
|
||||
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),
|
||||
new URL('../ui/views/panels/DisplayPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(template, /id="detection-density-slider"/);
|
||||
|
||||
@@ -16,6 +16,9 @@ import * as providers from '../../server/providers/local.js';
|
||||
import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js';
|
||||
import { makeFixtureRoot } from './fixtureRoot.mjs';
|
||||
|
||||
const nominatimStorageDir = await makeFixtureRoot('gev-build-composition-');
|
||||
test.after(() => rm(nominatimStorageDir, { recursive: true, force: true }));
|
||||
|
||||
test('explicit build inputs preserve browser-only defines, plugin order and loopback protections', () => {
|
||||
const plugin = { name: 'fixture-provider' };
|
||||
const config = createBrowserViteConfig({
|
||||
@@ -29,6 +32,7 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
||||
assert.equal(config.plugins[0].enforce, 'pre');
|
||||
assert.equal(config.plugins[1].name, 'host-check');
|
||||
assert.equal(config.plugins[1].enforce, 'pre');
|
||||
assert.equal(config.plugins[3].name, 'vite:vue');
|
||||
assert.equal(config.plugins[4], plugin);
|
||||
assert.equal(config.server.host, 'localhost');
|
||||
assert.equal(config.server.port, 4173);
|
||||
@@ -64,10 +68,34 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
||||
assert.ok(!csp.includes("script-src 'self' 'unsafe-inline'"));
|
||||
assert.deepEqual(config.preview.headers, config.server.headers);
|
||||
assert.deepEqual(config.define, {
|
||||
__VUE_I18N_FULL_INSTALL__: 'false',
|
||||
__VUE_I18N_LEGACY_API__: 'false',
|
||||
__INTLIFY_PROD_DEVTOOLS__: 'false',
|
||||
__INTLIFY_DROP_MESSAGE_COMPILER__: 'true',
|
||||
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
|
||||
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
|
||||
'import.meta.env.MAPILLARY_CLIENT_TOKEN': '""',
|
||||
});
|
||||
assert.equal(
|
||||
createBrowserViteConfig({ photonUrl: '' }).define[
|
||||
'import.meta.env.PHOTON_URL'
|
||||
],
|
||||
'null',
|
||||
'an empty PHOTON_URL reaches the browser only as the disabled sentinel',
|
||||
);
|
||||
assert.equal(
|
||||
createBrowserViteConfig({ photonUrl: 'https://u:[email protected]/api/' })
|
||||
.define['import.meta.env.PHOTON_URL'],
|
||||
'null',
|
||||
'credentials are never serialized into browser assets',
|
||||
);
|
||||
assert.equal(
|
||||
createBrowserViteConfig({ photonUrl: 'http://localhost:2322/api/' }).define[
|
||||
'import.meta.env.PHOTON_URL'
|
||||
],
|
||||
'null',
|
||||
'plain http would be blocked by connect-src, so it is refused here',
|
||||
);
|
||||
assert.equal(
|
||||
createBrowserViteConfig({ mapillaryToken: 'MLY|1|abc' }).define[
|
||||
'import.meta.env.MAPILLARY_CLIENT_TOKEN'
|
||||
@@ -115,7 +143,7 @@ test('build helper does not discover environment values or construct local provi
|
||||
test('root config retains existing named exports and standalone provider order', () => {
|
||||
for (const [name, value] of Object.entries(providers))
|
||||
assert.equal(compatibility[name], value, name);
|
||||
const config = standaloneConfig({ mode: 'test' });
|
||||
const config = standaloneConfig({ mode: 'test' }, { nominatimStorageDir });
|
||||
const standaloneProviders = config.plugins
|
||||
.slice(4, -6)
|
||||
.map((plugin) => plugin.name);
|
||||
@@ -129,7 +157,9 @@ test('root config retains existing named exports and standalone provider order',
|
||||
);
|
||||
assert.deepEqual(
|
||||
standaloneProviders.filter((name) => name !== 'gemini-live-proxy'),
|
||||
providers.localProviderPlugins().map((plugin) => plugin.name),
|
||||
providers
|
||||
.localProviderPlugins({ nominatimStorageDir })
|
||||
.map((plugin) => plugin.name),
|
||||
);
|
||||
assert.equal(config.plugins.at(-7).name, 'gev-key-setup');
|
||||
// Token fallback follows real providers and local MCP before the API 404.
|
||||
@@ -142,10 +172,26 @@ test('root config retains existing named exports and standalone provider order',
|
||||
assert.equal(config.plugins.at(-1).name, 'panel-build');
|
||||
});
|
||||
|
||||
test('standalone config passes a validated Photon setting into the browser define', () => {
|
||||
const previous = process.env.PHOTON_URL;
|
||||
process.env.PHOTON_URL = 'https://photon.example.test/api/';
|
||||
try {
|
||||
assert.equal(
|
||||
standaloneConfig({ mode: 'test' }, { nominatimStorageDir }).define[
|
||||
'import.meta.env.PHOTON_URL'
|
||||
],
|
||||
'"https://photon.example.test/api/"',
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.PHOTON_URL;
|
||||
else process.env.PHOTON_URL = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('directories the server writes while it runs are kept out of the dev watcher', () => {
|
||||
// Providers write cache files and logs here while the app runs; none of it is
|
||||
// source, so the dev watcher has no reason to stat it.
|
||||
const config = standaloneConfig({ mode: 'test' });
|
||||
const config = standaloneConfig({ mode: 'test' }, { nominatimStorageDir });
|
||||
const plugin = config.plugins.find(
|
||||
({ name }) => name === 'gev-unwatched-runtime-dirs',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
cachedVueModule,
|
||||
compileVueModule,
|
||||
ownCacheDirectory,
|
||||
vueCacheDirectory,
|
||||
vueCacheKey,
|
||||
} from '../../build/vueLoader.js';
|
||||
|
||||
const SOURCE =
|
||||
'<script setup>\nconst label = "Hi";\n</script>\n<template><b>{{ label }}</b></template>\n';
|
||||
const FILE = '/virtual/Greeting.vue';
|
||||
|
||||
test('a component compiles once and later processes read the cached module', (t) => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const compiled = cachedVueModule(SOURCE, FILE, directory);
|
||||
assert.equal(compiled, compileVueModule(SOURCE, FILE));
|
||||
const entries = readdirSync(directory);
|
||||
assert.deepEqual(entries, [`${vueCacheKey(SOURCE, FILE)}.mjs`]);
|
||||
// A cached entry is returned as stored, without compiling again.
|
||||
writeFileSync(path.join(directory, entries[0]), 'export default "cached";');
|
||||
assert.equal(
|
||||
cachedVueModule(SOURCE, FILE, directory),
|
||||
'export default "cached";',
|
||||
);
|
||||
});
|
||||
|
||||
test('an edited component or another path is a new cache entry', () => {
|
||||
const key = vueCacheKey(SOURCE, FILE);
|
||||
assert.notEqual(vueCacheKey(`${SOURCE}\n`, FILE), key);
|
||||
assert.notEqual(vueCacheKey(SOURCE, '/virtual/Other.vue'), key);
|
||||
assert.equal(vueCacheKey(SOURCE, FILE), key);
|
||||
});
|
||||
|
||||
test('GEV_VUE_CACHE=0 compiles every time; otherwise the cache sits in a per-user directory', () => {
|
||||
assert.equal(vueCacheDirectory({ GEV_VUE_CACHE: '0' }), null);
|
||||
if (process.platform !== 'win32') {
|
||||
const directory = vueCacheDirectory({});
|
||||
assert.equal(path.dirname(directory), tmpdir());
|
||||
assert.match(path.basename(directory), /^gev-vue-sfc-/);
|
||||
}
|
||||
// Windows temp directories may be shared, so the cache lives in the
|
||||
// user's profile, or not at all.
|
||||
assert.equal(
|
||||
vueCacheDirectory(
|
||||
{ LOCALAPPDATA: 'C:\\Users\\a\\AppData\\Local' },
|
||||
'win32',
|
||||
),
|
||||
path.join('C:\\Users\\a\\AppData\\Local', 'gev-vue-sfc'),
|
||||
);
|
||||
assert.equal(vueCacheDirectory({}, 'win32'), null);
|
||||
assert.equal(
|
||||
cachedVueModule(SOURCE, FILE, null),
|
||||
compileVueModule(SOURCE, FILE),
|
||||
);
|
||||
});
|
||||
|
||||
test('a cache directory that cannot be written still returns the compiled module', (t) => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
// A file where the cache directory should be makes every write fail.
|
||||
const blocked = path.join(directory, 'blocked');
|
||||
writeFileSync(blocked, '');
|
||||
assert.equal(
|
||||
cachedVueModule(SOURCE, FILE, blocked),
|
||||
compileVueModule(SOURCE, FILE),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'a cache directory others can write to, or a link to one, is never read',
|
||||
{
|
||||
skip: process.platform === 'win32',
|
||||
},
|
||||
(t) => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const shared = path.join(root, 'shared');
|
||||
mkdirSync(shared);
|
||||
chmodSync(shared, 0o777);
|
||||
// A module planted where the cache would look must not run.
|
||||
writeFileSync(
|
||||
path.join(shared, `${vueCacheKey(SOURCE, FILE)}.mjs`),
|
||||
'export default "planted";',
|
||||
);
|
||||
assert.equal(ownCacheDirectory(shared), false);
|
||||
assert.equal(
|
||||
cachedVueModule(SOURCE, FILE, shared),
|
||||
compileVueModule(SOURCE, FILE),
|
||||
);
|
||||
const own = path.join(root, 'own');
|
||||
mkdirSync(own, { mode: 0o700 });
|
||||
const link = path.join(root, 'link');
|
||||
symlinkSync(own, link);
|
||||
assert.equal(ownCacheDirectory(link), false);
|
||||
assert.equal(ownCacheDirectory(own), true);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user