Merge branch 'main' into german/map-render-error-state

This commit is contained in:
German Khokhlov committed 2026-11-13 18:47:36 +04:00
commit d42b6fe764
329 files changed
+47020 -1080

No files matched your search

File diff suppressed because it is too large. Load diff
+45
View File
@@ -385,6 +385,8 @@
"src/layers/vessels/index.js",
"src/layers/vessels/ingestion.js",
"src/layers/vessels/lifecycle.js",
"src/layers/vessels/photos.js",
"src/layers/vessels/photos.test.mjs",
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
"src/layers/vessels/rendering.js",
@@ -853,6 +855,45 @@
"scripts/qa-transit-recovery.mjs",
"scripts/format-runtime.json",
"src/tooling/transitService.test.mjs",
"src/tooling/railService.test.mjs",
"src/data/railFeeds.test.mjs",
"src/data/railProxy.test.mjs",
"src/layers/rail/policy.test.mjs",
"src/layers/rail/source.test.mjs",
"src/layers/rail/pathBinding.test.mjs",
"src/layers/rail/routeLines.test.mjs",
"src/layers/rail/stopLabels.test.mjs",
"src/layers/rail/schedules.test.mjs",
"src/data/railPaths.test.mjs",
"src/data/railSchedule.test.mjs",
"src/data/railTripUpdates.test.mjs",
"src/data/irishRail.test.mjs",
"src/tooling/buildRailPaths.test.mjs",
"src/tooling/buildRailPathsRoutes.test.mjs",
"scripts/build-rail-paths.mjs",
"src/tooling/buildRailPathsUnlocated.test.mjs",
"src/layers/rail/follow.test.mjs",
"src/layers/rail/groundHeight.test.mjs",
"src/layers/rail/liveOnly.test.mjs",
"src/testSupport/railLayerHarness.mjs",
"src/layers/rail/dedupe.test.mjs",
"src/data/railIreland.test.mjs",
"src/data/railNoPositions.test.mjs",
"src/data/railFeedsNational.test.mjs",
"src/data/railSiriVm.test.mjs",
"src/tooling/buildRailPathsTripEnds.test.mjs",
"src/data/railFeedsUs.test.mjs",
"src/data/railFeedsAsia.test.mjs",
"src/data/railFeedsFrance.test.mjs",
"src/data/railFeedsUsNoTerms.test.mjs",
"src/data/railFeedsUsUrban.test.mjs",
"src/tooling/buildRailPathsLocal.test.mjs",
"src/tooling/buildRailPathsWrite.test.mjs",
"src/tooling/railServiceFaults.test.mjs",
"src/tooling/railCatalogPacks.test.mjs",
"src/tooling/railServicePacks.test.mjs",
"src/tooling/buildRailPathsCli.test.mjs",
"src/data/railFeedsDocs.test.mjs",
"src/sources/overpassFeatures.test.mjs",
"src/data/cctvHlsStream.test.mjs",
"src/layers/cctv/videoPlayback.test.mjs",
@@ -1302,9 +1343,13 @@
"src/app/sourceComposition.js",
"src/app/sourceComposition.test.mjs",
"src/data/dataCredits.js",
"src/data/openVesselPhoto.js",
"src/data/openVesselPhoto.test.mjs",
"src/data/layerState.js",
"src/data/layerState.test.mjs",
"src/data/spriteOrder.js",
"src/i18n/page.js",
"src/i18n/page.test.mjs",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env node
/**
* Data Library maintenance. See docs/DATA-LIBRARY.md.
*
* npm run library:check [-- --base-ref origin/main]
* Validate every manifest, the share-token ledger and the generated
* DATA_SOURCES.md section. With --base-ref, also prove the ledger only
* appended rows since that ref (published tokens never change).
* npm run library:next -- <entry-id>
* Print the next free share token for a new switchable entry.
* npm run library:docs
* Rewrite the generated Data Library section of DATA_SOURCES.md.
* npm run library:probe [-- --json] [-- --strict]
* Hit every dataset and tile upstream once (bounded) and report
* status, latency and feature count. --strict exits 1 on a failure.
*/
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { LIBRARY_ENTRIES, LIBRARY_LEDGER } from '../src/library/catalog.js';
import { libraryTileUrl } from '../src/library/manifest.js';
import {
arcgisPageUrl,
datasetRequestUrl,
parseLibraryBody,
} from '../src/library/formats.js';
import {
nextLibraryToken,
readLibraryLedger,
} from '../src/library/selection.js';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const DATA_SOURCES = path.join(root, 'DATA_SOURCES.md');
const START = '<!-- data-library:start (generated by npm run library:docs) -->';
const END = '<!-- data-library:end -->';
const LEDGER_PATH = 'src/library/ledger.json';
const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s+/g, ' ');
/** The generated DATA_SOURCES.md section, one row per entry. */
export function libraryDocsSection(entries = LIBRARY_ENTRIES) {
const rows = [...entries]
.sort((a, b) => a.id.localeCompare(b.id))
.map((entry) => {
const host = (() => {
try {
return new URL(entry.source.url.replace(/\{[xyz]\}/g, '0')).host;
} catch {
return '';
}
})();
const how =
entry.kind === 'pack'
? `CCTV pack; operator sets \`${entry.pack.env}=1\``
: `${entry.kind}, ${entry.source.format}, ${entry.source.fetch}` +
(entry.licence.operatorOptIn
? `; operator adds \`${entry.id}\` to \`GEV_LIBRARY_ENABLE\``
: '') +
(entry.source.key
? `; needs the \`${entry.source.key.id}\` key`
: '');
return `| **${cell(entry.title)}** (\`${entry.id}\`, \`${host}\`) | ${cell(how)} | [${cell(entry.licence.name)}](${entry.licence.url}) | "${cell(entry.attribution.text)}" |`;
});
return [
START,
'',
'## Data Library entries (fetched at runtime — not stored in this repo)',
'',
'Each Data Library entry is a manifest in `src/library/entries/` pointing at a live upstream its provider hosts; see [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). An entry appears on the map only after a viewer adds it from **DATA LAYERS ▸ Browse data library**, and its attribution is registered in the Data attribution popover while it is in use. This table is generated from the manifests: edit the manifest, then run `npm run library:docs`.',
'',
'| Entry | How it is served | Licence / terms | Attribution |',
'| --- | --- | --- | --- |',
...rows,
'',
END,
].join('\n');
}
function replaceSection(text, section) {
const start = text.indexOf(START);
const end = text.indexOf(END);
if (start < 0 || end < start) {
const anchor = text.indexOf('\n## Bundled snapshots');
if (anchor < 0)
throw new Error('DATA_SOURCES.md has no Bundled snapshots heading');
return `${text.slice(0, anchor)}\n${section}\n${text.slice(anchor)}`;
}
return text.slice(0, start) + section + text.slice(end + END.length);
}
function check(baseRef) {
const errors = [];
const ledger = readLibraryLedger(LIBRARY_LEDGER);
errors.push(...ledger.errors.map((error) => `ledger ${error}`));
for (const entry of LIBRARY_ENTRIES) {
const switchable = entry.kind !== 'pack';
if (switchable && !ledger.tokenById.has(entry.id))
errors.push(
`${entry.id}: no share token (npm run library:next -- ${entry.id})`,
);
if (!switchable && ledger.tokenById.has(entry.id))
errors.push(
`${entry.id}: packs are switched by their host and take no token`,
);
}
if (baseRef) {
let published = [];
try {
published = JSON.parse(
execFileSync('git', ['show', `${baseRef}:${LEDGER_PATH}`], {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}),
);
} catch {
published = [];
}
published.forEach(([id, token], index) => {
const row = LIBRARY_LEDGER[index];
if (!row || row[0] !== id || row[1] !== token)
errors.push(
`ledger row ${index} (${id} → ${token}) was published on ${baseRef} and must not change`,
);
});
LIBRARY_LEDGER.slice(published.length).forEach(([id, token], offset) => {
const expected = nextLibraryToken(
LIBRARY_LEDGER.slice(0, published.length + offset),
);
if (token !== expected)
errors.push(
`ledger row for ${id} should take ${expected}, not ${token}`,
);
});
}
const text = readFileSync(DATA_SOURCES, 'utf8');
if (!text.includes(libraryDocsSection()))
errors.push(
'DATA_SOURCES.md Data Library section is out of date (npm run library:docs)',
);
if (errors.length) {
for (const error of errors) console.error(`✗ ${error}`);
process.exit(1);
}
console.log(
`Data Library valid: ${LIBRARY_ENTRIES.length} entries, ${LIBRARY_LEDGER.length} share tokens${baseRef ? `, ledger append-only against ${baseRef}` : ''}.`,
);
}
/** Points inside a coverage bbox to try, centre first, as [fx, fy] shares. */
const PROBE_POINTS = [
[0.5, 0.5],
[0.4, 0.45],
[0.6, 0.55],
[0.25, 0.25],
[0.75, 0.75],
];
/**
* A Web Mercator tile to probe. Regional entries try points inside their
* coverage bbox (a regional server answers 404 or a blank fill outside its
* photos, and a bbox centre can be sea); global entries probe a mid-world
* tile.
*/
function probeTile(entry, z, [fx, fy] = PROBE_POINTS[0]) {
const n = 2 ** z;
const bbox = entry.coverage?.bbox;
if (!bbox) return { x: Math.floor(n / 2), y: Math.floor(n / 3) };
const lon = bbox[0] + (bbox[2] - bbox[0]) * fx;
const lat = Math.max(-85, Math.min(85, bbox[1] + (bbox[3] - bbox[1]) * fy));
const rad = (lat * Math.PI) / 180;
const clamp = (value) => Math.min(n - 1, Math.max(0, Math.floor(value)));
return {
x: clamp(((lon + 180) / 360) * n),
y: clamp(
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n,
),
};
}
async function probeEntry(entry) {
const started = Date.now();
const signal = AbortSignal.timeout(30_000);
try {
if (entry.kind === 'tiles') {
// Regional imagery is probed at z13, the Open Imagery Register's
// validation zoom (some caches start there); global layers at their
// coarsest level.
const minZoom = entry.source.minZoom ?? 0;
const z = entry.coverage.bbox
? Math.min(entry.source.maxZoom ?? 18, Math.max(minZoom, 13))
: minZoom;
const points = entry.coverage.bbox ? PROBE_POINTS : [PROBE_POINTS[0]];
let status = null;
let lastError = null;
for (const point of points) {
const { x, y } = probeTile(entry, z, point);
const url = libraryTileUrl(entry.source.url, z, x, y);
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(20_000),
redirect: 'error',
});
await response.arrayBuffer();
status = response.status;
if (
response.ok &&
/^image\//.test(response.headers.get('content-type') || '')
)
return { id: entry.id, ok: true, status, ms: Date.now() - started };
} catch (error) {
lastError = error;
}
}
if (status === null && lastError) throw lastError;
return { id: entry.id, ok: false, status, ms: Date.now() - started };
}
if (entry.source.format === 'geoparquet') {
// List the partitions and read one footer by range request: proof the
// bucket answers and the files are GeoParquet, without reading data.
const { listPartitions, rangeBuffer, geoParquetInfo } =
await import('../src/library/parquet.js');
const { parquetMetadataAsync } = await import('hyparquet');
const fetchImpl = (target, options = {}) =>
fetch(target, { ...options, signal });
const objects = entry.source.partitions
? await listPartitions(entry, { fetchImpl, signal })
: [{ key: entry.source.url, size: null }];
const first = objects[0];
const url = entry.source.partitions
? new URL(first.key, entry.source.partitions.keyBase).href
: entry.source.url;
const size =
first.size ??
Number(
(await fetch(url, { method: 'HEAD', signal })).headers.get(
'content-length',
),
);
const metadata = await parquetMetadataAsync(
rangeBuffer(url, size, { fetchImpl, signal }),
{ initialFetchSize: 16 * 1024 },
);
return {
id: entry.id,
ok: Boolean(geoParquetInfo(metadata).column),
status: 200,
ms: Date.now() - started,
features: Number(metadata.num_rows),
partitions: objects.length,
};
}
const url =
entry.source.format === 'arcgis'
? arcgisPageUrl(
entry.source.url,
entry.source.query,
0,
// Some huge view services refuse a page smaller than their own.
entry.source.view ? 2000 : 100,
// A view-bounded layer is probed over its sample area.
entry.source.view?.sample ?? null,
)
: datasetRequestUrl(entry.source);
const response = await fetch(url, { signal, redirect: 'error' });
const text = await response.text();
const parsed = response.ok
? parseLibraryBody(entry.source.format, text, entry.source)
: null;
return {
id: entry.id,
ok: Boolean(parsed?.candidates.length),
status: response.status,
ms: Date.now() - started,
features: parsed?.candidates.length ?? 0,
};
} catch (error) {
return {
id: entry.id,
ok: false,
status: null,
ms: Date.now() - started,
error: error.message,
};
}
}
const [command = 'check', ...rest] = process.argv.slice(2);
const flag = (name) => rest.includes(name);
const option = (name) => {
const index = rest.indexOf(name);
return index >= 0 ? rest[index + 1] : null;
};
if (command === 'check') check(option('--base-ref'));
else if (command === 'next') {
const id = rest.find((arg) => !arg.startsWith('--'));
if (!id) throw new Error('usage: npm run library:next -- <entry-id>');
if (LIBRARY_LEDGER.some(([existing]) => existing === id))
throw new Error(`${id} already owns a token`);
console.log(nextLibraryToken(LIBRARY_LEDGER));
} else if (command === 'docs') {
const text = readFileSync(DATA_SOURCES, 'utf8');
writeFileSync(DATA_SOURCES, replaceSection(text, libraryDocsSection()));
console.log('DATA_SOURCES.md Data Library section updated.');
} else if (command === 'probe') {
const results = [];
for (const entry of LIBRARY_ENTRIES.filter(({ kind }) => kind !== 'pack'))
results.push(await probeEntry(entry));
if (flag('--json')) console.log(JSON.stringify(results, null, 2));
else
for (const result of results)
console.log(
`${result.ok ? 'ok ' : 'FAIL'} ${result.id} · ${result.status ?? '—'} · ${result.ms} ms${result.features !== undefined ? ` · ${result.features} features` : ''}${result.partitions !== undefined ? ` in the first of ${result.partitions} partitions` : ''}${result.error ? ` · ${result.error}` : ''}`,
);
if (flag('--strict') && results.some(({ ok }) => !ok)) process.exit(1);
} else {
console.error(`unknown command: ${command}`);
process.exit(2);
}
+282 -6
View File
@@ -105,6 +105,11 @@
"src/ui/views/panels/DrawSwatches.vue",
"src/annotations/drawMarks.js",
"src/ui/stores/display.js",
"src/ui/stores/dataLibrary.js",
"src/ui/views/panels/DataLibraryCard.vue",
"src/ui/views/panels/DataLibraryDialog.vue",
"src/ui/views/panels/DataLibraryBrowse.vue",
"src/ui/libraryBrowser.js",
"src/ui/localSdrPresentation.js",
"src/ui/unitLabels.js",
"src/layers/localAdsb/feedNames.js",
@@ -171,6 +176,28 @@
],
"external": ["vue", "pinia", "vue-i18n"]
},
"i18n": {
"exports": ["./i18n"],
"modules": [
"src/i18n/page.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/he.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/sv.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
"external": []
},
"time": {
"exports": ["./time"],
"modules": ["src/time/worldClock.js"],
@@ -1470,7 +1497,8 @@
"src/sources/live/index.js",
"src/sources/live/standalone.js",
"src/sources/live/vessels.js",
"src/data/planespottersPhoto.js"
"src/data/planespottersPhoto.js",
"src/data/openVesselPhoto.js"
],
"external": []
},
@@ -1692,7 +1720,9 @@
"src/i18n/locales/sv.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/layers/vessels/photos.js",
"src/data/openVesselPhoto.js"
],
"external": ["cesium"]
},
@@ -3252,6 +3282,27 @@
"src/i18n/marker.js",
"src/sources/nominatim.js",
"src/app/layers/transit.js",
"src/app/layers/rail.js",
"src/data/railFeeds.js",
"src/layers/rail/index.js",
"src/layers/rail/source.js",
"src/layers/rail/policy.js",
"src/layers/rail/ingestion.js",
"src/layers/rail/dedupe.js",
"src/layers/rail/rendering.js",
"src/layers/rail/selection.js",
"src/layers/rail/follow.js",
"src/layers/rail/viewport.js",
"src/layers/rail/controls.js",
"src/layers/rail/lifecycle.js",
"src/layers/rail/pathBinding.js",
"src/layers/rail/routeDisplay.js",
"src/layers/rail/routeLines.js",
"src/layers/rail/schedules.js",
"src/layers/rail/stopLabels.js",
"src/data/railPaths.js",
"src/data/railSchedule.js",
"src/data/railTimeZones.js",
"src/data/transit.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
@@ -3474,6 +3525,141 @@
"src/voice/geminiSession.js",
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js",
"src/ui/libraryBrowser.js",
"src/library/manifest.js",
"src/data/libraryLayerIds.js",
"src/library/selection.js",
"src/library/ledger.json",
"src/library/entries/ak-statewide-imagery.json",
"src/library/entries/amsterdam-traffic-cameras.json",
"src/library/entries/ar-statewide-imagery.json",
"src/library/entries/az-statewide-imagery.json",
"src/library/entries/bayern-dop40.json",
"src/library/entries/bismarck-mandan-nd.json",
"src/library/entries/bkg-topplus-open.json",
"src/library/entries/cctv-florida-fl511.json",
"src/library/entries/cctv-lithuania.json",
"src/library/entries/cctv-nyc-dot.json",
"src/library/entries/ct-statewide-imagery.json",
"src/library/entries/dakota-county-mn.json",
"src/library/entries/dc-statewide-imagery.json",
"src/library/entries/de-statewide-imagery.json",
"src/library/entries/ee-maa-amet-ortofoto.json",
"src/library/entries/eia-power-plants.json",
"src/library/entries/epa-superfund-sites.json",
"src/library/entries/epa-toxic-release-facilities.json",
"src/library/entries/epc-england-wales.json",
"src/library/entries/fema-usa-structures.json",
"src/library/entries/gbif-occurrence-density.json",
"src/library/entries/gdacs-disaster-alerts.json",
"src/library/entries/grand-forks-nd.json",
"src/library/entries/gsi-japan-map.json",
"src/library/entries/gsi-japan-photo.json",
"src/library/entries/gvp-holocene-volcanoes.json",
"src/library/entries/ia-statewide-imagery.json",
"src/library/entries/id-statewide-imagery.json",
"src/library/entries/ign-orthophotos-france.json",
"src/library/entries/il-statewide-imagery.json",
"src/library/entries/imf-portwatch-chokepoints.json",
"src/library/entries/imf-portwatch-disruptions.json",
"src/library/entries/in-statewide-imagery.json",
"src/library/entries/jrc-surface-water.json",
"src/library/entries/kartverket-admin-units.json",
"src/library/entries/kartverket-cadastre.json",
"src/library/entries/kartverket-depth.json",
"src/library/entries/kartverket-hillshade.json",
"src/library/entries/kartverket-nautical-charts.json",
"src/library/entries/kartverket-terrain-relief.json",
"src/library/entries/kartverket-topo-grey.json",
"src/library/entries/kartverket-topo.json",
"src/library/entries/kartverket-toporaster.json",
"src/library/entries/ks-statewide-imagery.json",
"src/library/entries/ky-statewide-imagery.json",
"src/library/entries/lake-county-in.json",
"src/library/entries/lawrence-ks.json",
"src/library/entries/lee-county-fl.json",
"src/library/entries/loudoun-county-va.json",
"src/library/entries/louisville-lojic-ky.json",
"src/library/entries/ma-statewide-imagery.json",
"src/library/entries/macrostrat-geology.json",
"src/library/entries/mapterhorn-terrain.json",
"src/library/entries/maris-ms.json",
"src/library/entries/md-statewide-imagery.json",
"src/library/entries/me-statewide-imagery.json",
"src/library/entries/mi-statewide-imagery.json",
"src/library/entries/mountain-village-co.json",
"src/library/entries/ms-statewide-imagery.json",
"src/library/entries/mt-statewide-imagery.json",
"src/library/entries/naip.json",
"src/library/entries/nasa-black-marble.json",
"src/library/entries/nasa-eonet-events.json",
"src/library/entries/nc-statewide-imagery.json",
"src/library/entries/nctcog-tx.json",
"src/library/entries/nd-statewide-imagery.json",
"src/library/entries/new-orleans-rpc-la.json",
"src/library/entries/nh-statewide-imagery.json",
"src/library/entries/nj-statewide-imagery.json",
"src/library/entries/noaa-tide-stations.json",
"src/library/entries/nrw-dop.json",
"src/library/entries/nsw-imagery.json",
"src/library/entries/ntad-national-bridges.json",
"src/library/entries/nwa-benton-washington-ar.json",
"src/library/entries/nws-active-alerts.json",
"src/library/entries/ny-statewide-imagery.json",
"src/library/entries/oakland-county-mi.json",
"src/library/entries/oh-statewide-imagery.json",
"src/library/entries/or-statewide-imagery.json",
"src/library/entries/pa-statewide-imagery.json",
"src/library/entries/pl-ortofotomapa.json",
"src/library/entries/pnoa-ma.json",
"src/library/entries/portland-metro-or.json",
"src/library/entries/pt-dgt-ortos.json",
"src/library/entries/ri-statewide-imagery.json",
"src/library/entries/swisstopo-swissimage.json",
"src/library/entries/tx-statewide-imagery.json",
"src/library/entries/union-county-oh.json",
"src/library/entries/usgs-hydrography.json",
"src/library/entries/usgs-topo.json",
"src/library/entries/va-statewide-imagery.json",
"src/library/entries/vt-statewide-imagery.json",
"src/library/entries/wallonie-ortho.json",
"src/library/entries/wi-statewide-imagery.json",
"src/library/entries/wri-global-power-plants.json",
"src/library/entries/wv-statewide-imagery.json",
"src/library/formats.js",
"src/library/entries/gvp-holocene-volcanoes.json",
"src/library/entries/nasa-black-marble.json",
"src/library/entries/nasa-eonet-events.json",
"src/library/catalog.js",
"src/library/source.js",
"src/library/layers.js",
"src/library/controller.js",
"src/library/extent.js",
"src/library/camera.js",
"src/library/entries/imf-portwatch-disruptions.json",
"src/library/entries/imf-portwatch-chokepoints.json",
"src/library/entries/noaa-tide-stations.json",
"src/library/entries/nws-active-alerts.json",
"src/library/entries/wri-global-power-plants.json",
"src/library/entries/fema-usa-structures.json",
"src/library/entries/epc-england-wales.json",
"src/library/parquet.js",
"src/library/entries/bkg-topplus-open.json",
"src/library/entries/epa-superfund-sites.json",
"src/library/entries/epa-toxic-release-facilities.json",
"src/library/entries/gbif-occurrence-density.json",
"src/library/entries/gdacs-disaster-alerts.json",
"src/library/entries/gsi-japan-map.json",
"src/library/entries/ign-orthophotos-france.json",
"src/library/entries/gsi-japan-photo.json",
"src/library/entries/jrc-surface-water.json",
"src/library/entries/ntad-national-bridges.json",
"src/library/entries/usgs-hydrography.json",
"src/library/entries/macrostrat-geology.json",
"src/library/entries/swisstopo-swissimage.json",
"src/library/entries/usgs-topo.json",
"src/library/outlines.js",
"src/library/relief.js",
"src/sceneCommandRelease.js",
"src/scopeMaskPolicy.js",
"src/visualStylePolicy.js",
@@ -3597,7 +3783,10 @@
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js"
"src/data/planespottersPhoto.js",
"src/ui/stores/dataLibrary.js",
"src/data/openVesselPhoto.js",
"src/layers/vessels/photos.js"
],
"external": [
"hls.js",
@@ -3609,6 +3798,8 @@
"satellite.js",
"mapillary-js",
"@jtarrio/webrtlsdr",
"hyparquet",
"fzstd",
"vue",
"pinia",
"vue-i18n"
@@ -4075,6 +4266,27 @@
"src/i18n/marker.js",
"src/sources/nominatim.js",
"src/app/layers/transit.js",
"src/app/layers/rail.js",
"src/data/railFeeds.js",
"src/layers/rail/index.js",
"src/layers/rail/source.js",
"src/layers/rail/policy.js",
"src/layers/rail/ingestion.js",
"src/layers/rail/dedupe.js",
"src/layers/rail/rendering.js",
"src/layers/rail/selection.js",
"src/layers/rail/follow.js",
"src/layers/rail/viewport.js",
"src/layers/rail/controls.js",
"src/layers/rail/lifecycle.js",
"src/layers/rail/pathBinding.js",
"src/layers/rail/routeDisplay.js",
"src/layers/rail/routeLines.js",
"src/layers/rail/schedules.js",
"src/layers/rail/stopLabels.js",
"src/data/railPaths.js",
"src/data/railSchedule.js",
"src/data/railTimeZones.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/data/transitIcons.js",
@@ -4284,7 +4496,9 @@
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js"
"src/data/planespottersPhoto.js",
"src/layers/vessels/photos.js",
"src/data/openVesselPhoto.js"
],
"external": [
"hls.js",
@@ -4386,7 +4600,11 @@
},
"layer-lifecycle": {
"exports": ["./data/lifecycle"],
"modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"],
"modules": [
"src/data/lifecycle.js",
"src/data/cameraArrival.js",
"src/data/libraryLayerIds.js"
],
"external": []
},
"ui-navigation": {
@@ -5093,6 +5311,63 @@
],
"external": []
},
"rail-provider": {
"runtime": "node",
"exports": ["./server/providers/rail"],
"modules": [
"server/providers/rail.js",
"src/data/railFeeds.js",
"src/data/railProxy.js",
"src/data/irishRail.js",
"src/data/railSiriVm.js",
"src/data/railTripUpdates.js",
"src/data/railScheduleIndex.js",
"src/data/railSchedule.js",
"src/data/railTimeZones.js",
"src/data/railNoPositions.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js",
"src/data/transitProxy.js",
"src/data/gtfsRealtime.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/transitHistory.js",
"src/sources/railService.js",
"src/sources/rateLimit.js"
],
"external": ["pbf"]
},
"rail-source-factory": {
"exports": ["./layers/rail/source"],
"modules": ["src/layers/rail/source.js"],
"external": []
},
"rail-service": {
"exports": ["./sources/rail-service"],
"modules": [
"src/data/railFeeds.js",
"src/data/railProxy.js",
"src/data/irishRail.js",
"src/data/railSiriVm.js",
"src/data/railTripUpdates.js",
"src/data/railScheduleIndex.js",
"src/data/railSchedule.js",
"src/data/railTimeZones.js",
"src/data/railNoPositions.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js",
"src/data/transitProxy.js",
"src/data/gtfsRealtime.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/transitHistory.js",
"src/sources/railService.js",
"src/sources/rateLimit.js"
],
"external": ["pbf"]
},
"transit-service": {
"exports": ["./sources/transit-service"],
"modules": [
@@ -5475,7 +5750,8 @@
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js"
"src/data/planespottersPhoto.js",
"src/data/openVesselPhoto.js"
],
"external": ["@mapbox/vector-tile", "pbf"]
},
+737
View File
@@ -0,0 +1,737 @@
#!/usr/bin/env node
/**
* Data Library live gate. Run against this candidate's dev or preview server:
*
* npm run qa:data-library -- --url http://localhost:4173
*
* Every upstream a library entry names is answered from fixtures in the
* browser (EONET, the EIA ArcGIS pages, the GIBS tiles and the proxied
* volcano route), so the gate is deterministic and offline apart from the
* app itself. It drives the real panel and dialog: Browse opens a modal,
* Add registers a layer under the Library heading and switches it on, the
* share link carries `lib=`, a reload restores the selection, Escape and
* clean view close the dialog, and Remove destroys the layer.
*/
import assert from 'node:assert/strict';
const { default: puppeteer } = await import('puppeteer');
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const base = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
const point = (lon, lat, properties) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties,
});
const collection = (features, extra = {}) =>
JSON.stringify({ type: 'FeatureCollection', features, ...extra });
// 1×1 transparent PNG.
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
const seen = {
eonet: 0,
arcgisPages: [],
tiles: 0,
proxied: 0,
structureBoxes: [],
};
function fixtureFor(url) {
const cors = { 'Access-Control-Allow-Origin': '*' };
if (url.hostname === 'eonet.gsfc.nasa.gov') {
seen.eonet += 1;
return {
status: 200,
headers: cors,
contentType: 'application/json',
body: collection([
point(-120, 38, {
title: 'Fixture Fire',
date: '2026-10-20T00:00:00Z',
}),
point(140, 35, {
title: 'Fixture Storm',
date: '2026-10-19T00:00:00Z',
}),
]),
};
}
if (
url.hostname === 'services2.arcgis.com' &&
url.pathname.includes('USA_Structures')
) {
seen.structureBoxes.push(url.searchParams.get('geometry'));
const square = (lon, lat) => ({
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[lon, lat],
[lon + 0.0004, lat],
[lon + 0.0004, lat + 0.0003],
[lon, lat + 0.0003],
[lon, lat],
],
],
},
properties: { OCC_CLS: 'Government', HEIGHT: 30 },
});
return {
status: 200,
headers: cors,
contentType: 'application/json',
body: collection([
square(-77.035, 38.892),
square(-77.03, 38.893),
square(-77.025, 38.894),
]),
};
}
if (url.hostname === 'services2.arcgis.com') {
const offset = Number(url.searchParams.get('resultOffset'));
seen.arcgisPages.push(offset);
const features =
offset === 0
? [
point(-118.9, 47.9, { Plant_Name: 'Fixture Dam', Total_MW: 6809 }),
point(-112.9, 33.4, {
Plant_Name: 'Fixture Nuclear',
Total_MW: 3937,
}),
]
: [point(-97.6, 28.8, { Plant_Name: 'Fixture Coast', Total_MW: 2560 })];
return {
status: 200,
headers: cors,
contentType: 'application/json',
body: collection(features, {
properties: { exceededTransferLimit: offset === 0 },
}),
};
}
if (url.hostname === 'gibs.earthdata.nasa.gov') {
seen.tiles += 1;
return { status: 200, headers: cors, contentType: 'image/png', body: PNG };
}
if (url.pathname === '/api/library/data/gvp-holocene-volcanoes') {
seen.proxied += 1;
return {
status: 200,
contentType: 'application/json',
body: collection([
point(-121.76, 46.85, { name: 'Fixture Volcano', __gevDetails: [] }),
]),
};
}
return null;
}
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
defaultViewport: { width: 1440, height: 900 },
protocolTimeout: 300_000,
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
const errors = [];
let count = 0;
const step = async (label, fn) => {
await fn();
console.log(`ok ${++count} ${label}`);
};
async function open(page, hash = '') {
await page.goto(`${base}/${hash}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevDataLibrary?.library.ready &&
window.__godsEyeView?.dataManager &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 180_000 },
);
}
const libraryRows = (page) =>
page.evaluate(() =>
[...document.querySelectorAll('#data-toggles [data-library-entry]')].map(
(row) => ({
id: row.dataset.libraryEntry,
on: row.querySelector('.data-toggle-btn')?.classList.contains('active'),
count: row.querySelector('.data-count')?.textContent,
}),
),
);
try {
const page = await browser.newPage();
page.on('console', (message) => {
if (message.type() === 'error' && /Library|library/.test(message.text()))
errors.push(message.text());
});
page.on('pageerror', (error) => errors.push(String(error)));
await page.setRequestInterception(true);
page.on('request', (request) => {
const fixture = fixtureFor(new URL(request.url()));
if (fixture) request.respond(fixture);
else request.continue();
});
await page.evaluateOnNewDocument(() => {
try {
// Start empty once; the reload step needs what this run stored.
if (!sessionStorage.getItem('qa-data-library')) {
localStorage.removeItem('gev:library:v1');
sessionStorage.setItem('qa-data-library', '1');
}
// Skip the first-run chooser (src/firstRunExperience.js).
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
} catch {
/* storage may be blocked; the gate still runs */
}
});
await open(page);
await step(
'the panel offers Browse and lists no library layer by default',
async () => {
assert.deepEqual(await libraryRows(page), []);
const hidden = await page.$eval(
'#data-library-browse',
(node) => node.hidden,
);
assert.equal(hidden, false);
},
);
await step(
'Browse opens a labelled modal with addable entries first and packs last',
async () => {
await page.evaluate(() => {
document.getElementById('data-panel')?.classList.remove('collapsed');
document.getElementById('data-library-browse').click();
});
const dialog = await page.$eval('#data-library', (node) => ({
open: node.open,
modal: node.matches(':modal'),
title: document.getElementById(node.getAttribute('aria-labelledby'))
?.textContent,
focus: document.activeElement?.type,
order: [...node.querySelectorAll('.data-library-item')].map(
(item) => item.dataset.libraryId,
),
}));
assert.equal(dialog.open, true);
assert.equal(dialog.modal, true);
assert.equal(dialog.title, 'DATA LIBRARY');
assert.equal(dialog.focus, 'search');
assert.ok(dialog.order.at(-1).startsWith('cctv-'), dialog.order.join());
assert.ok(!dialog.order[0].startsWith('cctv-'));
},
);
await step(
'a pack explains the operator switch instead of offering Add',
async () => {
const pack = await page.$eval(
'#data-library .data-library-item[data-library-id="cctv-nyc-dot"]',
(item) => ({
action: Boolean(item.querySelector('.data-library-action')),
reason: item.querySelector('.data-library-reason')?.textContent,
}),
);
assert.equal(pack.action, false);
assert.match(pack.reason, /CCTV_NYC_ENABLED=1/);
},
);
for (const [id, expected] of [
['nasa-eonet-events', '2'],
['eia-power-plants', '3'],
['gvp-holocene-volcanoes', '1'],
['nasa-black-marble', 'DRAPED'],
])
await step(
`Add ${id} registers it under Library and switches it on`,
async () => {
await page.click(
`#data-library button[data-library-id="${id}"][data-library-action="add"]`,
);
await page.waitForFunction(
(layerId, text) => {
const row = document.querySelector(
`#data-toggles [data-layer-id="${layerId}"]`,
);
return (
row
?.querySelector('.data-toggle-btn')
?.classList.contains('active') &&
row.querySelector('.data-count')?.textContent === text
);
},
{ timeout: 60_000 },
`lib-${id}`,
expected,
);
},
);
await step(
'the fixtures were reached the way each manifest says',
async () => {
assert.ok(seen.eonet >= 1);
assert.deepEqual(seen.arcgisPages.slice(0, 2), [0, 2]);
assert.ok(seen.proxied >= 1);
const draped = await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('lib-nasa-black-marble')
.module.getDiagnostics().draped,
);
assert.equal(draped, true);
// Imagery requests tiles on the following frames, not on enable.
for (let waited = 0; seen.tiles === 0 && waited < 30_000; waited += 250)
await new Promise((resolve) => setTimeout(resolve, 250));
assert.ok(seen.tiles >= 1, 'the drape requested its tiles');
const headings = await page.$$eval(
'#data-toggles .data-layer-group-heading',
(nodes) => nodes.map((node) => node.textContent),
);
assert.equal(headings.at(-1), 'Library');
},
);
await step(
'the stats strip sits above the intro and counts what is on the map',
async () => {
await page.waitForFunction(
() =>
document.querySelector(
'[data-stat="features"] .data-library-stat-value',
)?.textContent === '6',
{ timeout: 15_000 },
);
const strip = await page.evaluate(() => {
const stats = document.querySelector(
'#data-library .data-library-stats',
);
const read = (id) =>
[...document.querySelectorAll(`[data-stat="${id}"] dd`)].map(
(node) => node.textContent,
);
return {
beforeIntro:
stats?.nextElementSibling?.classList.contains('data-library-intro'),
label: stats?.getAttribute('aria-label'),
entries: read('entries'),
added: read('added'),
features: read('features'),
};
});
assert.equal(strip.beforeIntro, true);
assert.equal(strip.label, 'Data Library at a glance');
// Counted from the shipped catalog, so a new entry does not break this.
const kinds = await page.evaluate(() => {
const counts = { dataset: 0, tiles: 0, pack: 0 };
for (const { entry } of window.__gevDataLibrary.library.list())
counts[entry.kind] += 1;
return counts;
});
assert.deepEqual(strip.entries, [
String(kinds.dataset + kinds.tiles + kinds.pack),
`${kinds.dataset} datasets · ${kinds.tiles} tile layers · ${kinds.pack} packs`,
]);
assert.deepEqual(strip.added, ['4', '4 on the map']);
// Fixtures: 2 EONET events, 3 power plants, 1 volcano.
assert.deepEqual(strip.features, ['6', 'from 3 datasets']);
},
);
await step('the share link carries lib= with the ledger tokens', async () => {
await page.waitForFunction(
() => /(?:^|&)lib=00\.02\.01\.03(?:&|$)/.test(location.hash.slice(1)),
{ timeout: 15_000 },
);
});
await step(
'Escape closes the dialog and returns focus to Browse',
async () => {
await page.focus('#data-library input[type="search"]');
await page.keyboard.press('Escape');
const state = await page.evaluate(() => ({
open: document.getElementById('data-library').open,
focus: document.activeElement?.id,
}));
assert.deepEqual(state, { open: false, focus: 'data-library-browse' });
},
);
await step('clean view closes an open dialog', async () => {
const open = await page.evaluate(async () => {
window.__gevDataLibrary.browser.open();
document.body.classList.add('ui-clean-view');
await new Promise((resolve) => setTimeout(resolve, 50));
const result = document.getElementById('data-library').open;
document.body.classList.remove('ui-clean-view');
return result;
});
assert.equal(open, false);
});
await step(
'a reload restores the added entries and what was on',
async () => {
await page.evaluate(() => {
document
.querySelector(
'#data-toggles [data-layer-id="lib-gvp-holocene-volcanoes"] .data-toggle-btn',
)
.click();
});
await page.waitForFunction(
() =>
JSON.parse(localStorage.getItem('gev:library:v1')).on.length === 3,
);
await open(page, '');
await page.waitForFunction(
() =>
document.querySelectorAll('#data-toggles [data-library-entry]')
.length === 4,
{ timeout: 60_000 },
);
const rows = await libraryRows(page);
assert.deepEqual(rows.map(({ id, on }) => [id, on]).sort(), [
['eia-power-plants', true],
['gvp-holocene-volcanoes', false],
['nasa-black-marble', true],
['nasa-eonet-events', true],
]);
},
);
const cameraCenter = () =>
page.evaluate(() => {
const { camera } = window.__godsEyeView.viewer;
const carto = camera.positionCartographic;
return {
lon: (carto.longitude * 180) / Math.PI,
lat: (carto.latitude * 180) / Math.PI,
height: carto.height,
};
});
const waitForFlight = () =>
page.waitForFunction(
() => !window.__godsEyeView.viewer.camera._currentFlight,
{ timeout: 30_000, polling: 200 },
);
await step(
'Zoom to layer on the row works while off: it switches on and flies',
async () => {
const chip =
'#data-toggles [data-layer-id="lib-eia-power-plants"] [data-chip-id="zoom"]';
await page.evaluate(() =>
document.getElementById('data-panel')?.classList.remove('collapsed'),
);
// The icons work while the layer is off; Zoom switches it back on.
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled(
'lib-eia-power-plants',
false,
{ origin: 'user' },
),
);
await page.waitForSelector(chip, { visible: true, timeout: 30_000 });
await page.evaluate(
(selector) => document.querySelector(selector).click(),
chip,
);
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.isEnabled('lib-eia-power-plants'),
{ timeout: 30_000 },
);
await new Promise((resolve) => setTimeout(resolve, 300));
await waitForFlight();
const center = await cameraCenter();
// Fixture plants span -118.9…-97.6 °E and 28.8…47.9 °N.
assert.ok(center.lon > -125 && center.lon < -90, JSON.stringify(center));
assert.ok(center.lat > 22 && center.lat < 55, JSON.stringify(center));
},
);
await step(
'ZOOM in the dialog closes it and flies to that entry',
async () => {
await page.evaluate(() => window.__gevDataLibrary.browser.open());
const zoom =
'#data-library button[data-library-id="gvp-holocene-volcanoes"][data-library-action="zoom"]';
assert.ok(await page.$(zoom), 'Zoom is offered for an entry that is off');
await page.click(
'#data-library button[data-library-id="nasa-eonet-events"][data-library-action="zoom"]',
);
const open = await page.$eval('#data-library', (node) => node.open);
assert.equal(open, false);
await new Promise((resolve) => setTimeout(resolve, 300));
await waitForFlight();
const center = await cameraCenter();
// Fixture events at (-120, 38) and (140, 35): the frame sits between them.
assert.ok(center.lat > 20 && center.lat < 55, JSON.stringify(center));
},
);
await step('Remove destroys the layer and forgets it', async () => {
await page.evaluate(() => window.__gevDataLibrary.browser.open());
await page.click(
'#data-library button[data-library-id="nasa-black-marble"][data-library-action="remove"]',
);
await page.waitForFunction(
() =>
!document.querySelector(
'#data-toggles [data-layer-id="lib-nasa-black-marble"]',
) &&
!window.__godsEyeView.dataManager.layers.has('lib-nasa-black-marble'),
{ timeout: 30_000 },
);
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('gev:library:v1')),
);
assert.equal(stored.added.includes('nasa-black-marble'), false);
});
await step(
'the row delete icon removes that entry, even while it is off',
async () => {
await page.evaluate(() => {
window.__gevDataLibrary.browser.close();
document.getElementById('data-panel')?.classList.remove('collapsed');
});
const chip =
'#data-toggles [data-layer-id="lib-eia-power-plants"] [data-chip-id="remove"]';
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled(
'lib-eia-power-plants',
false,
{ origin: 'user' },
),
);
await page.waitForSelector(chip, { visible: true, timeout: 30_000 });
const label = await page.$eval(chip, (node) => ({
name: node.getAttribute('aria-label'),
glyph: node.textContent,
pressed: node.getAttribute('aria-pressed'),
}));
assert.deepEqual(label, {
name: 'Remove from Data Layers',
glyph: 'delete',
pressed: null,
});
await page.evaluate(
(selector) => document.querySelector(selector).click(),
chip,
);
await page.waitForFunction(
() =>
!window.__godsEyeView.dataManager.layers.has(
'lib-eia-power-plants',
) &&
!document.querySelector(
'#data-toggles [data-layer-id="lib-eia-power-plants"]',
),
{ timeout: 30_000 },
);
},
);
await step(
'a view-bounded layer asks to zoom in, then loads only what is in view',
async () => {
await page.evaluate(() =>
window.__gevDataLibrary.library.add('fema-usa-structures'),
);
const row = '#data-toggles [data-layer-id="lib-fema-usa-structures"]';
await page.waitForFunction(
(selector) =>
/zoom in to load this layer/.test(
document.querySelector(`${selector} .data-toggle-meta`)
?.textContent || '',
),
{ timeout: 30_000 },
row,
);
assert.equal(seen.structureBoxes.length, 0, 'nothing loads from orbit');
await page.evaluate(() =>
window.__gevDataLibrary.library.zoom('fema-usa-structures'),
);
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('lib-fema-usa-structures')
.module.getDiagnostics().count === 3,
{ timeout: 60_000, polling: 250 },
);
const altitude = (await cameraCenter()).height;
assert.ok(altitude <= 4000, `landed at ${Math.round(altitude)} m`);
const [west, south, east, north] = seen.structureBoxes
.at(-1)
.split(',')
.map(Number);
assert.ok(east - west < 0.2 && north - south < 0.2, 'a view-sized box');
assert.ok(
west < -77.03 && east > -77.03 && south < 38.893 && north > 38.893,
);
// Climbing back out clears the buildings and shows the guidance again.
await page.evaluate(() => {
const { viewer, requestRender } = window.__godsEyeView;
viewer.camera.zoomOut(60_000);
requestRender?.('qa-data-library');
});
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('lib-fema-usa-structures')
.module.getDiagnostics().tooHigh,
{ timeout: 30_000, polling: 250 },
);
},
);
await step('Remove all asks twice, then clears the library', async () => {
await page.evaluate(() => window.__gevDataLibrary.browser.open());
await page.click('#data-library .data-library-reset');
const armed = await page.$eval(
'#data-library .data-library-reset',
(node) => ({
text: node.textContent,
armed: node.classList.contains('is-armed'),
}),
);
assert.equal(armed.armed, true);
assert.match(armed.text, /CONFIRM/);
assert.ok(
(await page.$$('#data-toggles [data-library-entry]')).length > 0,
'one press removes nothing',
);
await page.click('#data-library .data-library-reset');
await page.waitForFunction(
() =>
!document.querySelector('#data-toggles [data-library-entry]') &&
document.querySelector('#data-library .data-library-reset').hidden,
{ timeout: 30_000 },
);
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('gev:library:v1')),
);
assert.deepEqual(stored, { added: [], on: [] });
});
const onIds = (target) =>
target.evaluate(() =>
window.__gevDataLibrary.library
.list()
.filter((row) => row.on)
.map((row) => row.entry.id)
.sort(),
);
await step(
'a copied /share link opens the same library layers in a clean browser',
async () => {
await page.evaluate(async () => {
const { library } = window.__gevDataLibrary;
await library.add('nasa-eonet-events');
await library.add('nasa-black-marble');
});
await page.waitForFunction(
() => /(?:^|&)lib=00\.03(?:&|$)/.test(location.hash.slice(1)),
{ timeout: 15_000 },
);
const href = await page.evaluate(() =>
window.__gevDataLibrary.buildShareLink(),
);
assert.match(new URL(href).pathname, /\/share\//);
// A separate profile: nothing stored, nothing added.
const context = await browser.createBrowserContext();
try {
const recipient = await context.newPage();
await recipient.setRequestInterception(true);
recipient.on('request', (request) => {
const fixture = fixtureFor(new URL(request.url()));
if (fixture) request.respond(fixture);
else request.continue();
});
await recipient.evaluateOnNewDocument(() => {
try {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
} catch {
/* the gate still runs */
}
});
await recipient.goto(href, { waitUntil: 'domcontentloaded' });
await recipient.waitForFunction(
() => window.__gevDataLibrary?.library.ready,
{ timeout: 180_000 },
);
await recipient.waitForFunction(
() =>
window.__gevDataLibrary.library.list().filter((row) => row.on)
.length === 2,
{ timeout: 60_000 },
);
assert.deepEqual(await onIds(recipient), [
'nasa-black-marble',
'nasa-eonet-events',
]);
} finally {
await context.close();
}
},
);
await step(
'a share opened in the same tab switches library layers on and off',
async () => {
const navigate = (lib) =>
page.evaluate((value) => {
const params = new URLSearchParams(location.hash.slice(1));
params.set('lat', String(Number(params.get('lat')) + 1));
if (value === null) params.delete('lib');
else params.set('lib', value);
location.hash = params.toString();
}, lib);
await navigate('00');
await page.waitForFunction(
() => {
const on = window.__gevDataLibrary.library
.list()
.filter((row) => row.on)
.map((row) => row.entry.id);
return on.length === 1 && on[0] === 'nasa-eonet-events';
},
{ timeout: 30_000 },
);
// A share with every library layer off: the added ones stay listed, off.
await navigate(null);
await page.waitForFunction(
() => window.__gevDataLibrary.library.list().every((row) => !row.on),
{ timeout: 30_000 },
);
assert.deepEqual(
(await libraryRows(page)).map(({ id, on }) => [id, on]).sort(),
[
['nasa-black-marble', false],
['nasa-eonet-events', false],
],
);
},
);
assert.deepEqual(errors, [], 'no library errors on the page');
console.log(`# ${count} passed`);
} finally {
await browser.close();
}