mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge branch 'main' into contrib/gods-002-director-dateline-seek-20261011-reviewed
This commit is contained in:
commit
b80ef27eb9
29 files changed
+387
-99
No files matched your search
@@ -150,7 +150,7 @@ jobs:
|
|||||||
run: npm run check:boundaries
|
run: npm run check:boundaries
|
||||||
|
|
||||||
- name: Run focused onboarding tests
|
- name: Run focused onboarding tests
|
||||||
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs
|
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs src/codexOauthRealtime.test.mjs
|
||||||
|
|
||||||
- name: Build production bundle
|
- name: Build production bundle
|
||||||
run: npm run build
|
run: npm run build
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
- Snow flakes sway within a fixed range instead of drifting sideways faster the
|
||||||
|
longer the app runs; the Wind setting still scales the sway.
|
||||||
|
|
||||||
|
- The right rail ignores panels that other CSS hides, such as during an event
|
||||||
|
view, so they no longer force one-panel-at-a-time layout or collapse visible
|
||||||
|
panels. Panels the rail collapses itself are still restored.
|
||||||
|
|
||||||
|
- Coordinate searches refuse empty latitude or longitude components instead of navigating to an unintended location.
|
||||||
|
|
||||||
- Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through
|
- Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through
|
||||||
their ion-backed providers, retaining ion and per-tile attribution without
|
their ion-backed providers, retaining ion and per-tile attribution without
|
||||||
widening the Content Security Policy.
|
widening the Content Security Policy.
|
||||||
@@ -195,6 +204,12 @@
|
|||||||
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
|
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
|
||||||
under NLOD 2.0.
|
under NLOD 2.0.
|
||||||
|
|
||||||
|
- Live HLS cameras play on a page opened over plain HTTP from another machine
|
||||||
|
(`HOST=0.0.0.0`). The client lease id came from `crypto.randomUUID`, which
|
||||||
|
browsers only expose in secure contexts, so starting a live feed threw
|
||||||
|
before playback. The id now falls back to a v4 UUID built from
|
||||||
|
`crypto.getRandomValues`, which the server's lease check accepts.
|
||||||
|
|
||||||
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
|
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
|
||||||
|
|
||||||
- Ship each bundled data pack once. The region, marine, admin-boundary,
|
- Ship each bundled data pack once. The region, marine, admin-boundary,
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ locked dependencies, finds a free local port, and opens the app.
|
|||||||
Version 8.2 fixes the launcher installation issue;
|
Version 8.2 fixes the launcher installation issue;
|
||||||
[details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g).
|
[details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g).
|
||||||
|
|
||||||
|
**Pinokio opens but the globe is very choppy?** Before treating that as a GPU
|
||||||
|
compatibility or minimum-hardware problem, copy the local server URL shown by
|
||||||
|
Pinokio and open the same URL in your normal hardware-accelerated browser. One
|
||||||
|
Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went
|
||||||
|
away when the same running server was opened in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)).
|
||||||
|
That points to the embedded browser rather than the app server or your
|
||||||
|
hardware; it does not establish a minimum GPU requirement.
|
||||||
|
|
||||||
### Path 2 — Terminal / coding agent
|
### Path 2 — Terminal / coding agent
|
||||||
|
|
||||||
Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
|
Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
|
||||||
|
|||||||
+1
-1
@@ -158,7 +158,7 @@ routing and regional caches. Request parameters cannot override these endpoints.
|
|||||||
Voice controls accept an action runner and a controller factory through
|
Voice controls accept an action runner and a controller factory through
|
||||||
`createVoiceCommands` (`./voice/commands`). The default Realtime controller owns
|
`createVoiceCommands` (`./voice/commands`). The default Realtime controller owns
|
||||||
microphone tracks, playback, push-to-talk, tool cancellation and radio handoff.
|
microphone tracks, playback, push-to-talk, tool cancellation and radio handoff.
|
||||||
Its backend supplies `requestToken({ tier, signal })` and
|
Its backend supplies `requestToken({ tier, authMode, signal })` and
|
||||||
`negotiate({ offerSdp, credential, signal })`. The Realtime-compatible adapter
|
`negotiate({ offerSdp, credential, signal })`. The Realtime-compatible adapter
|
||||||
(`./voice/realtime-backend`) accepts separate token and connection transports
|
(`./voice/realtime-backend`) accepts separate token and connection transports
|
||||||
and endpoints. Only the short-lived client secret reaches the SDP endpoint.
|
and endpoints. Only the short-lived client secret reaches the SDP endpoint.
|
||||||
|
|||||||
+11
-2
@@ -1014,6 +1014,8 @@ and sharpen. Display supplies actions and renders settings; effect execution has
|
|||||||
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
|
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
|
||||||
Stopping the controller revokes animation before asynchronous UI teardown; final
|
Stopping the controller revokes animation before asynchronous UI teardown; final
|
||||||
destruction removes its stages and restores the borrowed bloom configuration.
|
destruction removes its stages and restores the borrowed bloom configuration.
|
||||||
|
Snow's sideways sway stays within a fixed range at any session age; Wind scales
|
||||||
|
its amplitude.
|
||||||
|
|
||||||
## Display control ownership
|
## Display control ownership
|
||||||
|
|
||||||
@@ -1048,6 +1050,9 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
|
|||||||
panel, and honors keyboard focus on the right. Mobile layout still releases
|
panel, and honors keyboard focus on the right. Mobile layout still releases
|
||||||
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
||||||
writes. This extraction does not change panel positions or layout defaults.
|
writes. This extraction does not change panel positions or layout defaults.
|
||||||
|
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
||||||
|
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
||||||
|
collapse can still be undone.
|
||||||
|
|
||||||
## Surface keyboard lifecycle
|
## Surface keyboard lifecycle
|
||||||
|
|
||||||
@@ -4321,6 +4326,8 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
|
|
||||||
### Place-search providers
|
### Place-search providers
|
||||||
|
|
||||||
|
Coordinate input requires exactly two nonempty components; extra comma or semicolon separators decline to geocoding rather than silently choosing a location.
|
||||||
|
|
||||||
Location search/fly-to, annotations and Radio location lookup receive one
|
Location search/fly-to, annotations and Radio location lookup receive one
|
||||||
`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes
|
`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes
|
||||||
two offline providers first — a strict decimal-degree coordinate reader and an
|
two offline providers first — a strict decimal-degree coordinate reader and an
|
||||||
@@ -4445,7 +4452,7 @@ are omitted rather than framing the wrong part of the globe.
|
|||||||
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
|
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
|
||||||
- **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`.
|
- **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`.
|
||||||
- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off).
|
- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off).
|
||||||
- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect across the transitions within 1 px; that measurement is the real layout-shift check.
|
- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect, relative to the panel, across the transitions within 1 px; that measurement is the real layout-shift check. `QA_RECENT_IMAGERY_SLOW_RENDER=1` gives loaded software renderers a 4 s zoom deadline and an 8-sample stability tail; the default stays 2 s and 12 samples.
|
||||||
|
|
||||||
### 3D Aircraft + Tracking (June 2026)
|
### 3D Aircraft + Tracking (June 2026)
|
||||||
|
|
||||||
@@ -5221,7 +5228,9 @@ its paint loop. Camera deactivation destroys the decoder, and fatal playback
|
|||||||
errors revert to the labeled frame fallback. Camera changes clear the old panel
|
errors revert to the labeled frame fallback. Camera changes clear the old panel
|
||||||
frame. hls.js is imported only when an HLS camera starts. Two live sessions may
|
frame. hls.js is imported only when an HLS camera starts. Two live sessions may
|
||||||
coexist with at most eight independent consumer leases each; no-access lease
|
coexist with at most eight independent consumer leases each; no-access lease
|
||||||
expiry is 15 seconds. Closing one consumer leaves other consumers running. Encrypted/fMP4/byte-range playlists fail
|
expiry is 15 seconds. Closing one consumer leaves other consumers running. Lease
|
||||||
|
ids use `crypto.randomUUID` where the page is a secure context and an equivalent
|
||||||
|
v4 id from `crypto.getRandomValues` on plain-HTTP LAN pages. Encrypted/fMP4/byte-range playlists fail
|
||||||
closed. Performance and live agency reliability require recorded validation.
|
closed. Performance and live agency reliability require recorded validation.
|
||||||
|
|
||||||
## Wind forecast models
|
## Wind forecast models
|
||||||
|
|||||||
@@ -128,6 +128,12 @@ Use the same controls before attributing a difference to the application:
|
|||||||
6. Record live object counts before attributing a difference to the client.
|
6. Record live object counts before attributing a difference to the client.
|
||||||
7. Treat a live-source outage as missing coverage, not as evidence of low client
|
7. Treat a live-source outage as missing coverage, not as evidence of low client
|
||||||
rendering cost.
|
rendering cost.
|
||||||
|
8. Record whether the app is running in Pinokio's embedded view or in an
|
||||||
|
external browser. One Pinokio 8.2.0 user reported severe stutter in the
|
||||||
|
embedded view that went away when the same local URL was opened in a normal
|
||||||
|
browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)).
|
||||||
|
Do not compare embedded-view and external-browser measurements directly;
|
||||||
|
compare the reported GPU renderer and matched scene settings first.
|
||||||
|
|
||||||
## What is not established yet
|
## What is not established yet
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ The session adapter, action runner and backend interfaces remain unchanged.
|
|||||||
| Owner | State and responsibility |
|
| Owner | State and responsibility |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element |
|
| `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element |
|
||||||
|
| `realtimeBackend.js` | Token and SDP requests, their separate transports, the abort/timeout scope around both, and client-secret expiry |
|
||||||
| `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation |
|
| `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation |
|
||||||
| `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers |
|
| `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers |
|
||||||
| `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime |
|
| `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime |
|
||||||
@@ -21,6 +22,15 @@ The session adapter, action runner and backend interfaces remain unchanged.
|
|||||||
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
|
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
|
||||||
| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
||||||
|
|
||||||
|
The backend is replaceable per deployment: `realtimeController.js` accepts it as a
|
||||||
|
`backend` option and falls back to `createRealtimeBackend()`. Its two method
|
||||||
|
signatures live with [application construction](APPLICATION.md); what belongs here
|
||||||
|
is the ownership. The connection is the backend's only consumer in the application.
|
||||||
|
The connection reads credential metadata for diagnostics and cost accounting,
|
||||||
|
then passes the complete credential unchanged to `negotiate`. Client-secret expiry is refused inside the backend, not by the connection.
|
||||||
|
`realtimeOwners.test.mjs` drives whole sessions through a substitute that
|
||||||
|
implements only those two methods.
|
||||||
|
|
||||||
Pure input policy, preferences and protocol-response policy have separate modules.
|
Pure input policy, preferences and protocol-response policy have separate modules.
|
||||||
Owners receive named readers, operations or focused collaborators. Cross-owner
|
Owners receive named readers, operations or focused collaborators. Cross-owner
|
||||||
cancellation uses operations; response handling does not own Radio's reservations
|
cancellation uses operations; response handling does not own Radio's reservations
|
||||||
|
|||||||
@@ -230,16 +230,20 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(APP_URL);
|
const res = await fetch(APP_URL);
|
||||||
|
// Only the status matters; an unread body can hold the socket open.
|
||||||
|
await res.body?.cancel();
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
|
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
|
||||||
if (!FIXTURES && !status?.hasKey) {
|
if (!FIXTURES && !status?.hasKey) {
|
||||||
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
|
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||||
|
|||||||
@@ -30,12 +30,16 @@ const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.
|
|||||||
const FILES = {
|
const FILES = {
|
||||||
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
||||||
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
||||||
html: path.join(ROOT, 'index.html'),
|
html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'),
|
||||||
css: path.join(ROOT, 'style.css'),
|
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
|
||||||
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
||||||
voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'),
|
voiceDescriptions: path.join(
|
||||||
main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'),
|
ROOT,
|
||||||
ui: path.join(ROOT, 'src', 'ui.js'),
|
'server/providers/openai/toolDescriptions.js',
|
||||||
|
),
|
||||||
|
main: path.join(ROOT, 'src', 'app', 'startupChrome.js'),
|
||||||
|
ui: path.join(ROOT, 'src', 'ui', 'panelPositionControls.js'),
|
||||||
|
radio: path.join(ROOT, 'src', 'ui', 'radioBindings.js'),
|
||||||
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
|
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,14 +67,14 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the session flag is never read, so it re-nags on every reload',
|
defect: 'the session flag is never read, so it re-nags on every reload',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;",
|
from: " if (\n readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===\n 'dismissed'\n )\n return false;",
|
||||||
to: '// session check removed',
|
to: ' // session check removed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the durable flag is never read, so the checkbox does nothing',
|
defect: 'the durable flag is never read, so the checkbox does nothing',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;",
|
from: " if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')\n return false;",
|
||||||
to: '// durable check removed',
|
to: ' // durable check removed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
|
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
|
||||||
@@ -105,7 +109,7 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the guarded resolver stops catching, so a hostile getter escapes',
|
defect: 'the guarded resolver stops catching, so a hostile getter escapes',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
|
from: " try {\n return kind === 'session'\n ? globalThis.sessionStorage\n : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
|
||||||
to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;",
|
to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -131,8 +135,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'nothing watches for another surface taking the screen',
|
defect: 'nothing watches for another surface taking the screen',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });",
|
from: " surfaceObserver?.observe(documentRef.body, {\n attributes: true,\n attributeFilter: ['class'],\n });",
|
||||||
to: '/* observer never attached */',
|
to: ' /* observer never attached */',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
|
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
|
||||||
@@ -155,8 +159,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
|
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();',
|
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
|
||||||
to: ' && root.getClientRects().length > 0;',
|
to: ' root.getClientRects().length > 0;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
|
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
|
||||||
@@ -174,9 +178,9 @@ const MUTATIONS = [
|
|||||||
// Anchored past the call so it cannot land on the cockpit disclosure's own
|
// Anchored past the call so it cannot land on the cockpit disclosure's own
|
||||||
// stopImmediatePropagation() a few lines below and prove nothing about this.
|
// stopImmediatePropagation() a few lines below and prove nothing about this.
|
||||||
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
|
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
|
||||||
file: 'ui',
|
file: 'radio',
|
||||||
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
|
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
|
||||||
to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
|
to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
||||||
@@ -234,7 +238,7 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
|
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: " environmental: Object.freeze({",
|
from: ' environmental: Object.freeze({',
|
||||||
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
|
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -326,8 +330,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the Context panel is left collapsed, hiding the mode that just started',
|
defect: 'the Context panel is left collapsed, hiding the mode that just started',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });",
|
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
|
||||||
to: '// panel reveal removed',
|
to: ' // panel reveal removed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
|
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
|
||||||
@@ -340,8 +344,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
|
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));",
|
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
|
||||||
to: 'for (const button of buttons) button.disabled = next;',
|
to: ' for (const button of buttons) button.disabled = next;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'Tab escapes into an app the visitor has not seen yet',
|
defect: 'Tab escapes into an app the visitor has not seen yet',
|
||||||
@@ -370,8 +374,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the card stays clickable through its fade-out (double-launch)',
|
defect: 'the card stays clickable through its fade-out (double-launch)',
|
||||||
file: 'css',
|
file: 'css',
|
||||||
from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
|
from: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
|
||||||
to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
|
to: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the launcher survives Clean-UI and recording mode',
|
defect: 'the launcher survives Clean-UI and recording mode',
|
||||||
@@ -403,8 +407,8 @@ const MUTATIONS = [
|
|||||||
// those instead and prove nothing about this feature.
|
// those instead and prove nothing about this feature.
|
||||||
defect: "the launcher's reduced-motion opt-out is dropped",
|
defect: "the launcher's reduced-motion opt-out is dropped",
|
||||||
file: 'css',
|
file: 'css',
|
||||||
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }',
|
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow {\n transition: none;\n }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices {\n scroll-behavior: auto;\n }',
|
||||||
to: ' .first-run-nothing { transition: none; }',
|
to: ' .first-run-nothing {\n transition: none;\n }',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Markup and startup ordering ───────────────────────────────────────────
|
// ── Markup and startup ordering ───────────────────────────────────────────
|
||||||
@@ -442,21 +446,21 @@ const MUTATIONS = [
|
|||||||
defect: 'the launcher is revealed before the loading cover yields',
|
defect: 'the launcher is revealed before the loading cover yields',
|
||||||
file: 'main',
|
file: 'main',
|
||||||
from: " loadingScreen.classList.add('hidden');",
|
from: " loadingScreen.classList.add('hidden');",
|
||||||
to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');',
|
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the globe missions lose their DataManager and cannot enable layers',
|
defect: 'the globe missions lose their DataManager and cannot enable layers',
|
||||||
file: 'main',
|
file: 'main',
|
||||||
from: 'initFirstRunExperience({ styleManager, dataManager });',
|
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
|
||||||
to: 'initFirstRunExperience({ styleManager });',
|
to: 'firstRun = initializeWelcome?.({ styleManager });',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Voice: schema must not drift ──────────────────────────────────────────
|
// ── Voice: schema must not drift ──────────────────────────────────────────
|
||||||
{
|
{
|
||||||
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
|
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
|
||||||
file: 'voiceTools',
|
file: 'voiceDescriptions',
|
||||||
from: " 'earthquakes',\n 'satellites',",
|
from: "'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers",
|
||||||
to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',",
|
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
|
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const TESTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const FLIGHTS = 'src/layers/flights/motion.js';
|
const FLIGHTS = 'src/layers/flights/motion.js';
|
||||||
const FLOOR = 'src/data/groundFloor.js';
|
const FLOOR = 'src/services/groundFloor.js';
|
||||||
const ALT = 'src/data/renderAltitude.js';
|
const ALT = 'src/data/renderAltitude.js';
|
||||||
|
|
||||||
/** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */
|
/** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */
|
||||||
@@ -133,8 +133,8 @@ const MUTATIONS = [
|
|||||||
edits: [
|
edits: [
|
||||||
{
|
{
|
||||||
file: FLOOR,
|
file: FLOOR,
|
||||||
from: ' if (lowest == null || h < lowest) lowest = h;',
|
from: ' if (lowest == null || h < lowest) lowest = h;',
|
||||||
to: ' if (lowest == null || h > lowest) lowest = h;',
|
to: ' if (lowest == null || h > lowest) lowest = h;',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -143,8 +143,8 @@ const MUTATIONS = [
|
|||||||
edits: [
|
edits: [
|
||||||
{
|
{
|
||||||
file: FLOOR,
|
file: FLOOR,
|
||||||
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
|
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
|
||||||
to: ' return resolved >= 1 ? lowest : null;',
|
to: ' return resolved >= 1 ? lowest : null;',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,17 +35,17 @@ const MUTATIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'heading lerps in Cartesian space and cannot cross a U-turn',
|
defect: 'heading lerps in Cartesian space and cannot cross a U-turn',
|
||||||
from: ' const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir);',
|
from: ' const turned = Cesium.Matrix3.multiplyByVector(\n rotation,\n state.headingDir,\n _frameDir,\n );',
|
||||||
to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);',
|
to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the exact-180° bank direction becomes a floating-point coin toss',
|
defect: 'the exact-180° bank direction becomes a floating-point coin toss',
|
||||||
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) return Math.PI;',
|
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS)\n return Math.PI;',
|
||||||
to: ' // branch removed',
|
to: ' // branch removed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'a cold cell is read as sea level (the 1.3 km underground bug)',
|
defect: 'a cold cell is read as sea level (the 1.3 km underground bug)',
|
||||||
from: ' state.floorM = Number.isFinite(state.floorM) ? state.floorM : state.coldSeedFloorM;',
|
from: ' state.floorM = Number.isFinite(state.floorM)\n ? state.floorM\n : state.coldSeedFloorM;',
|
||||||
to: ' state.floorM = carto.height;',
|
to: ' state.floorM = carto.height;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,12 +55,12 @@ const MUTATIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the dolly races the fire-and-forget warm instead of arming for it',
|
defect: 'the dolly races the fire-and-forget warm instead of arming for it',
|
||||||
from: ' if (canAnswer && !state.floorKnown && !state.departed && state.armS < ROUTE_ARM_S) {',
|
from: ' if (\n canAnswer &&\n !state.floorKnown &&\n !state.departed &&\n state.armS < ROUTE_ARM_S\n ) {',
|
||||||
to: ' if (false) {',
|
to: ' if (false) {',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'no mesh probe, so a permanently cold DEM is flown blind',
|
defect: 'no mesh probe, so a permanently cold DEM is flown blind',
|
||||||
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {',
|
from: ' if (\n cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {',
|
||||||
to: ' if (false) {',
|
to: ' if (false) {',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,8 +90,8 @@ const MUTATIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the mesh probe fires every arming frame instead of once per route',
|
defect: 'the mesh probe fires every arming frame instead of once per route',
|
||||||
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
|
from: ' cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;',
|
||||||
to: ' if (cold.length && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
|
to: ' cold.length &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'one warm cell resolves a corridor whose other cells are unknown',
|
defect: 'one warm cell resolves a corridor whose other cells are unknown',
|
||||||
@@ -105,13 +105,13 @@ const MUTATIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample',
|
defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample',
|
||||||
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + ROUTE_MIN_CLEARANCE_M,',
|
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) +\n ROUTE_MIN_CLEARANCE_M,',
|
||||||
to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,',
|
to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)',
|
defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)',
|
||||||
from: ' cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
|
from: ' cam.setView({\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });',
|
||||||
to: ' cam.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000), orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
|
to: ' cam.setView({\n destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000),\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the OS reduced-motion preference is never consulted',
|
defect: 'the OS reduced-motion preference is never consulted',
|
||||||
|
|||||||
@@ -12,8 +12,10 @@
|
|||||||
* Run: start the app (`./scripts/dev-fresh.sh`) and
|
* Run: start the app (`./scripts/dev-fresh.sh`) and
|
||||||
* node scripts/qa-recent-imagery.mjs
|
* node scripts/qa-recent-imagery.mjs
|
||||||
* Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default
|
* Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default
|
||||||
* qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without
|
* qa-shots/recent-imagery), QA_HEADFUL=1.
|
||||||
* network access to NASA the run skips with exit code 0.
|
* QA_RECENT_IMAGERY_SLOW_RENDER=1 permits loaded SwiftShader CI to use a
|
||||||
|
* 4 s ZOOM IN wait and 0.8 s height-stability tail (defaults: 2 s / 1.2 s).
|
||||||
|
* Every wait is bounded. Without NASA access the run skips with code 0.
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -22,6 +24,9 @@ import puppeteer from 'puppeteer';
|
|||||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||||
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery';
|
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery';
|
||||||
const HEADFUL = process.env.QA_HEADFUL === '1';
|
const HEADFUL = process.env.QA_HEADFUL === '1';
|
||||||
|
const SLOW_RENDER = process.env.QA_RECENT_IMAGERY_SLOW_RENDER === '1';
|
||||||
|
const ZOOM_TIMEOUT_MS = SLOW_RENDER ? 4_000 : 2_000;
|
||||||
|
const HEIGHT_TAIL_SAMPLES = SLOW_RENDER ? 8 : 12;
|
||||||
const STEP_TIMEOUT = 20_000;
|
const STEP_TIMEOUT = 20_000;
|
||||||
const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 };
|
const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 };
|
||||||
const PANEL = '#recent-imagery-panel';
|
const PANEL = '#recent-imagery-panel';
|
||||||
@@ -308,15 +313,21 @@ const CONTROLS = {
|
|||||||
};
|
};
|
||||||
const measure = () =>
|
const measure = () =>
|
||||||
page.evaluate((controls) => {
|
page.evaluate((controls) => {
|
||||||
|
const panel = document.getElementById('recent-imagery-panel');
|
||||||
const body = document.getElementById('recent-imagery-panel-body');
|
const body = document.getElementById('recent-imagery-panel-body');
|
||||||
|
const panelRect = panel?.getBoundingClientRect();
|
||||||
const out = { bodyScroll: body?.scrollTop ?? null, rects: {} };
|
const out = { bodyScroll: body?.scrollTop ?? null, rects: {} };
|
||||||
for (const [name, selector] of Object.entries(controls)) {
|
for (const [name, selector] of Object.entries(controls)) {
|
||||||
const rect = document.querySelector(selector)?.getBoundingClientRect();
|
const rect = document.querySelector(selector)?.getBoundingClientRect();
|
||||||
out.rects[name] = rect
|
out.rects[name] =
|
||||||
? [rect.left, rect.top, rect.width, rect.height].map(
|
rect && panelRect
|
||||||
(v) => Math.round(v * 10) / 10,
|
? [
|
||||||
)
|
rect.left - panelRect.left,
|
||||||
: null;
|
rect.top - panelRect.top,
|
||||||
|
rect.width,
|
||||||
|
rect.height,
|
||||||
|
].map((v) => Math.round(v * 10) / 10)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, CONTROLS);
|
}, CONTROLS);
|
||||||
@@ -372,6 +383,9 @@ try {
|
|||||||
const initialStack = await page.evaluate(() => window.__riQa.activeStack());
|
const initialStack = await page.evaluate(() => window.__riQa.activeStack());
|
||||||
const photoreal = initialStack === 'photoreal';
|
const photoreal = initialStack === 'photoreal';
|
||||||
console.log(`initial map stack: ${initialStack}`);
|
console.log(`initial map stack: ${initialStack}`);
|
||||||
|
console.log(
|
||||||
|
`stability: ${SLOW_RENDER ? 'slow-render' : 'default'} (zoom ${ZOOM_TIMEOUT_MS} ms, last ${HEIGHT_TAIL_SAMPLES} samples)`,
|
||||||
|
);
|
||||||
const onPhotorealOnly = (name, passed, detail) =>
|
const onPhotorealOnly = (name, passed, detail) =>
|
||||||
photoreal
|
photoreal
|
||||||
? check(name, passed, detail)
|
? check(name, passed, detail)
|
||||||
@@ -1042,13 +1056,13 @@ try {
|
|||||||
) <=
|
) <=
|
||||||
target * 0.01,
|
target * 0.01,
|
||||||
fitHeight,
|
fitHeight,
|
||||||
2_000,
|
ZOOM_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
const zoomedHeight = await page.evaluate(
|
const zoomedHeight = await page.evaluate(
|
||||||
() => window.__godsEyeView.viewer.camera.positionCartographic.height,
|
() => window.__godsEyeView.viewer.camera.positionCartographic.height,
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
`ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`,
|
`ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`,
|
||||||
zoomed,
|
zoomed,
|
||||||
`height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`,
|
`height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`,
|
||||||
);
|
);
|
||||||
@@ -1258,7 +1272,8 @@ try {
|
|||||||
);
|
);
|
||||||
await wait(100);
|
await wait(100);
|
||||||
}
|
}
|
||||||
const tail = (key) => new Set(samples.slice(8).map((s) => s[key]));
|
const tail = (key) =>
|
||||||
|
new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key]));
|
||||||
return {
|
return {
|
||||||
settled: tail('imagery').size === 1 && tail('cctv').size === 1,
|
settled: tail('imagery').size === 1 && tail('cctv').size === 1,
|
||||||
detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`,
|
detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`,
|
||||||
|
|||||||
@@ -139,15 +139,19 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(APP_URL);
|
const res = await fetch(APP_URL);
|
||||||
|
// Only the status matters; an unread body can hold the socket open.
|
||||||
|
await res.body?.cancel();
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||||
if (!status?.hasKey) {
|
if (!status?.hasKey) {
|
||||||
console.error('Server has no TomTom key — the A/B needs live flow.');
|
console.error('Server has no TomTom key — the A/B needs live flow.');
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
|||||||
@@ -145,15 +145,19 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(APP_URL);
|
const res = await fetch(APP_URL);
|
||||||
|
// Only the status matters; an unread body can hold the socket open.
|
||||||
|
await res.body?.cancel();
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||||
if (!status?.hasKey) {
|
if (!status?.hasKey) {
|
||||||
console.error('Server has no TomTom key — the A/B needs live flow.');
|
console.error('Server has no TomTom key — the A/B needs live flow.');
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
|||||||
@@ -113,17 +113,21 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(APP_URL);
|
const res = await fetch(APP_URL);
|
||||||
|
// Only the status matters; an unread body can hold the socket open.
|
||||||
|
await res.body?.cancel();
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||||
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
|
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
|
||||||
if (!statusBefore?.hasKey) {
|
if (!statusBefore?.hasKey) {
|
||||||
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
|
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||||
|
|||||||
@@ -21,9 +21,20 @@ export function assertNode24AllocationRuntime(version = process.versions.node) {
|
|||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directories this runner searches for `*.test.mjs`, in stable order.
|
||||||
|
*
|
||||||
|
* `server/` belongs here because provider modules own tests of their own: a
|
||||||
|
* suite placed beside the module it covers must run, and CI's unit-test job runs
|
||||||
|
* `npm test`, so a directory missing from this list is a suite that never runs
|
||||||
|
* there. `unitTestRunner.test.mjs` asserts the list covers every `*.test.mjs`
|
||||||
|
* outside dependencies, build output and dot-directories, so adding a tree
|
||||||
|
* without adding it here fails.
|
||||||
|
*/
|
||||||
|
export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']);
|
||||||
|
|
||||||
/** Discover repository unit tests in stable path order. */
|
/** Discover repository unit tests in stable path order. */
|
||||||
export function discoverUnitTestFiles(root = process.cwd()) {
|
export function discoverUnitTestFiles(root = process.cwd()) {
|
||||||
const sourceRoot = path.join(root, 'src');
|
|
||||||
const files = [];
|
const files = [];
|
||||||
const visit = (directory) => {
|
const visit = (directory) => {
|
||||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
@@ -34,7 +45,7 @@ export function discoverUnitTestFiles(root = process.cwd()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
visit(sourceRoot);
|
for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot));
|
||||||
return files.sort();
|
return files.sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,9 +142,11 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde
|
|||||||
const rows = Array.from({ length: 15000 }, (_, i) =>
|
const rows = Array.from({ length: 15000 }, (_, i) =>
|
||||||
row(now, `vehicle-${i}`),
|
row(now, `vehicle-${i}`),
|
||||||
);
|
);
|
||||||
const start = performance.now();
|
// Main-thread CPU: excludes scheduler waits and parallel GC helper threads.
|
||||||
|
const start = process.threadCpuUsage();
|
||||||
h.ingest(feed, rows, now);
|
h.ingest(feed, rows, now);
|
||||||
times.push(performance.now() - start);
|
const usage = process.threadCpuUsage(start);
|
||||||
|
times.push((usage.user + usage.system) / 1000);
|
||||||
}
|
}
|
||||||
assert.equal(h.diagnostics().keys, 15000);
|
assert.equal(h.diagnostics().keys, 15000);
|
||||||
for (let i = 0; i < 15000; i++) {
|
for (let i = 0; i < 15000; i++) {
|
||||||
@@ -154,12 +156,12 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde
|
|||||||
}
|
}
|
||||||
assert.ok(h.diagnostics().allocatedBytes < 16 * 1024 * 1024);
|
assert.ok(h.diagnostics().allocatedBytes < 16 * 1024 * 1024);
|
||||||
t.diagnostic(
|
t.diagnostic(
|
||||||
`15000-vehicle ingestion ms: ${times.map((n) => n.toFixed(1)).join('/')}`,
|
`15000-vehicle ingestion CPU ms: ${times.map((n) => n.toFixed(1)).join('/')}`,
|
||||||
);
|
);
|
||||||
// A ceiling that says "not quadratic", not a benchmark: 15,000 rows in
|
// CPU time measures ingestion work without counting scheduler delays on
|
||||||
// well under two seconds on a loaded laptop or a slow CI runner. The
|
// loaded runners. Keep the two-second ceiling as a guard against quadratic
|
||||||
// per-run figure above is the diagnostic to read.
|
// work, not a benchmark; the per-poll figures above show any slowdown.
|
||||||
assert.ok(Math.max(...times) < 2000, `ingestion ms: ${times}`);
|
assert.ok(Math.max(...times) < 2000, `ingestion CPU ms: ${times}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('snapshot capacity pressure preserves members not yet visited and discloses recreated history', (t) => {
|
test('snapshot capacity pressure preserves members not yet visited and discloses recreated history', (t) => {
|
||||||
|
|||||||
@@ -51,9 +51,11 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
|
|||||||
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
|
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
|
||||||
let adminSignal;
|
let adminSignal;
|
||||||
let release;
|
let release;
|
||||||
|
let lookupStarted;
|
||||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||||
featureSource: featureSource({
|
featureSource: featureSource({
|
||||||
getAdministrativeAreas(_point, { signal }) {
|
getAdministrativeAreas(_point, { signal }) {
|
||||||
|
lookupStarted = Date.now();
|
||||||
adminSignal = signal;
|
adminSignal = signal;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
release = resolve;
|
release = resolve;
|
||||||
@@ -61,7 +63,6 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const started = Date.now();
|
|
||||||
const region = await resolveRegionRingForQuery(
|
const region = await resolveRegionRingForQuery(
|
||||||
'Atlantis',
|
'Atlantis',
|
||||||
undefined,
|
undefined,
|
||||||
@@ -73,8 +74,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
|
|||||||
ring: null,
|
ring: null,
|
||||||
error: 'region-timeout',
|
error: 'region-timeout',
|
||||||
});
|
});
|
||||||
|
// Timed from the boundary lookup, so the bundled pack scans before it do not
|
||||||
|
// count. Under the 3 s default budget, so an ignored budgetMs still fails.
|
||||||
assert.ok(
|
assert.ok(
|
||||||
Date.now() - started < 1000,
|
Date.now() - lookupStarted < 2_000,
|
||||||
'returns at the budget, not the lookup',
|
'returns at the budget, not the lookup',
|
||||||
);
|
);
|
||||||
// The lookup is left running so it can fill the boundary cache.
|
// The lookup is left running so it can fill the boundary cache.
|
||||||
|
|||||||
@@ -145,17 +145,20 @@ test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by
|
|||||||
|
|
||||||
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
|
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
|
||||||
const checked = [];
|
const checked = [];
|
||||||
|
// Built with path.join so the stub matches the resolver on Windows too,
|
||||||
|
// where the separators are backslashes.
|
||||||
|
const installed = path.join('/home/fixture', '.local', 'bin', 'codex');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveCodexExecutable({
|
resolveCodexExecutable({
|
||||||
environment: {},
|
environment: {},
|
||||||
home: '/home/fixture',
|
home: '/home/fixture',
|
||||||
access(candidate) {
|
access(candidate) {
|
||||||
checked.push(candidate);
|
checked.push(candidate);
|
||||||
if (candidate.endsWith('/.local/bin/codex')) return;
|
if (candidate === installed) return;
|
||||||
throw new Error('missing');
|
throw new Error('missing');
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
path.join('/home/fixture', '.local', 'bin', 'codex'),
|
installed,
|
||||||
);
|
);
|
||||||
assert.equal(checked.length, 1);
|
assert.equal(checked.length, 1);
|
||||||
|
|
||||||
|
|||||||
@@ -2398,10 +2398,13 @@ test('a newer absolute intent aborts a hung guard-compensation instead of starvi
|
|||||||
removeGuard();
|
removeGuard();
|
||||||
// The newest intent must be able to abort the hung compensation and run.
|
// The newest intent must be able to abort the hung compensation and run.
|
||||||
const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' });
|
const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' });
|
||||||
|
let giveUpTimer;
|
||||||
const finalOn = await Promise.race([
|
const finalOn = await Promise.race([
|
||||||
pendingFinalOn,
|
pendingFinalOn,
|
||||||
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
|
new Promise((resolve) => {
|
||||||
]);
|
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
|
||||||
|
}),
|
||||||
|
]).finally(() => clearTimeout(giveUpTimer));
|
||||||
assert.notEqual(finalOn, 'starved', 'newest intent must not starve behind the compensation');
|
assert.notEqual(finalOn, 'starved', 'newest intent must not starve behind the compensation');
|
||||||
assert.equal(compensationAborted, true, 'the hung compensation was aborted');
|
assert.equal(compensationAborted, true, 'the hung compensation was aborted');
|
||||||
assert.equal(await pendingBlockedOn, false, 'the guard-blocked request stays unfulfilled');
|
assert.equal(await pendingBlockedOn, false, 'the guard-blocked request stays unfulfilled');
|
||||||
@@ -2451,10 +2454,13 @@ test('re-entrant setEnabled from a blocked-adoption listener supersedes the comp
|
|||||||
releaseFirstDisable();
|
releaseFirstDisable();
|
||||||
|
|
||||||
const blockedOn = await pendingBlockedOn;
|
const blockedOn = await pendingBlockedOn;
|
||||||
|
let giveUpTimer;
|
||||||
const reentrant = await Promise.race([
|
const reentrant = await Promise.race([
|
||||||
(async () => reentrantResult === null ? 'never-fired' : await reentrantResult)(),
|
(async () => reentrantResult === null ? 'never-fired' : await reentrantResult)(),
|
||||||
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
|
new Promise((resolve) => {
|
||||||
]);
|
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
|
||||||
|
}),
|
||||||
|
]).finally(() => clearTimeout(giveUpTimer));
|
||||||
await pendingOff;
|
await pendingOff;
|
||||||
await mgr.waitForLayerSettled('flights');
|
await mgr.waitForLayerSettled('flights');
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* A v4 UUID for the client lease. `crypto.randomUUID` only exists in secure
|
||||||
|
* contexts, so a page opened over plain HTTP from another machine (the
|
||||||
|
* HOST=0.0.0.0 LAN opt-in) has none; `getRandomValues` exists in every context
|
||||||
|
* and yields the same shape the server's lease check accepts.
|
||||||
|
*/
|
||||||
|
export function createLeaseId(cryptoImpl = globalThis.crypto) {
|
||||||
|
if (typeof cryptoImpl.randomUUID === 'function') {
|
||||||
|
return cryptoImpl.randomUUID();
|
||||||
|
}
|
||||||
|
const bytes = cryptoImpl.getRandomValues(new Uint8Array(16));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'));
|
||||||
|
return [
|
||||||
|
hex.slice(0, 4),
|
||||||
|
hex.slice(4, 6),
|
||||||
|
hex.slice(6, 8),
|
||||||
|
hex.slice(8, 10),
|
||||||
|
hex.slice(10),
|
||||||
|
]
|
||||||
|
.map((group) => group.join(''))
|
||||||
|
.join('-');
|
||||||
|
}
|
||||||
|
|
||||||
/** One decoder per active camera; both surfaces consume this video element. */
|
/** One decoder per active camera; both surfaces consume this video element. */
|
||||||
export function attachCctvVideo(
|
export function attachCctvVideo(
|
||||||
video,
|
video,
|
||||||
@@ -7,13 +32,14 @@ export function attachCctvVideo(
|
|||||||
loadHls = () => import('hls.js'),
|
loadHls = () => import('hls.js'),
|
||||||
onFailure = () => {},
|
onFailure = () => {},
|
||||||
fetchImpl = globalThis.fetch,
|
fetchImpl = globalThis.fetch,
|
||||||
|
cryptoImpl = globalThis.crypto,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
// Preserve replay for existing finite video feeds; live HLS must not loop.
|
// Preserve replay for existing finite video feeds; live HLS must not loop.
|
||||||
video.loop = feedType !== 'hls';
|
video.loop = feedType !== 'hls';
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let hls = null;
|
let hls = null;
|
||||||
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
|
const leaseId = feedType === 'hls' ? createLeaseId(cryptoImpl) : null;
|
||||||
const mediaUrl =
|
const mediaUrl =
|
||||||
feedType === 'hls'
|
feedType === 'hls'
|
||||||
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
|
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { attachCctvVideo } from './videoPlayback.js';
|
import { webcrypto } from 'node:crypto';
|
||||||
|
import { attachCctvVideo, createLeaseId } from './videoPlayback.js';
|
||||||
|
|
||||||
|
const LEASE_V4 =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
/** What an insecure context exposes: getRandomValues but no randomUUID. */
|
||||||
|
const insecureCrypto = {
|
||||||
|
getRandomValues: (array) => webcrypto.getRandomValues(array),
|
||||||
|
};
|
||||||
function video() {
|
function video() {
|
||||||
const v = new EventTarget();
|
const v = new EventTarget();
|
||||||
Object.assign(v, {
|
Object.assign(v, {
|
||||||
@@ -69,6 +78,34 @@ test('native HLS releases its client lease without response-header access', asyn
|
|||||||
assert.equal(releases[0].init.method, 'DELETE');
|
assert.equal(releases[0].init.method, 'DELETE');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('lease ids are v4 UUIDs with or without crypto.randomUUID', () => {
|
||||||
|
assert.match(createLeaseId(), LEASE_V4);
|
||||||
|
const ids = new Set();
|
||||||
|
for (let i = 0; i < 64; i++) ids.add(createLeaseId(insecureCrypto));
|
||||||
|
assert.equal(ids.size, 64);
|
||||||
|
for (const id of ids) assert.match(id, LEASE_V4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HLS starts on a plain-HTTP LAN page without crypto.randomUUID', async () => {
|
||||||
|
const source = video();
|
||||||
|
source.canPlayType = () => 'probably';
|
||||||
|
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
|
||||||
|
loadHls: async () => ({ default: { isSupported: () => false } }),
|
||||||
|
fetchImpl: async () => {},
|
||||||
|
cryptoImpl: insecureCrypto,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await playback.ready;
|
||||||
|
const lease = new URL(
|
||||||
|
source.src,
|
||||||
|
'http://192.168.1.10:4173/',
|
||||||
|
).searchParams.get('lease');
|
||||||
|
assert.match(lease, LEASE_V4);
|
||||||
|
} finally {
|
||||||
|
playback.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('finite video feeds retain looping while live HLS does not loop', async () => {
|
test('finite video feeds retain looping while live HLS does not loop', async () => {
|
||||||
for (const feedType of ['mp4', 'webm', 'hls']) {
|
for (const feedType of ['mp4', 'webm', 'hls']) {
|
||||||
const source = video();
|
const source = video();
|
||||||
|
|||||||
@@ -357,10 +357,11 @@ test('WebUSB picker remains available when no authorized receiver matches', asyn
|
|||||||
|
|
||||||
/** Resolve with the promise's value, or 'timed out' after `ms`. */
|
/** Resolve with the promise's value, or 'timed out' after `ms`. */
|
||||||
function within(promise, ms = 1_000) {
|
function within(promise, ms = 1_000) {
|
||||||
return Promise.race([
|
let timer;
|
||||||
promise,
|
const timeout = new Promise((resolve) => {
|
||||||
new Promise((resolve) => setTimeout(() => resolve('timed out'), ms)),
|
timer = setTimeout(() => resolve('timed out'), ms);
|
||||||
]);
|
});
|
||||||
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseCoordinateQuery } from './coordinateParser.js';
|
||||||
|
test('search refuses omitted components and repeated separators', () => {
|
||||||
|
for (const q of ['30,, -97', ',30,-97', '30,-97,', '30;; -97', '30,; -97'])
|
||||||
|
assert.equal(parseCoordinateQuery(q), null, q);
|
||||||
|
for (const q of ['30,-97', '30; -97', '30 -97', 'N 30, W 97'])
|
||||||
|
assert.deepEqual(
|
||||||
|
[parseCoordinateQuery(q).lat, parseCoordinateQuery(q).lon],
|
||||||
|
[30, -97],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -49,8 +49,8 @@ function splitComponents(query) {
|
|||||||
const parts = /[,;]/.test(text)
|
const parts = /[,;]/.test(text)
|
||||||
? text.split(/[,;]/)
|
? text.split(/[,;]/)
|
||||||
: text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i);
|
: text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i);
|
||||||
const trimmed = parts.map((part) => part.trim()).filter(Boolean);
|
const trimmed = parts.map((part) => part.trim());
|
||||||
return trimmed.length === 2 ? trimmed : null;
|
return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Present a parsed pair the way a coordinate is normally written. */
|
/** Present a parsed pair the way a coordinate is normally written. */
|
||||||
|
|||||||
+4
-2
@@ -29,11 +29,13 @@ export const snowShader = {
|
|||||||
float depth = 0.5 + layer * 0.5;
|
float depth = 0.5 + layer * 0.5;
|
||||||
float speed = 0.4 + layer * 0.3;
|
float speed = 0.4 + layer * 0.3;
|
||||||
float size = mix(0.01, 0.025, layer);
|
float size = mix(0.01, 0.025, layer);
|
||||||
float windForce = sin(time * 0.5 + layer * 3.14) * (0.05 + wind * 0.2);
|
float windForce = 0.05 + wind * 0.2;
|
||||||
|
|
||||||
vec2 snowUV = uv * vec2(1.0, 0.5) * (4.0 + layer * 4.0);
|
vec2 snowUV = uv * vec2(1.0, 0.5) * (4.0 + layer * 4.0);
|
||||||
snowUV.y += time * speed;
|
snowUV.y += time * speed;
|
||||||
snowUV.x += time * windForce;
|
// Offset is the integral of a swaying velocity sin(time * 0.5 + phase) * windForce,
|
||||||
|
// so the sway stays bounded however long the app has been running.
|
||||||
|
snowUV.x -= 2.0 * cos(time * 0.5 + layer * 3.14) * windForce;
|
||||||
|
|
||||||
vec2 cell = floor(snowUV);
|
vec2 cell = floor(snowUV);
|
||||||
vec2 f = fract(snowUV);
|
vec2 f = fract(snowUV);
|
||||||
|
|||||||
@@ -704,6 +704,51 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity', () => {
|
||||||
|
const f = fixture('right');
|
||||||
|
const weather = element('weather-panel', { height: 300 });
|
||||||
|
weather.computed.display = 'none';
|
||||||
|
f.stack.children.push(weather);
|
||||||
|
weather.parentElement = f.stack;
|
||||||
|
|
||||||
|
f.run();
|
||||||
|
|
||||||
|
assert.equal(f.stack.classList.contains('layout-exclusive'), false);
|
||||||
|
assert.equal(f.stack.dataset.expandedCount, '0');
|
||||||
|
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||||
|
assert.equal(f.second.getAttribute('aria-hidden'), undefined);
|
||||||
|
assert.equal(
|
||||||
|
weather.style.getPropertyValue('--right-panel-allocated-height'),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('right layout still restores panels that exclusive presentation hides', () => {
|
||||||
|
const f = fixture('right');
|
||||||
|
// Model layers.css: an exclusive rail hides its collapsed panels.
|
||||||
|
f.options.windowRef.getComputedStyle = (node) => ({
|
||||||
|
...node.computed,
|
||||||
|
display:
|
||||||
|
f.stack.classList.contains('layout-exclusive') &&
|
||||||
|
node.classList.contains('collapsed')
|
||||||
|
? 'none'
|
||||||
|
: node.computed.display,
|
||||||
|
});
|
||||||
|
f.expand(f.first, 250);
|
||||||
|
f.second.classList.add('layout-auto-collapsed');
|
||||||
|
f.stack.classList.add('layout-exclusive');
|
||||||
|
|
||||||
|
f.run();
|
||||||
|
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
|
||||||
|
assert.equal(f.second.getAttribute('aria-hidden'), 'true');
|
||||||
|
|
||||||
|
f.options.hud.visible = false;
|
||||||
|
f.run();
|
||||||
|
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||||
|
assert.equal(f.second.classList.contains('layout-auto-collapsed'), false);
|
||||||
|
assert.deepEqual(f.collapsed, ['second']);
|
||||||
|
});
|
||||||
|
|
||||||
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
|
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
|
||||||
const f = fixture('right');
|
const f = fixture('right');
|
||||||
const imagery = element('recent-imagery-panel', { height: 0 });
|
const imagery = element('recent-imagery-panel', { height: 0 });
|
||||||
|
|||||||
@@ -69,10 +69,17 @@ export function layoutRightPanelRail({
|
|||||||
// A collapse may schedule one follow-up, which only measures and allocates.
|
// A collapse may schedule one follow-up, which only measures and allocates.
|
||||||
const isCollapseRetry = pendingCollapseRetries.delete(stack);
|
const isCollapseRetry = pendingCollapseRetries.delete(stack);
|
||||||
|
|
||||||
// A floating (lifted-out) panel is not laid out by the rail.
|
// A floating (lifted-out) panel is not laid out by the rail. Neither is one
|
||||||
|
// that other CSS hides, such as an event view. Exclusive presentation hides
|
||||||
|
// collapsed panels itself; those stay in the rail so they can be restored.
|
||||||
|
const hiddenByRail = (panel) =>
|
||||||
|
stack.classList.contains('layout-exclusive') &&
|
||||||
|
panel.classList.contains('collapsed');
|
||||||
const panels = [...stack.children].filter(
|
const panels = [...stack.children].filter(
|
||||||
(panel) =>
|
(panel) =>
|
||||||
panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden,
|
panel.matches('[data-panel-id]:not(.panel-floating)') &&
|
||||||
|
!panel.hidden &&
|
||||||
|
(hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'),
|
||||||
);
|
);
|
||||||
if (!hud.visible || hud.variant !== 'tactical') {
|
if (!hud.visible || hud.variant !== 'tactical') {
|
||||||
for (const panel of panels.filter((item) =>
|
for (const panel of panels.filter((item) =>
|
||||||
|
|||||||
@@ -1,14 +1,40 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
ALLOCATION_TEST_FILES,
|
ALLOCATION_TEST_FILES,
|
||||||
|
UNIT_TEST_ROOTS,
|
||||||
allocationTestArgs,
|
allocationTestArgs,
|
||||||
assertNode24AllocationRuntime,
|
assertNode24AllocationRuntime,
|
||||||
buildUnitTestPlan,
|
buildUnitTestPlan,
|
||||||
|
discoverUnitTestFiles,
|
||||||
isCalibratedAllocationRuntime,
|
isCalibratedAllocationRuntime,
|
||||||
} from '../scripts/run-unit-tests.mjs';
|
} from '../scripts/run-unit-tests.mjs';
|
||||||
|
|
||||||
|
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
|
||||||
|
/** Every `*.test.mjs` a contributor could add, found without consulting the runner. */
|
||||||
|
function testFilesOnDisk(root) {
|
||||||
|
// Build outputs and dependencies are not places a contributor writes a test,
|
||||||
|
// and walking them would be slow enough to matter. Everything else counts.
|
||||||
|
const skip = new Set(['node_modules', 'dist', 'build', 'public']);
|
||||||
|
const found = [];
|
||||||
|
const visit = (directory) => {
|
||||||
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
if (entry.name.startsWith('.') || skip.has(entry.name)) continue;
|
||||||
|
const absolute = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) visit(absolute);
|
||||||
|
else if (entry.isFile() && entry.name.endsWith('.test.mjs')) {
|
||||||
|
found.push(path.relative(root, absolute).split(path.sep).join('/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(root);
|
||||||
|
return found.sort();
|
||||||
|
}
|
||||||
|
|
||||||
test('unit runner serializes only GC-bracketed allocation microbenchmarks', () => {
|
test('unit runner serializes only GC-bracketed allocation microbenchmarks', () => {
|
||||||
const ordinary = [
|
const ordinary = [
|
||||||
'src/data/manager.test.mjs',
|
'src/data/manager.test.mjs',
|
||||||
@@ -37,6 +63,30 @@ test('unit runner serializes only GC-bracketed allocation microbenchmarks', () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the runner discovers every test file in the repository', () => {
|
||||||
|
// CI's unit-test job runs `npm test`, which runs exactly what this function
|
||||||
|
// returns. A `*.test.mjs` outside the searched roots therefore never runs
|
||||||
|
// there, while still reading — in review and to its author — like covered
|
||||||
|
// code. Compare the two sets rather than their sizes, so the failure names the
|
||||||
|
// files instead of only disagreeing about a count. The final equality also
|
||||||
|
// catches the reverse: files the runner finds that this walk skips.
|
||||||
|
const discovered = discoverUnitTestFiles(repositoryRoot);
|
||||||
|
const onDisk = testFilesOnDisk(repositoryRoot);
|
||||||
|
const undiscovered = onDisk.filter((file) => !discovered.includes(file));
|
||||||
|
assert.deepEqual(
|
||||||
|
undiscovered,
|
||||||
|
[],
|
||||||
|
`test files the runner never executes: ${undiscovered.join(', ')}`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(discovered, onDisk);
|
||||||
|
for (const file of discovered) {
|
||||||
|
assert.ok(
|
||||||
|
UNIT_TEST_ROOTS.some((root) => file.startsWith(`${root}/`)),
|
||||||
|
`${file} should sit under one of ${UNIT_TEST_ROOTS.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('allocation runtime calibration is explicit and pinned to Node 24', () => {
|
test('allocation runtime calibration is explicit and pinned to Node 24', () => {
|
||||||
assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0');
|
assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0');
|
||||||
assert.throws(
|
assert.throws(
|
||||||
|
|||||||
Reference in new issue
Block a user