From 0da4d390eaf5b54cac0efeb269879d92e6a562bd Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 26 Sep 2026 17:36:59 +0700 Subject: [PATCH 01/28] docs: add the realtime backend to the voice ownership table The ownership table lists every owner except the backend, though realtimeBackend.js owns the two requests, their separate transports, the abort/timeout scope around both, and client-secret expiry. The method signatures are already documented with application construction, so link there rather than repeat them, and state only the ownership facts written nowhere: the connection is the backend's only consumer in the application, the resolved credential is opaque to it and is handed to negotiate whole, and client-secret expiry is refused inside the backend. --- docs/VOICE-OWNERSHIP.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/VOICE-OWNERSHIP.md b/docs/VOICE-OWNERSHIP.md index d5553df00..be7a6c26b 100644 --- a/docs/VOICE-OWNERSHIP.md +++ b/docs/VOICE-OWNERSHIP.md @@ -8,6 +8,7 @@ The session adapter, action runner and backend interfaces remain unchanged. | Owner | State and responsibility | | --- | --- | | `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 | | `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 | @@ -15,6 +16,15 @@ The session adapter, action runner and backend interfaces remain unchanged. | `realtimeViewport.js` | One retained image, bounded deletion identities and capture generation | | `realtimeDiagnostics.js` | Bounded error history and sanitized optional diagnostics | +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 resolved credential is opaque to the connection and is handed to `negotiate` +whole. 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. Owners receive named readers, operations or focused collaborators. Cross-owner cancellation uses operations; response handling does not own Radio's reservations From 993b1bcdb1d7871a1f39b7cabc7c16387d93dd5d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:29:19 -0500 Subject: [PATCH 02/28] docs: add Pinokio browser performance workaround --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 88690d52f..0ff6c32e1 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,14 @@ locked dependencies, finds a free local port, and opens the app. Version 8.2 fixes the launcher installation issue; [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. A +Pinokio 8.2.0 Linux report reproduced severe stutter in Pinokio's embedded view +while the same running server was smooth in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). +That isolates the browser/compositor path from the app server and your machine; +it does not establish a minimum GPU requirement. + ### Path 2 — Terminal / coding agent Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about From 4ab530b8a83c6f56b34612c1ea5c9bc07c34e3ea Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:29:21 -0500 Subject: [PATCH 03/28] docs: distinguish embedded browser performance --- docs/PERFORMANCE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 34b207f79..873ef8768 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -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. 7. Treat a live-source outage as missing coverage, not as evidence of low client rendering cost. +8. Record whether the app is running in Pinokio's embedded view or in an + external browser. The same Pinokio 8.2.0 Linux server was reported as + severely stuttery in the embedded view and smooth when its local URL was + opened directly in a normal browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). + Treat that as a browser/compositor-path difference until the GPU renderer + proves otherwise. ## What is not established yet From c87f007e34a627bc9554c4bfe8070267f9c5e0b5 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 11:39:19 +0700 Subject: [PATCH 04/28] fix(build): discover unit tests under server/, not only src/ npm test is the only suite CI runs, and its discovery walked src/ alone, so server/providers/transitHistory.test.mjs has never executed since it was added. Its 8 tests pass, so this was silent coverage loss rather than a hidden failure: the file reads like covered code in review and never ran anywhere. Add server/ to the searched roots, and pin the property with a test that walks the repository itself and compares the two sets, so a tree added later without updating the roots fails loudly instead of going quiet. Suite goes from 5090 to 5099 tests, 0 failures: the 8 tests in that file plus the regression test added here. --- scripts/run-unit-tests.mjs | 14 ++++++++-- src/unitTestRunner.test.mjs | 51 ++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 9bd7e70e0..7024ea38c 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -21,9 +21,19 @@ export function assertNode24AllocationRuntime(version = process.versions.node) { 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 `npm test` is the only + * thing CI invokes, so a directory missing from this list is a suite that never + * runs anywhere. `unitTestRunner.test.mjs` asserts the list covers every test + * file in the repository, 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. */ export function discoverUnitTestFiles(root = process.cwd()) { - const sourceRoot = path.join(root, 'src'); const files = []; const visit = (directory) => { for (const entry of readdirSync(directory, { withFileTypes: true })) { @@ -34,7 +44,7 @@ export function discoverUnitTestFiles(root = process.cwd()) { } } }; - visit(sourceRoot); + for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot)); return files.sort(); } diff --git a/src/unitTestRunner.test.mjs b/src/unitTestRunner.test.mjs index 002dcb796..201f6ed8e 100644 --- a/src/unitTestRunner.test.mjs +++ b/src/unitTestRunner.test.mjs @@ -1,14 +1,40 @@ import test from 'node:test'; 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 { ALLOCATION_TEST_FILES, + UNIT_TEST_ROOTS, allocationTestArgs, assertNode24AllocationRuntime, buildUnitTestPlan, + discoverUnitTestFiles, isCalibratedAllocationRuntime, } 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', () => { const ordinary = [ 'src/data/manager.test.mjs', @@ -37,6 +63,29 @@ test('unit runner serializes only GC-bracketed allocation microbenchmarks', () = ); }); +test('the runner discovers every test file in the repository', () => { + // `npm test` is the only suite CI runs, and it runs exactly what this + // function returns. A `*.test.mjs` outside the searched roots therefore never + // executes anywhere, 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. + 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', () => { assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0'); assert.throws( From de4c8343ddd3ce3a164e0d44e63cf3c7e805f477 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:33:46 -0500 Subject: [PATCH 05/28] test: make Recent Imagery live gate panel-relative --- scripts/qa-recent-imagery.mjs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 1460ed3b5..f9601432f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -308,15 +308,21 @@ const CONTROLS = { }; const measure = () => page.evaluate((controls) => { + const panel = document.getElementById('recent-imagery-panel'); const body = document.getElementById('recent-imagery-panel-body'); + const panelRect = panel?.getBoundingClientRect(); const out = { bodyScroll: body?.scrollTop ?? null, rects: {} }; for (const [name, selector] of Object.entries(controls)) { const rect = document.querySelector(selector)?.getBoundingClientRect(); - out.rects[name] = rect - ? [rect.left, rect.top, rect.width, rect.height].map( - (v) => Math.round(v * 10) / 10, - ) - : null; + out.rects[name] = + rect && panelRect + ? [ + rect.left - panelRect.left, + rect.top - panelRect.top, + rect.width, + rect.height, + ].map((v) => Math.round(v * 10) / 10) + : null; } return out; }, CONTROLS); @@ -1042,13 +1048,13 @@ try { ) <= target * 0.01, fitHeight, - 2_000, + 4_000, ); const zoomedHeight = await page.evaluate( () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`, + `ZOOM IN lands at ${Math.round(fitHeight / 1000)} km`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1258,7 +1264,7 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(8).map((s) => s[key])); + const tail = (key) => new Set(samples.slice(-8).map((s) => s[key])); return { settled: tail('imagery').size === 1 && tail('cctv').size === 1, detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, From 8d1f69a8731b6cab3fb95f1bf6d5cd05d89f7e85 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:34:28 -0500 Subject: [PATCH 06/28] ci: prove Recent Imagery gate repair --- .../recent-imagery-gate-repair-proof.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/recent-imagery-gate-repair-proof.yml diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml new file mode 100644 index 000000000..b613d9561 --- /dev/null +++ b/.github/workflows/recent-imagery-gate-repair-proof.yml @@ -0,0 +1,37 @@ +name: Recent Imagery gate repair proof + +on: + push: + branches: + - fix/recent-imagery-qa-relative-layout-20261003 + +permissions: + contents: read + +jobs: + browser-proof: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - name: Start GEV + run: | + npm run dev -- --host 127.0.0.1 --port 4173 > /tmp/gev-dev.log 2>&1 & + for i in {1..90}; do + if curl -fsS http://127.0.0.1:4173/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/gev-dev.log + exit 1 + - name: Recent Imagery live gate + run: node scripts/qa-recent-imagery.mjs + - name: Dev log on failure + if: failure() + run: cat /tmp/gev-dev.log From 123bf55f39a7dc12cad6215b362da0acc91c6c23 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:52:30 -0700 Subject: [PATCH 07/28] fix(qa): repoint stale mutation anchors after the September refactors The three mutation gates matched source text that has since moved or been reflowed, so 37 of their 108 reverts reported STALE and could no longer prove anything: - qa-floorhold-mutations (2): the neighbour-floor code moved from src/data/groundFloor.js into src/services/groundFloor.js (ac977bd). - qa-flyroute-mutations (8): src/cameraVerbs.js was reflowed by the formatter (2aca7ae); the anchors now match the formatted lines. - qa-firstrun-mutations (27): the launcher markup, styles and startup moved to src/ui/templates/welcome.html, src/ui/styles/first-run.css and src/app/startupChrome.js; the rail default to panelPositionControls.js and the radio ESC handling to radioBindings.js (a67bc60 and follow-ups). The tool-schema pin now edits a tool description, since the context-mode enum it used to edit no longer lives in tools.js. Every revert still reintroduces the same defect. Results: floor-hold 22/22, fly_route 19/19, first-run 67/67 caught, with all source files restored. Co-Authored-By: Claude Opus 5.5 --- scripts/qa-firstrun-mutations.mjs | 68 ++++++++++++++++-------------- scripts/qa-floorhold-mutations.mjs | 10 ++--- scripts/qa-flyroute-mutations.mjs | 20 ++++----- 3 files changed, 51 insertions(+), 47 deletions(-) diff --git a/scripts/qa-firstrun-mutations.mjs b/scripts/qa-firstrun-mutations.mjs index b8b7c9f5e..3ef331abd 100644 --- a/scripts/qa-firstrun-mutations.mjs +++ b/scripts/qa-firstrun-mutations.mjs @@ -30,12 +30,16 @@ const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome. const FILES = { keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'), module: path.join(ROOT, 'src', 'firstRunExperience.js'), - html: path.join(ROOT, 'index.html'), - css: path.join(ROOT, 'style.css'), + html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'), + css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'), voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'), - voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'), - main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'), - ui: path.join(ROOT, 'src', 'ui.js'), + voiceDescriptions: path.join( + ROOT, + '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'), }; @@ -63,14 +67,14 @@ const MUTATIONS = [ { defect: 'the session flag is never read, so it re-nags on every reload', file: 'module', - from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;", - to: '// session check removed', + from: " if (\n readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===\n 'dismissed'\n )\n return false;", + to: ' // session check removed', }, { defect: 'the durable flag is never read, so the checkbox does nothing', file: 'module', - from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;", - to: '// durable check removed', + from: " if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')\n return false;", + to: ' // durable check removed', }, { 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', 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;", }, { @@ -131,8 +135,8 @@ const MUTATIONS = [ { defect: 'nothing watches for another surface taking the screen', file: 'module', - from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });", - to: '/* observer never attached */', + from: " surfaceObserver?.observe(documentRef.body, {\n attributes: true,\n attributeFilter: ['class'],\n });", + to: ' /* observer never attached */', }, { 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', file: 'module', - from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();', - to: ' && root.getClientRects().length > 0;', + from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();', + to: ' root.getClientRects().length > 0;', }, { 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 // stopImmediatePropagation() a few lines below and prove nothing about this. defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things', - file: 'ui', - from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn', - to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn', + file: 'radio', + from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n 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', @@ -234,7 +238,7 @@ const MUTATIONS = [ { defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump', 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({", }, { @@ -326,8 +330,8 @@ const MUTATIONS = [ { defect: 'the Context panel is left collapsed, hiding the mode that just started', file: 'module', - from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });", - to: '// panel reveal removed', + from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });", + to: ' // panel reveal removed', }, { 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 mid-flight', file: 'module', - from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));", - to: 'for (const button of buttons) button.disabled = next;', + from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));", + to: ' for (const button of buttons) button.disabled = next;', }, { 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)', 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;', - to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);', + 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:\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', @@ -403,8 +407,8 @@ const MUTATIONS = [ // those instead and prove nothing about this feature. defect: "the launcher's reduced-motion opt-out is dropped", 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; }', - to: ' .first-run-nothing { transition: none; }', + 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 {\n transition: none;\n }', }, // ── Markup and startup ordering ─────────────────────────────────────────── @@ -442,21 +446,21 @@ const MUTATIONS = [ defect: 'the launcher is revealed before the loading cover yields', file: 'main', 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', file: 'main', - from: 'initFirstRunExperience({ styleManager, dataManager });', - to: 'initFirstRunExperience({ styleManager });', + from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });', + to: 'firstRun = initializeWelcome?.({ styleManager });', }, // ── Voice: schema must not drift ────────────────────────────────────────── { defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)', - file: 'voiceTools', - from: " 'earthquakes',\n 'satellites',", - to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',", + file: 'voiceDescriptions', + from: "'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers", + 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', diff --git a/scripts/qa-floorhold-mutations.mjs b/scripts/qa-floorhold-mutations.mjs index 0e2ac9d62..b193ff169 100644 --- a/scripts/qa-floorhold-mutations.mjs +++ b/scripts/qa-floorhold-mutations.mjs @@ -26,7 +26,7 @@ const TESTS = [ ]; 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'; /** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */ @@ -133,8 +133,8 @@ const MUTATIONS = [ edits: [ { file: FLOOR, - from: ' if (lowest == null || h < lowest) lowest = h;', - to: ' if (lowest == null || h > lowest) lowest = h;', + from: ' if (lowest == null || h < lowest) lowest = h;', + to: ' if (lowest == null || h > lowest) lowest = h;', }, ], }, @@ -143,8 +143,8 @@ const MUTATIONS = [ edits: [ { file: FLOOR, - from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;', - to: ' return resolved >= 1 ? lowest : null;', + from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;', + to: ' return resolved >= 1 ? lowest : null;', }, ], }, diff --git a/scripts/qa-flyroute-mutations.mjs b/scripts/qa-flyroute-mutations.mjs index dbece6dba..d7d3d3325 100644 --- a/scripts/qa-flyroute-mutations.mjs +++ b/scripts/qa-flyroute-mutations.mjs @@ -35,17 +35,17 @@ const MUTATIONS = [ }, { 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);', }, { 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', }, { 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;', }, { @@ -55,12 +55,12 @@ const MUTATIONS = [ }, { 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) {', }, { 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) {', }, { @@ -90,8 +90,8 @@ const MUTATIONS = [ }, { 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;', - to: ' if (cold.length && 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: ' cold.length &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;', }, { 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', - 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,', }, { defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)', - from: ' cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });', - to: ' cam.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000), 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({\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', From 9501dc121bce1552c4ce50dfa4d448064165f97e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:33:01 -0500 Subject: [PATCH 08/28] test: preserve strict default timing with opt-in slow CI gate --- .../recent-imagery-gate-repair-proof.yml | 2 ++ scripts/qa-recent-imagery.mjs | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml index b613d9561..d93925e8c 100644 --- a/.github/workflows/recent-imagery-gate-repair-proof.yml +++ b/.github/workflows/recent-imagery-gate-repair-proof.yml @@ -31,6 +31,8 @@ jobs: cat /tmp/gev-dev.log exit 1 - name: Recent Imagery live gate + env: + QA_RECENT_IMAGERY_SLOW_RENDER: '1' run: node scripts/qa-recent-imagery.mjs - name: Dev log on failure if: failure() diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index f9601432f..f81209b9f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -12,8 +12,10 @@ * Run: start the app (`./scripts/dev-fresh.sh`) and * node scripts/qa-recent-imagery.mjs * Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default - * qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without - * network access to NASA the run skips with exit code 0. + * qa-shots/recent-imagery), QA_HEADFUL=1. + * 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 path from 'node:path'; @@ -22,6 +24,9 @@ import puppeteer from 'puppeteer'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery'; 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 AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 }; const PANEL = '#recent-imagery-panel'; @@ -1048,13 +1053,13 @@ try { ) <= target * 0.01, fitHeight, - 4_000, + ZOOM_TIMEOUT_MS, ); const zoomedHeight = await page.evaluate( () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN lands at ${Math.round(fitHeight / 1000)} km`, + `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within ${ZOOM_TIMEOUT_MS / 1_000} s`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1264,7 +1269,7 @@ try { ); 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 { settled: tail('imagery').size === 1 && tail('cctv').size === 1, detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, From 9d59e555c2b1bd2053296561f9dc01ba64ed551b Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:47 -0500 Subject: [PATCH 09/28] style: format slow-CI timing assertions --- scripts/qa-recent-imagery.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index f81209b9f..9c1d6700e 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -1059,7 +1059,7 @@ try { () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within ${ZOOM_TIMEOUT_MS / 1_000} s`, + `ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1269,7 +1269,8 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key])); + const tail = (key) => + new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key])); return { settled: tail('imagery').size === 1 && tail('cctv').size === 1, detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, From 2357a1140907720f6bfc7037a992196e83057dfb Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:36:02 -0500 Subject: [PATCH 10/28] ci: remove fork-only Recent Imagery proof workflow per review --- .../recent-imagery-gate-repair-proof.yml | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/recent-imagery-gate-repair-proof.yml diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml deleted file mode 100644 index d93925e8c..000000000 --- a/.github/workflows/recent-imagery-gate-repair-proof.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Recent Imagery gate repair proof - -on: - push: - branches: - - fix/recent-imagery-qa-relative-layout-20261003 - -permissions: - contents: read - -jobs: - browser-proof: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - - name: Start GEV - run: | - npm run dev -- --host 127.0.0.1 --port 4173 > /tmp/gev-dev.log 2>&1 & - for i in {1..90}; do - if curl -fsS http://127.0.0.1:4173/ >/dev/null; then - exit 0 - fi - sleep 1 - done - cat /tmp/gev-dev.log - exit 1 - - name: Recent Imagery live gate - env: - QA_RECENT_IMAGERY_SLOW_RENDER: '1' - run: node scripts/qa-recent-imagery.mjs - - name: Dev log on failure - if: failure() - run: cat /tmp/gev-dev.log From 632005d221f2a5c60045769208c4b442d69cf3e5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:33:27 -0400 Subject: [PATCH 11/28] fix(director): seek camera longitude across the shortest dateline arc --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 ++++ src/director/director-dateline-seek.test.mjs | 26 ++++++++++++++++++++ src/director/timeline.js | 8 +++++- 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/director/director-dateline-seek.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..2075effa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Director timeline seeks cross the dateline along the short longitude arc. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..cb0b43c54 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(director): seek camera longitude across the shortest dateline arc + +Interpolate longitude with the existing shortest-angle easing, normalize intermediate longitudes, and preserve exact authored endpoints at progress zero and one. diff --git a/src/director/director-dateline-seek.test.mjs b/src/director/director-dateline-seek.test.mjs new file mode 100644 index 000000000..f950814f3 --- /dev/null +++ b/src/director/director-dateline-seek.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cameraAtProgress } from './timeline.js'; +const camera = (lon) => ({ + lat: 10, + lon, + alt: 1000, + heading: 0, + pitch: -40, + roll: 0, +}); +test('director seek follows the short dateline arc in both directions', () => { + for (const [a, b] of [ + [179, -179], + [-179, 179], + ]) { + assert.equal( + Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon), + 180, + ); + assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a); + assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b); + assert.ok(Math.abs(cameraAtProgress(camera(a), camera(b), 0.25).lon) > 179); + } + assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22); +}); diff --git a/src/director/timeline.js b/src/director/timeline.js index 7fd4cbbae..1da08d498 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -45,7 +45,13 @@ export function cameraAtProgress(fromCamera, toCamera, progress) { }; return { lat: lerp(source.lat, target.lat), - lon: lerp(source.lon, target.lon), + lon: + t === 0 + ? Number(source.lon) + : t === 1 + ? Number(target.lon) + : ((((lerpAngle(source.lon, target.lon) + 180) % 360) + 360) % 360) - + 180, alt: lerp(source.alt, target.alt), heading: lerpAngle(source.heading, target.heading), pitch: lerp(source.pitch, target.pitch), From 3b08bd74872a4a7c2dac103139a05ce660fd71f0 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:42 -0400 Subject: [PATCH 12/28] fix: address review feedback for PR #1023 --- docs/CURRENT-STATE.md | 6 +----- src/director/camera.js | 7 ++++++- src/director/timeline.js | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cb0b43c54..37a9e6c0a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -399,6 +399,7 @@ camera moves with easing, duration and holds. Playback and seek share one coordinate sampler; navigation/manual input revokes authored motion and pending holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid reference. Legacy projects retain their ordinary flights, content and edits. +Seeking between ordinary camera poses uses the shortest longitude arc across the dateline while preserving both authored endpoint longitudes. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input @@ -5310,8 +5311,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(director): seek camera longitude across the shortest dateline arc - -Interpolate longitude with the existing shortest-angle easing, normalize intermediate longitudes, and preserve exact authored endpoints at progress zero and one. diff --git a/src/director/camera.js b/src/director/camera.js index 059c7a873..e009c2cba 100644 --- a/src/director/camera.js +++ b/src/director/camera.js @@ -28,6 +28,11 @@ export function resolveCameraMove(scene, shot) { }; } +/** Wrap an interpolated camera longitude to WGS84 without changing authored endpoints. */ +export function normalizeCameraLongitude(longitude) { + return ((((longitude + 180) % 360) + 360) % 360) - 180; +} + /** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */ export function sampleCameraMove(move, progress) { const t = Math.max(0, Math.min(1, Number(progress) || 0)); @@ -45,7 +50,7 @@ export function sampleCameraMove(move, progress) { const { from, to } = move; return { lat: lerp(from.lat, to.lat), - lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180, + lon: normalizeCameraLongitude(angle(from.lon, to.lon)), alt: lerp(from.alt, to.alt), heading: angle(from.heading, to.heading), pitch: lerp(from.pitch, to.pitch), diff --git a/src/director/timeline.js b/src/director/timeline.js index 1da08d498..fbaca4301 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -2,6 +2,7 @@ import { resolveCameraPose, resolveCameraMove, sampleCameraMove, + normalizeCameraLongitude, } from './camera.js'; const DEFAULT_SHOT_DURATION_SEC = 4; @@ -50,8 +51,7 @@ export function cameraAtProgress(fromCamera, toCamera, progress) { ? Number(source.lon) : t === 1 ? Number(target.lon) - : ((((lerpAngle(source.lon, target.lon) + 180) % 360) + 360) % 360) - - 180, + : normalizeCameraLongitude(lerpAngle(source.lon, target.lon)), alt: lerp(source.alt, target.alt), heading: lerpAngle(source.heading, target.heading), pitch: lerp(source.pitch, target.pitch), From 2c5fb039ce1da3b0ce7ba9e177499b16c14c4656 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:33:43 -0400 Subject: [PATCH 13/28] fix(tools): enforce string schema lengths by Unicode code point --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ src/tools/schema.js | 5 +++-- src/tools/tool-schema-codepoints.test.mjs | 20 ++++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/tools/tool-schema-codepoints.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..f7607fd02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..7e1970768 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(tools): enforce string schema lengths by Unicode code point + +Count code points for minLength and maxLength, preserving ASCII behavior and regular-expression validation. diff --git a/src/tools/schema.js b/src/tools/schema.js index 7c40c3eb5..cdb63cfc1 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,9 +106,10 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - if (schema.minLength != null && value.length < schema.minLength) + const length = [...value].length; + if (schema.minLength != null && length < schema.minLength) fail(`must be at least ${schema.minLength} characters`); - if (schema.maxLength != null && value.length > schema.maxLength) + if (schema.maxLength != null && length > schema.maxLength) fail(`must be at most ${schema.maxLength} characters`); if ( schema.pattern !== undefined && diff --git a/src/tools/tool-schema-codepoints.test.mjs b/src/tools/tool-schema-codepoints.test.mjs new file mode 100644 index 000000000..c379b77e9 --- /dev/null +++ b/src/tools/tool-schema-codepoints.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateValue } from './schema.js'; +test('tool string schemas count Unicode characters rather than UTF-16 units', () => { + const value = '𠮷野家😀'; + assert.deepEqual(validateValue({ type: 'string', maxLength: 4 }, value), []); + assert.equal( + validateValue({ type: 'string', minLength: 5 }, value).length, + 1, + ); + assert.equal( + validateValue({ type: 'string', maxLength: 3 }, value).length, + 1, + ); + assert.deepEqual( + validateValue({ type: 'string', minLength: 4, maxLength: 4 }, 'abcd'), + [], + ); + assert.equal(validateValue({ type: 'string', minLength: 2 }, '𠮷').length, 1); +}); From 615ba9a00826a50c2907184b9193c6e6d51bdc0b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:53 -0400 Subject: [PATCH 14/28] fix: address review feedback for PR #1026 --- docs/CURRENT-STATE.md | 7 ++----- src/tools/schema.js | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7e1970768..e66800fd5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -73,6 +73,8 @@ Windows/Claude Desktop Cowork confirmation remains outstanding (#927). ## Tools and local MCP server — October 1, 2026 +Tool argument string lengths follow JSON Schema Unicode code-point counts, so supplementary characters count once rather than as two UTF-16 units. + `gods-eye-view/tools` defines queries that answer questions from the app's data, and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, @@ -5310,8 +5312,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(tools): enforce string schema lengths by Unicode code point - -Count code points for minLength and maxLength, preserving ASCII behavior and regular-expression validation. diff --git a/src/tools/schema.js b/src/tools/schema.js index cdb63cfc1..b6e8e2fd0 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,7 +106,8 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - const length = [...value].length; + let length = 0; + for (const character of value) length += 1; if (schema.minLength != null && length < schema.minLength) fail(`must be at least ${schema.minLength} characters`); if (schema.maxLength != null && length > schema.maxLength) From c86c1a4ccedb0468bc622c00ee151c1224bab71a Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:35:40 -0400 Subject: [PATCH 15/28] fix(weather): keep absent observations unknown instead of reporting clear skies --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 ++++ src/data/regionalModel.js | 23 +++++++++++++++++-- .../weather-missing-observations.test.mjs | 20 ++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/data/weather-missing-observations.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..d3a8f9027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Weather records keep missing conditions unknown and refuse summaries without a measured temperature. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..c066ab182 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(weather): keep absent observations unknown instead of reporting clear skies + +Refuse absent or boolean primary temperatures and keep absent/boolean weather codes explicitly CONDITIONS UNKNOWN while retaining valid numeric strings and real zero observations. diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 08b4e9336..90eeac1fd 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -90,9 +90,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { /** Normalize Open-Meteo current conditions into a small source-stamped record. */ export function normalizeRegionalWeather(payload) { const current = payload?.current; - if (!current || !Number.isFinite(Number(current.temperature_2m))) return null; + if ( + !current || + current.temperature_2m == null || + typeof current.temperature_2m === 'boolean' || + (typeof current.temperature_2m === 'string' && + !current.temperature_2m.trim()) || + !Number.isFinite(Number(current.temperature_2m)) + ) + return null; const numberOrNull = (value) => { - if (value === null || value === undefined || value === '') return null; + if ( + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()) + ) + return null; return Number.isFinite(Number(value)) ? Number(value) : null; }; // Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC, @@ -119,6 +132,12 @@ export function normalizeRegionalWeather(payload) { /** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */ export function weatherCodeLabel(code) { + if ( + code == null || + typeof code === 'boolean' || + (typeof code === 'string' && !code.trim()) + ) + return 'CONDITIONS UNKNOWN'; const value = Number(code); if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN'; if (value === 0) return 'CLEAR'; diff --git a/src/data/weather-missing-observations.test.mjs b/src/data/weather-missing-observations.test.mjs new file mode 100644 index 000000000..4e89dcedd --- /dev/null +++ b/src/data/weather-missing-observations.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeRegionalWeather, weatherCodeLabel } from './regionalModel.js'; +test('missing weather observations never assert clear conditions', () => { + for (const value of [null, undefined, '', ' ', false, true]) { + assert.equal(weatherCodeLabel(value), 'CONDITIONS UNKNOWN'); + assert.equal( + normalizeRegionalWeather({ current: { temperature_2m: value } }), + null, + ); + } + assert.equal(weatherCodeLabel(0), 'CLEAR'); + assert.equal(weatherCodeLabel('0'), 'CLEAR'); + const record = normalizeRegionalWeather({ + current: { temperature_2m: 0, weather_code: null, wind_speed_10m: false }, + }); + assert.equal(record.temperatureC, 0); + assert.equal(record.weatherCode, null); + assert.equal(record.windKph, null); +}); From 7126555acb42d8a2c9bb7e60a14fc569a628bccf Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:54 -0400 Subject: [PATCH 16/28] fix: address review feedback for PR #1027 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c066ab182..076603256 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4625,6 +4625,8 @@ are omitted rather than framing the wrong part of the globe. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. +Missing local weather observations remain unknown rather than being converted to zero or clear skies; a missing temperature drops the whole local weather record, including its wind and condition. + - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. - **Aircraft course slew:** civilian and military 3D models retain the 60°/s course limiter, but each rendered frame can consume at most 250 ms of accumulated slew time. A long tile/render stall therefore catches up over multiple visible frames instead of turning one delayed frame into a heading snap. @@ -5310,8 +5312,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(weather): keep absent observations unknown instead of reporting clear skies - -Refuse absent or boolean primary temperatures and keep absent/boolean weather codes explicitly CONDITIONS UNKNOWN while retaining valid numeric strings and real zero observations. From 750f3da6eba3b115399ab50d8fadb9144fb7a846 Mon Sep 17 00:00:00 2001 From: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:48:51 +0900 Subject: [PATCH 17/28] fix(styles): keep Snow wind sway bounded over time (#782) The horizontal offset multiplied elapsed time by a sine, so flakes swung faster the longer the app ran. It is now the integral of the intended sway velocity, which stays bounded at any session age; Wind still scales the amplitude. Co-authored-by: Sameh Khamis --- CHANGELOG.md | 3 +++ docs/CURRENT-STATE.md | 2 ++ src/styles/snow.js | 6 ++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a7f6823..1ef811a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9281f4a40..ebf7495f5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1013,6 +1013,8 @@ and sharpen. Display supplies actions and renders settings; effect execution has no DOM dependency. Existing presets and the 500 ms transition remain unchanged. Stopping the controller revokes animation before asynchronous UI teardown; final 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 diff --git a/src/styles/snow.js b/src/styles/snow.js index e2ada6ad7..b23f9bd71 100644 --- a/src/styles/snow.js +++ b/src/styles/snow.js @@ -29,11 +29,13 @@ export const snowShader = { float depth = 0.5 + layer * 0.5; float speed = 0.4 + layer * 0.3; 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); 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 f = fract(snowUV); From 85754bcf50c7b52fe3c753d34c653b4ae6826960 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 11:59:14 -0700 Subject: [PATCH 18/28] test: say which CI job and which files the discovery guard covers --- scripts/run-unit-tests.mjs | 9 +++++---- src/unitTestRunner.test.mjs | 11 ++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 7024ea38c..6cc6aaa94 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -25,10 +25,11 @@ export function assertNode24AllocationRuntime(version = process.versions.node) { * 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 `npm test` is the only - * thing CI invokes, so a directory missing from this list is a suite that never - * runs anywhere. `unitTestRunner.test.mjs` asserts the list covers every test - * file in the repository, so adding a tree without adding it here fails. + * 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']); diff --git a/src/unitTestRunner.test.mjs b/src/unitTestRunner.test.mjs index 201f6ed8e..80d9091e0 100644 --- a/src/unitTestRunner.test.mjs +++ b/src/unitTestRunner.test.mjs @@ -64,11 +64,12 @@ test('unit runner serializes only GC-bracketed allocation microbenchmarks', () = }); test('the runner discovers every test file in the repository', () => { - // `npm test` is the only suite CI runs, and it runs exactly what this - // function returns. A `*.test.mjs` outside the searched roots therefore never - // executes anywhere, 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. + // 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)); From afb3a92914b4d1bc19fa62d708456a331e0db274 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 11:59:51 -0700 Subject: [PATCH 19/28] docs: say what the connection reads from the voice credential It reads the token, model, transcription model and tier for diagnostics and cost accounting, so the credential is not opaque to it. Also lists requestToken's authMode option in APPLICATION.md. --- docs/APPLICATION.md | 2 +- docs/VOICE-OWNERSHIP.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index deafe9168..c5a2642e6 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -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 `createVoiceCommands` (`./voice/commands`). The default Realtime controller owns 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 (`./voice/realtime-backend`) accepts separate token and connection transports and endpoints. Only the short-lived client secret reaches the SDP endpoint. diff --git a/docs/VOICE-OWNERSHIP.md b/docs/VOICE-OWNERSHIP.md index 98b6b0c5c..bb28eb3e9 100644 --- a/docs/VOICE-OWNERSHIP.md +++ b/docs/VOICE-OWNERSHIP.md @@ -26,8 +26,8 @@ The backend is replaceable per deployment: `realtimeController.js` accepts it as `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 resolved credential is opaque to the connection and is handed to `negotiate` -whole. Client-secret expiry is refused inside the backend, not by the connection. +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. From 4cd7200fe445200968109b2a65cb6a98a485deb3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 12:00:16 -0700 Subject: [PATCH 20/28] docs: attribute the Pinokio stutter report accurately The workaround came from one Pinokio 8.2.0 user who did not state an OS, and a single report suggests rather than isolates the embedded-browser cause. --- README.md | 10 +++++----- docs/PERFORMANCE.md | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6c6b1c238..50c5a4003 100644 --- a/README.md +++ b/README.md @@ -119,11 +119,11 @@ Version 8.2 fixes the launcher installation issue; **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. A -Pinokio 8.2.0 Linux report reproduced severe stutter in Pinokio's embedded view -while the same running server was smooth in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). -That isolates the browser/compositor path from the app server and your machine; -it does not establish a minimum GPU requirement. +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 diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 873ef8768..65d0abae9 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -129,11 +129,11 @@ Use the same controls before attributing a difference to the application: 7. Treat a live-source outage as missing coverage, not as evidence of low client rendering cost. 8. Record whether the app is running in Pinokio's embedded view or in an - external browser. The same Pinokio 8.2.0 Linux server was reported as - severely stuttery in the embedded view and smooth when its local URL was - opened directly in a normal browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). - Treat that as a browser/compositor-path difference until the GPU renderer - proves otherwise. + 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 From 91ac5c119084fdb200362b2c6dd07cb2324fb081 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:23:49 -0700 Subject: [PATCH 21/28] test(codex): match the installed-executable stub on Windows (#997) The test's access stub matched a forward-slash suffix, so on Windows the resolver's backslash path never matched and it fell back to PATH. The stub and the expected value now use the same path.join result. The Windows CI job also runs this test file now. Co-authored-by: Sameh Khamis --- .github/workflows/ci.yml | 2 +- src/codexOauthRealtime.test.mjs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 375bc9077..491f2468d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,7 +150,7 @@ jobs: run: npm run check:boundaries - 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 run: npm run build diff --git a/src/codexOauthRealtime.test.mjs b/src/codexOauthRealtime.test.mjs index 07e1a7dcc..4a577ac70 100644 --- a/src/codexOauthRealtime.test.mjs +++ b/src/codexOauthRealtime.test.mjs @@ -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 () => { 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( resolveCodexExecutable({ environment: {}, home: '/home/fixture', access(candidate) { checked.push(candidate); - if (candidate.endsWith('/.local/bin/codex')) return; + if (candidate === installed) return; throw new Error('missing'); }, }), - path.join('/home/fixture', '.local', 'bin', 'codex'), + installed, ); assert.equal(checked.length, 1); From 8daaa065d2b410380e18a5702112d7cad7e2e900 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 14:16:30 -0700 Subject: [PATCH 22/28] qa: log the Recent Imagery gate's stability mode and document its flag --- docs/CURRENT-STATE.md | 2 +- scripts/qa-recent-imagery.mjs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ebf7495f5..91ba81591 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4451,7 +4451,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. - **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). -- 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) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 9c1d6700e..5d42279dc 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -383,6 +383,9 @@ try { const initialStack = await page.evaluate(() => window.__riQa.activeStack()); const photoreal = initialStack === 'photoreal'; 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) => photoreal ? check(name, passed, detail) From 4cacd4cc0021858c009d38a977095fac73a49136 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:48:53 -0700 Subject: [PATCH 23/28] fix(qa): exit QA gates gracefully on Windows (#1064) process.exit(2) after a successful fetch trips a libuv assertion on Windows and turns the exit code into 127. The four gates set process.exitCode and return instead, and discard the unread reachability response so an early exit does not wait for the server to finish the body. Co-authored-by: Sameh Khamis --- scripts/qa-firms.mjs | 8 ++++++-- scripts/qa-traffic-jamviz-ab.mjs | 8 ++++++-- scripts/qa-traffic-preset-ab.mjs | 8 ++++++-- scripts/qa-traffic.mjs | 8 ++++++-- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs index d32ede533..27c8663bf 100644 --- a/scripts/qa-firms.mjs +++ b/scripts/qa-firms.mjs @@ -230,16 +230,20 @@ async function main() { try { 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}`); } catch (e) { 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); if (!FIXTURES && !status?.hasKey) { 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 }); diff --git a/scripts/qa-traffic-jamviz-ab.mjs b/scripts/qa-traffic-jamviz-ab.mjs index 73d28ca60..b236ab0b1 100644 --- a/scripts/qa-traffic-jamviz-ab.mjs +++ b/scripts/qa-traffic-jamviz-ab.mjs @@ -139,15 +139,19 @@ async function main() { try { 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}`); } catch (e) { 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); if (!status?.hasKey) { 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 }); diff --git a/scripts/qa-traffic-preset-ab.mjs b/scripts/qa-traffic-preset-ab.mjs index 44319af0d..d1b4950fd 100644 --- a/scripts/qa-traffic-preset-ab.mjs +++ b/scripts/qa-traffic-preset-ab.mjs @@ -145,15 +145,19 @@ async function main() { try { 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}`); } catch (e) { 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); if (!status?.hasKey) { 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 }); diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 2a04d5595..424f3486e 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -113,17 +113,21 @@ async function main() { try { 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}`); } catch (e) { 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); if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.'); if (!statusBefore?.hasKey) { 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 }); From 504d56ba2b3d73dcad45c14cd0715764c8cb2955 Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 14 Oct 2026 17:58:53 -0400 Subject: [PATCH 24/28] test: fix flaky timing and timeout guards (#969) Clears the timeout guards once each race settles, measures the 15,000-vehicle transit ingestion with main-thread CPU time instead of wall time, and times the region-timeout case from the boundary lookup with a 2 s ceiling, below the 3 s default budget, so an ignored budget still fails. Co-authored-by: Sameh Khamis --- server/providers/transitHistory.test.mjs | 16 +++++++++------- src/annotations/regionRing.test.mjs | 7 +++++-- src/data/manager.test.mjs | 14 ++++++++++---- src/sdr/controller.test.mjs | 9 +++++---- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/server/providers/transitHistory.test.mjs b/server/providers/transitHistory.test.mjs index 6ea9209f1..cb06c182a 100644 --- a/server/providers/transitHistory.test.mjs +++ b/server/providers/transitHistory.test.mjs @@ -142,9 +142,11 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde const rows = Array.from({ length: 15000 }, (_, 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); - times.push(performance.now() - start); + const usage = process.threadCpuUsage(start); + times.push((usage.user + usage.system) / 1000); } assert.equal(h.diagnostics().keys, 15000); 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); 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 - // well under two seconds on a loaded laptop or a slow CI runner. The - // per-run figure above is the diagnostic to read. - assert.ok(Math.max(...times) < 2000, `ingestion ms: ${times}`); + // CPU time measures ingestion work without counting scheduler delays on + // loaded runners. Keep the two-second ceiling as a guard against quadratic + // work, not a benchmark; the per-poll figures above show any slowdown. + assert.ok(Math.max(...times) < 2000, `ingestion CPU ms: ${times}`); }); test('snapshot capacity pressure preserves members not yet visited and discloses recreated history', (t) => { diff --git a/src/annotations/regionRing.test.mjs b/src/annotations/regionRing.test.mjs index 09814247b..cd8ad6447 100644 --- a/src/annotations/regionRing.test.mjs +++ b/src/annotations/regionRing.test.mjs @@ -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 () => { let adminSignal; let release; + let lookupStarted; const { resolveRegionRingForQuery } = createAnnotationResolver({ featureSource: featureSource({ getAdministrativeAreas(_point, { signal }) { + lookupStarted = Date.now(); adminSignal = signal; return new Promise((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( 'Atlantis', undefined, @@ -73,8 +74,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th ring: null, 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( - Date.now() - started < 1000, + Date.now() - lookupStarted < 2_000, 'returns at the budget, not the lookup', ); // The lookup is left running so it can fill the boundary cache. diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index fce64ae8f..23641c962 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -2398,10 +2398,13 @@ test('a newer absolute intent aborts a hung guard-compensation instead of starvi removeGuard(); // The newest intent must be able to abort the hung compensation and run. const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' }); + let giveUpTimer; const finalOn = await Promise.race([ 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.equal(compensationAborted, true, 'the hung compensation was aborted'); 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(); const blockedOn = await pendingBlockedOn; + let giveUpTimer; const reentrant = await Promise.race([ (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 mgr.waitForLayerSettled('flights'); unsubscribe(); diff --git a/src/sdr/controller.test.mjs b/src/sdr/controller.test.mjs index 95b1eb5f2..f8078dbe5 100644 --- a/src/sdr/controller.test.mjs +++ b/src/sdr/controller.test.mjs @@ -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`. */ function within(promise, ms = 1_000) { - return Promise.race([ - promise, - new Promise((resolve) => setTimeout(() => resolve('timed out'), ms)), - ]); + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve('timed out'), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } /** From cbefd529f9ca08d55e874240142d495cdf1d342f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 15:05:05 -0700 Subject: [PATCH 25/28] fix(director): turn heading and roll the short way for any authored angle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single % made the seek delta negative when the target angle was more than 540° below the start, so 350° to -350° turned 340° through 180°. Uses the same double modulo as playback, and adds tests for exact ±180° endpoints and a near-antipodal seek. --- docs/CURRENT-STATE.md | 4 ++- src/director/director-dateline-seek.test.mjs | 27 ++++++++++++++++++++ src/director/timeline.js | 4 ++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fa48b7bfe..3a7f7b94f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -399,7 +399,9 @@ camera moves with easing, duration and holds. Playback and seek share one coordinate sampler; navigation/manual input revokes authored motion and pending holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid reference. Legacy projects retain their ordinary flights, content and edits. -Seeking between ordinary camera poses uses the shortest longitude arc across the dateline while preserving both authored endpoint longitudes. +Seeking between ordinary camera poses uses the shortest longitude arc across the +dateline while preserving both authored endpoint longitudes, and turns heading +and roll the short way for any authored angles. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input diff --git a/src/director/director-dateline-seek.test.mjs b/src/director/director-dateline-seek.test.mjs index f950814f3..b0dd2b24b 100644 --- a/src/director/director-dateline-seek.test.mjs +++ b/src/director/director-dateline-seek.test.mjs @@ -24,3 +24,30 @@ test('director seek follows the short dateline arc in both directions', () => { } assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22); }); + +test('director seek keeps exact ±180 endpoints and picks the short arc near the antipode', () => { + for (const [a, b] of [ + [180, -180], + [-180, 180], + ]) { + assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a); + assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b); + assert.equal( + Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon), + 180, + ); + } + // 10°E to 169.9°W is 179.9° westward through Greenwich, not 180.1° east. + const mid = cameraAtProgress(camera(10), camera(-169.9), 0.5).lon; + assert.ok(mid > -81 && mid < -79, `short arc midpoint, got ${mid}`); +}); + +test('director seek turns heading and roll the short way for any authored angles', () => { + const pose = (heading, roll) => ({ ...camera(0), heading, roll }); + const mid = cameraAtProgress(pose(350, 350), pose(-350, -350), 0.5); + // 350° and -350° are 20° apart through 0°/360°, not 340° through 180°. + for (const value of [mid.heading, mid.roll]) { + const wrapped = ((value % 360) + 360) % 360; + assert.ok(wrapped < 1e-9 || 360 - wrapped < 1e-9, `got ${value}`); + } +}); diff --git a/src/director/timeline.js b/src/director/timeline.js index fbaca4301..f7cffcece 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -41,7 +41,9 @@ export function cameraAtProgress(fromCamera, toCamera, progress) { const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased; const lerpAngle = (from, to) => { const start = Number(from) || 0; - const delta = ((Number(to) - start + 540) % 360) - 180; + // Double modulo keeps the delta in [-180, 180) for any authored angles, + // as sampleCameraMove does; a single % goes negative below -540. + const delta = ((((Number(to) - start + 540) % 360) + 360) % 360) - 180; return start + delta * eased; }; return { From c12938cd05b843ca00e8bf0ac4be5d5c5770acaa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 15:05:40 -0700 Subject: [PATCH 26/28] perf(tools): stop counting string code points once the bounds are decided Strings without length bounds are not counted, and counting stops at the larger bound, so a huge tool argument costs no more than its limit. --- src/tools/schema.js | 20 ++++++++++++++------ src/tools/tool-schema-codepoints.test.mjs | 13 +++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/tools/schema.js b/src/tools/schema.js index b6e8e2fd0..f857825a1 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,12 +106,20 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - let length = 0; - for (const character of value) length += 1; - if (schema.minLength != null && length < schema.minLength) - fail(`must be at least ${schema.minLength} characters`); - if (schema.maxLength != null && length > schema.maxLength) - fail(`must be at most ${schema.maxLength} characters`); + if (schema.minLength != null || schema.maxLength != null) { + // Code points, as JSON Schema counts. Counting stops once both bounds + // are decided, so a huge argument costs no more than its limit. + const limit = Math.max( + schema.minLength ?? 0, + schema.maxLength != null ? schema.maxLength + 1 : 0, + ); + let length = 0; + for (const _ of value) if (++length >= limit) break; + if (schema.minLength != null && length < schema.minLength) + fail(`must be at least ${schema.minLength} characters`); + if (schema.maxLength != null && length > schema.maxLength) + fail(`must be at most ${schema.maxLength} characters`); + } if ( schema.pattern !== undefined && !new RegExp(schema.pattern, 'u').test(value) diff --git a/src/tools/tool-schema-codepoints.test.mjs b/src/tools/tool-schema-codepoints.test.mjs index c379b77e9..27af78fa3 100644 --- a/src/tools/tool-schema-codepoints.test.mjs +++ b/src/tools/tool-schema-codepoints.test.mjs @@ -18,3 +18,16 @@ test('tool string schemas count Unicode characters rather than UTF-16 units', () ); assert.equal(validateValue({ type: 'string', minLength: 2 }, '𠮷').length, 1); }); + +test('tool string bounds hold at their edges and on huge arguments', () => { + const string = (bounds) => ({ type: 'string', ...bounds }); + assert.deepEqual(validateValue(string({ maxLength: 3 }), '😀😀😀'), []); + assert.equal(validateValue(string({ maxLength: 3 }), '😀😀😀😀').length, 1); + assert.deepEqual(validateValue(string({ minLength: 3 }), '😀😀😀'), []); + assert.deepEqual(validateValue(string({}), 'x'.repeat(1e6)), []); + // Counting all of it takes roughly 0.7 s; stopping at the limit takes none. + const huge = 'x'.repeat(1e8); + const started = performance.now(); + assert.equal(validateValue(string({ maxLength: 60 }), huge).length, 1); + assert.ok(performance.now() - started < 200, 'stops counting past the limit'); +}); From 5be2185dc79353cf76969a13c4372b20b15428d2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 15:06:52 -0700 Subject: [PATCH 27/28] fix(weather): treat a missing code as unavailable in cloud effects too Number(null) is 0, so the cockpit cloud profile treated a missing weather code as clear but available. The briefing's three checks now share one helper, and the current-state note is its own bullet instead of folding into the route one. --- docs/CURRENT-STATE.md | 3 +-- src/data/regionalModel.js | 41 ++++++++++++--------------------- src/weatherEffectsMath.js | 8 ++++++- src/weatherEffectsMath.test.mjs | 9 ++++++++ 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f62c77bab..0ec9548ae 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4632,8 +4632,7 @@ are omitted rather than framing the wrong part of the globe. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. -Missing local weather observations remain unknown rather than being converted to zero or clear skies; a missing temperature drops the whole local weather record, including its wind and condition. - +- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. - **Aircraft course slew:** civilian and military 3D models retain the 60°/s course limiter, but each rendered frame can consume at most 250 ms of accumulated slew time. A long tile/render stall therefore catches up over multiple visible frames instead of turning one delayed frame into a heading snap. diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 90eeac1fd..3a6d79f14 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -87,27 +87,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { return articles; } +/** A reported number, or null when the observation is missing or not numeric. */ +function observedNumber(value) { + if ( + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()) + ) + return null; + return Number.isFinite(Number(value)) ? Number(value) : null; +} + /** Normalize Open-Meteo current conditions into a small source-stamped record. */ export function normalizeRegionalWeather(payload) { const current = payload?.current; - if ( - !current || - current.temperature_2m == null || - typeof current.temperature_2m === 'boolean' || - (typeof current.temperature_2m === 'string' && - !current.temperature_2m.trim()) || - !Number.isFinite(Number(current.temperature_2m)) - ) - return null; - const numberOrNull = (value) => { - if ( - value == null || - typeof value === 'boolean' || - (typeof value === 'string' && !value.trim()) - ) - return null; - return Number.isFinite(Number(value)) ? Number(value) : null; - }; + if (!current || observedNumber(current.temperature_2m) == null) return null; + const numberOrNull = observedNumber; // Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC, // but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly. const observedRaw = @@ -132,14 +127,8 @@ export function normalizeRegionalWeather(payload) { /** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */ export function weatherCodeLabel(code) { - if ( - code == null || - typeof code === 'boolean' || - (typeof code === 'string' && !code.trim()) - ) - return 'CONDITIONS UNKNOWN'; - const value = Number(code); - if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN'; + const value = observedNumber(code); + if (value == null) return 'CONDITIONS UNKNOWN'; if (value === 0) return 'CLEAR'; if ([1, 2].includes(value)) return 'PARTLY CLOUDY'; if (value === 3) return 'OVERCAST'; diff --git a/src/weatherEffectsMath.js b/src/weatherEffectsMath.js index 0c1276bab..ec06741aa 100644 --- a/src/weatherEffectsMath.js +++ b/src/weatherEffectsMath.js @@ -13,7 +13,13 @@ function range01(value, min, max) { * Missing weather fails clear instead of inventing conditions. */ export function deriveWeatherEffectProfile(weather) { - if (!weather || !Number.isFinite(Number(weather.weatherCode))) { + const reported = weather?.weatherCode; + // Number(null) and Number('') are 0 (clear), so a missing code is checked first. + if ( + reported == null || + reported === '' || + !Number.isFinite(Number(reported)) + ) { return { available: false, cloud: 0, diff --git a/src/weatherEffectsMath.test.mjs b/src/weatherEffectsMath.test.mjs index 6ca4387bd..bed3d1cd4 100644 --- a/src/weatherEffectsMath.test.mjs +++ b/src/weatherEffectsMath.test.mjs @@ -10,6 +10,15 @@ test('missing weather fails clear instead of inventing effects', () => { assert.equal(profile.storm, 0); }); +test('a missing weather code is unavailable, not clear', () => { + for (const weatherCode of [null, undefined, '']) { + const profile = deriveWeatherEffectProfile({ weatherCode, cloudCoverPct: 90 }); + assert.equal(profile.available, false, `weatherCode ${weatherCode}`); + assert.equal(profile.cloud, 0); + } + assert.equal(deriveWeatherEffectProfile({ weatherCode: 0 }).available, true); +}); + test('clear weather follows observed cloud cover without precipitation', () => { const profile = deriveWeatherEffectProfile({ weatherCode: 1, From e50bc45674c4e7f940dce07f3c67b7c1ed83fb67 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:36:35 -0700 Subject: [PATCH 28/28] fix(qa): run the macOS-only gates on Windows and Linux (#1061) Seven QA scripts hard-coded macOS Google Chrome. They now share chromeExecutablePath(): PUPPETEER_EXECUTABLE_PATH when set, then macOS Chrome when installed, otherwise Puppeteer's managed browser. The cables screenshot path is built with fileURLToPath so it works on Windows. qa-overlay-baseline keeps its fixed Chrome because its baselines record the executable. Co-authored-by: Sameh Khamis --- scripts/format-scope.json | 2 ++ scripts/precompute-cctv-heights.mjs | 4 ++-- scripts/qa-cables-shot.mjs | 5 ++++- scripts/qa-chrome.mjs | 20 ++++++++++++++++++++ scripts/qa-directions.mjs | 4 ++-- scripts/qa-draw-tool.mjs | 4 ++-- scripts/qa-recent-imagery.mjs | 4 ++-- scripts/qa-transit-heading.mjs | 4 ++-- scripts/qa-transit-recovery.mjs | 4 ++-- scripts/qa-transit.mjs | 4 ++-- src/qaChrome.test.mjs | 27 +++++++++++++++++++++++++++ 11 files changed, 67 insertions(+), 15 deletions(-) create mode 100644 scripts/qa-chrome.mjs create mode 100644 src/qaChrome.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1a880b284..296880561 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -934,6 +934,8 @@ "scripts/qa-street-level.mjs", "src/tooling/streetLevelQa.test.mjs", "scripts/qa-browserEvidence.mjs", + "scripts/qa-chrome.mjs", + "src/qaChrome.test.mjs", "scripts/fixtures/street-level/mapillaryGraph.mjs", "scripts/fixtures/street-level/kartaView.mjs", "src/layers/streetLevel/filter.test.mjs", diff --git a/scripts/precompute-cctv-heights.mjs b/scripts/precompute-cctv-heights.mjs index 24813b5e7..648768341 100644 --- a/scripts/precompute-cctv-heights.mjs +++ b/scripts/precompute-cctv-heights.mjs @@ -1,6 +1,7 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { chromeExecutablePath } from './qa-chrome.mjs'; import { planeSupportPoints, poseHash, @@ -389,8 +390,7 @@ async function main() { const { default: puppeteer } = await import('puppeteer'); browser = await puppeteer.launch({ headless: false, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), protocolTimeout: 600000, args: [ '--no-sandbox', diff --git a/scripts/qa-cables-shot.mjs b/scripts/qa-cables-shot.mjs index 12af9beb8..3e3008e03 100644 --- a/scripts/qa-cables-shot.mjs +++ b/scripts/qa-cables-shot.mjs @@ -6,6 +6,7 @@ */ import puppeteer from 'puppeteer'; import { mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; const argv = process.argv; const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214'; @@ -65,7 +66,9 @@ try { requestAnimationFrame(tick); })); await new Promise((r) => setTimeout(r, 1_000)); - const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname; + const path = fileURLToPath( + new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url), + ); await page.screenshot({ path }); console.log(`saved ${path}`); } diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs new file mode 100644 index 000000000..05c4dbff0 --- /dev/null +++ b/scripts/qa-chrome.mjs @@ -0,0 +1,20 @@ +/** + * The browser for gates that drive an installed Chrome. These gates were + * written against macOS Google Chrome, which Windows and Linux do not have. + */ +import { existsSync } from 'node:fs'; + +export const MACOS_CHROME = + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + +/** + * PUPPETEER_EXECUTABLE_PATH when set, then macOS Google Chrome when it is + * installed. Otherwise undefined, so Puppeteer launches the browser it manages. + */ +export function chromeExecutablePath({ + env = process.env, + exists = existsSync, +} = {}) { + if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; + return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; +} diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs index 9501690f9..ca271026f 100644 --- a/scripts/qa-directions.mjs +++ b/scripts/qa-directions.mjs @@ -10,6 +10,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const args = process.argv.slice(2); @@ -35,8 +36,7 @@ const check = (name, passed, detail = '') => { const browser = await puppeteer.launch({ headless: false, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--no-sandbox', '--disable-background-timer-throttling', diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index a5a16af1d..adb29f3af 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw'; @@ -22,8 +23,7 @@ const browser = await puppeteer.launch({ headless: !HEADFUL, ...(HEADFUL ? { - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), } : {}), args: [ diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 5d42279dc..89d60d96f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -20,6 +20,7 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery'; @@ -54,8 +55,7 @@ const browser = await puppeteer.launch({ headless: !HEADFUL, ...(HEADFUL ? { - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), } : {}), args: [ diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs index a16bab9bc..c229be96c 100644 --- a/scripts/qa-transit-heading.mjs +++ b/scripts/qa-transit-heading.mjs @@ -1,11 +1,11 @@ import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; /** Prove parked and moving headings in the real scene, without live feeds. */ export async function runTransitHeadingRegression(base, check) { const browser = await puppeteer.launch({ headless: true, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: ['--no-sandbox', '--disable-background-timer-throttling'], }); try { diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index e5fadda49..5f6404b41 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,5 +1,6 @@ /** Camera-jump regression against an existing server; never starts a server. */ import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -8,8 +9,7 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery'; await mkdir(shots, { recursive: true }); const browser = await puppeteer.launch({ headless: false, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--disable-background-timer-throttling', '--disable-renderer-backgrounding', diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index aac1d8f60..7d83d77a4 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -36,6 +36,7 @@ import { */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; @@ -262,8 +263,7 @@ await mkdir(SHOTS, { recursive: true }); const browser = await puppeteer.launch({ headless: false, protocolTimeout: 150000, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--no-sandbox', '--disable-background-timer-throttling', diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs new file mode 100644 index 000000000..4414f3c93 --- /dev/null +++ b/src/qaChrome.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; + +test('gates keep macOS Chrome where it is installed', () => { + assert.equal( + chromeExecutablePath({ env: {}, exists: (p) => p === MACOS_CHROME }), + MACOS_CHROME, + ); +}); + +test('gates fall back to Puppeteer’s browser without macOS Chrome', () => { + assert.equal( + chromeExecutablePath({ env: {}, exists: () => false }), + undefined, + ); +}); + +test('an explicit executable path wins', () => { + assert.equal( + chromeExecutablePath({ + env: { PUPPETEER_EXECUTABLE_PATH: 'C:\\Chrome\\chrome.exe' }, + exists: () => true, + }), + 'C:\\Chrome\\chrome.exe', + ); +});