From d58199a21497f3f71b2ef211965c5b14c7045872 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:54:28 +0530 Subject: [PATCH 01/10] =?UTF-8?q?fix(vessels):=20show=20a=20359.7=C2=B0=20?= =?UTF-8?q?course=20as=200=C2=B0,=20not=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vessel cards and the AIS HUD rounded the direction without wrapping, so a vessel reporting no heading and a course over ground of 359.5° or more read "360°" on its cards and "HDG: 360DEG" in the HUD. The rounded direction now wraps to 0°. --- docs/CURRENT-STATE.md | 4 +++- src/data/aisLiveVessels.test.mjs | 21 +++++++++++++++++++++ src/layers/vessels/cards.js | 9 ++++++--- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..11bc07655 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -503,7 +503,9 @@ those reports arrive as 102.3 knots and 360 degrees. Both are stored as unknown, and vessel cards, the HUD and analyst queries show a missing measurement rather than a reading. Heading keeps its existing separate check for its own sentinel. Genuine readings are unchanged, including a stopped vessel's zero and the -highest speed and course the fields can encode. +highest speed and course the fields can encode. Vessel cards and the HUD wrap +the direction after rounding to whole degrees, so a course of 359.5° or more +reads `0°` (`0DEG` in the HUD) rather than `360°`. For an already tracked vessel, AISStream static messages update name, type, destination and IMO without changing its position, motion, fix timestamps, diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 96a5cd105..85abb9323 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1322,6 +1322,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted', assert.deepEqual(bare.details, []); }); +test('vessel cards read a course that rounds to north as 0°, not 360°', () => { + const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0]; + assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°'); + assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°'); + assert.equal( + buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0], + 'CONTAINER SHIP · 14.5KT · 0°', + ); +}); + +test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => { + const harness = installWireHarness(undefined, { selectedRecord: null }); + try { + Object.assign(harness.record, { heading: null, course: 359.7 }); + assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true); + assert.match(harness.hud.textContent, /HDG: 0DEG/); + } finally { + harness.cleanup(); + } +}); + test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => { const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' })); assert.equal(unnamed.title, 'MMSI 353136000'); diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index 64e115637..a5671ae54 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -60,7 +60,10 @@ export function createCards({ if (record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed)); const direction = record.heading ?? record.course; - if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`); + // A course of 359.5° and above rounds to north; modulo keeps it from + // printing 360°. The selected card and the HUD wrap the same way. + if (Number.isFinite(direction)) + parts.push(`${Math.round(direction) % 360}°`); return { id: vesselOverlayEntryId(record), actionable: Boolean(record?.mmsi), @@ -91,7 +94,7 @@ export function createCards({ [ vesselTypeShort(record) || t('VESSEL'), formatSpeed(record.speed), - Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°', + Number.isFinite(direction) ? `${Math.round(direction) % 360}°` : '--°', ].join(' · '), ]; const destination = String(record.destination || '').trim(); @@ -181,7 +184,7 @@ export function createCards({ function formatHeading(heading) { return t('{heading}DEG', { - heading: Number.isFinite(heading) ? Math.round(heading) : '--', + heading: Number.isFinite(heading) ? Math.round(heading) % 360 : '--', }); } From 003a622602c72b7edc8e3245478d4c7473e83212 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:57:12 +0530 Subject: [PATCH 02/10] docs(changelog): add #1432 fragment --- changelog.d/1432-vessel-course-north.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1432-vessel-course-north.md diff --git a/changelog.d/1432-vessel-course-north.md b/changelog.d/1432-vessel-course-north.md new file mode 100644 index 000000000..45d9e5845 --- /dev/null +++ b/changelog.d/1432-vessel-course-north.md @@ -0,0 +1,3 @@ +- Show a vessel course of 359.5° or more as 0°. Vessel cards and the AIS HUD + rounded the course without wrapping, so a ship with no reported heading on + a 359.7° course read "360°" and "HDG: 360DEG" (Aditya, @Rohilalala, #1432). From 940050d4e5abd244b291408f9de8cb5e8277de4a Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:06:07 +0530 Subject: [PATCH 03/10] =?UTF-8?q?fix(cctv):=20show=20a=20camera=20facing?= =?UTF-8?q?=20359.6=C2=B0=20as=20HDG=200=C2=B0,=20not=20HDG=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CCTV HUD heading token and the camera panel's meta line rounded the bearing without wrapping, so a camera facing 359.5° or more (a feed bearing, or a heading calibrated just west of north) read "HDG 360°". Both now wrap after rounding: 359.6° reads "HDG 0°", 359.4° "HDG 359°". --- docs/CURRENT-STATE.md | 4 +++- src/layers/cctv/headingConfidence.js | 3 ++- src/layers/cctv/headingConfidence.test.mjs | 9 ++++++++ src/ui/cctvControls.test.mjs | 24 ++++++++++++++++++++++ src/ui/cctvPresentation.js | 2 +- 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..c3e87e8d6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4788,7 +4788,9 @@ silently demoting every later lookup for the session. wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration (`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as estimated, matching the CAL badge. Public camera state carries `headingConfidence` and - `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). + `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). The HUD token and the + camera panel's meta line wrap the bearing after rounding to whole degrees, so a camera + facing 359.5° or more reads `HDG 0°` rather than `HDG 360°`. - **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens diff --git a/src/layers/cctv/headingConfidence.js b/src/layers/cctv/headingConfidence.js index 26a877558..fad0867de 100644 --- a/src/layers/cctv/headingConfidence.js +++ b/src/layers/cctv/headingConfidence.js @@ -44,7 +44,8 @@ export function isHeadingEstimated(camera) { * @returns {string} */ export function headingHudToken(camera) { - const hdg = Math.round(Number(camera?.headingDeg) || 0); + // 359.5° and above rounds to north; modulo keeps it from printing 360°. + const hdg = Math.round(Number(camera?.headingDeg) || 0) % 360; return isHeadingEstimated(camera) ? t('HDG {value}° (ESTIMATED)', { value: hdg }) : t('HDG {value}°', { value: hdg }); diff --git a/src/layers/cctv/headingConfidence.test.mjs b/src/layers/cctv/headingConfidence.test.mjs index 513bf0d1b..3cf673861 100644 --- a/src/layers/cctv/headingConfidence.test.mjs +++ b/src/layers/cctv/headingConfidence.test.mjs @@ -44,6 +44,15 @@ test('HUD token tags a synthetic bearing and rounds the degrees', () => { ); }); +test('HUD token reads a bearing that rounds to north as 0°, not 360°', () => { + assert.equal(headingHudToken({ headingDeg: 359.4 }), 'HDG 359°'); + assert.equal(headingHudToken({ headingDeg: 359.6 }), 'HDG 0°'); + assert.equal( + headingHudToken({ headingDeg: 359.5, headingConfidence: 'low' }), + 'HDG 0° (ESTIMATED)', + ); +}); + test('HUD token stays untagged for surveyed and calibrated bearings', () => { assert.equal( headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }), diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 6a47ff91f..23e5ea75e 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -47,6 +47,30 @@ function fixture(t) { return { controls, requests }; } +test('the panel meta line reads a bearing that rounds to north as HDG 0°', (t) => { + const { controls } = fixture(t); + controls.actions.setPanelCollapsed = () => {}; + controls._cctvMeta = element(); + const meta = (headingDeg) => { + const camera = { + id: 'a', + city: 'Austin', + headingDeg, + fovDeg: 74, + rangeM: 700, + }; + controls._renderCctvState({ + enabled: true, + activeCameraId: camera.id, + activeCamera: camera, + cameras: [camera], + }); + return controls._cctvMeta.textContent; + }; + assert.match(meta(359.4), /· HDG 359° ·/); + assert.match(meta(359.6), /· HDG 0° ·/); +}); + test('a late image completion cannot replace a newer camera preview', (t) => { const { controls, requests } = fixture(t); controls._queueCctvFrame('first.jpg', 'a', true); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 070c47f64..e4dffa98e 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -171,7 +171,7 @@ export function _renderCctvState(state) { '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}', { city: activeCamera.city, - heading: `${Math.round(activeCamera.headingDeg)}°`, + heading: `${Math.round(activeCamera.headingDeg) % 360}°`, fov: `${Math.round(activeCamera.fovDeg)}°`, range: Math.round(activeCamera.rangeM), projection: projLabel, From 065d7f4f8bf35fa9b465656bbed987c9a8944f87 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:15:16 +0530 Subject: [PATCH 04/10] =?UTF-8?q?fix(alpr):=20show=20a=20359.6=C2=B0=20cam?= =?UTF-8?q?era=20bearing=20as=200=C2=B0,=20not=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selected ALPR card rounded a mapped camera:direction without wrapping, so a camera facing 359.5° or more read "DIRECTION 360°". The rounded bearing now wraps to 0°. --- docs/CURRENT-STATE.md | 4 +++- src/layers/alpr/overlay.test.mjs | 18 ++++++++++++++++++ src/layers/alpr/visuals.js | 3 ++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..dd595af17 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -894,7 +894,9 @@ change. Once the camera is still and tiles have settled, each overlay anchor is replaced once by the rendered surface under it (two depth samples per paint, accepted only within -30/+40 m of the floor cell), so badges do not float or sink on slopes; wedge corners follow their own floor cells instead of staying -level with the apex. Unknown bearings never acquire a wedge. The row reads +level with the apex. Unknown bearings never acquire a wedge. The selected +card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing +of 359.5° or more reads `0°` rather than `360°`. The row reads `N nearby · M on screen`; when every loaded camera is outside the view it adds "None on screen — nearby cameras are outside the view" instead of looking unloaded. `scripts/qa-alpr-journey.mjs` drives an Austin journey with ALPR off, diff --git a/src/layers/alpr/overlay.test.mjs b/src/layers/alpr/overlay.test.mjs index 1bb9985af..c9aa8e5f5 100644 --- a/src/layers/alpr/overlay.test.mjs +++ b/src/layers/alpr/overlay.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { createAlprOverlay } from './overlay.js'; +import { normalizeAlprNode } from './records.js'; import { directionWedgePositions, alprLabelDetails, @@ -218,6 +219,23 @@ test('adapter labels never claim a custom source is public OSM data', () => { ); }); +test('a mapped bearing that rounds up to a whole turn reads 0°, not 360°', () => { + const direction = (tag) => + alprLabelDetails( + normalizeAlprNode({ + type: 'node', + id: 1, + lat: 30, + lon: -97, + tags: { 'surveillance:type': 'ALPR', 'camera:direction': tag }, + }), + { label: 'Custom directory' }, + ).find((line) => line.startsWith('DIRECTION')); + assert.equal(direction('359.6'), 'DIRECTION 0°'); + assert.equal(direction('359.4'), 'DIRECTION 359°'); + assert.equal(direction('0.4'), 'DIRECTION 0°'); +}); + test('overlay glyphs and native badges shrink alike with distance', () => { assert.equal(markerScale(100), 1.15); assert.equal(markerScale(40_000), 0.45); diff --git a/src/layers/alpr/visuals.js b/src/layers/alpr/visuals.js index 3a3d3fc5f..2a9202d92 100644 --- a/src/layers/alpr/visuals.js +++ b/src/layers/alpr/visuals.js @@ -49,8 +49,9 @@ export function alprLabelDetails(record, source) { const details = [ sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`, ]; + // Wrap after rounding: 359.6° rounds to 360°, which is north. if (Number.isFinite(record.directionDeg)) - details.push(`DIRECTION ${Math.round(record.directionDeg)}°`); + details.push(`DIRECTION ${Math.round(record.directionDeg) % 360}°`); const equipment = [ ...new Set( [record.manufacturer, record.operator, record.cameraType] From fdcbcdaaef39373c5ce2981914ade6a4185add9e Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:02 +0530 Subject: [PATCH 05/10] docs(changelog): add #1441 fragment --- changelog.d/1441-cctv-heading-north.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1441-cctv-heading-north.md diff --git a/changelog.d/1441-cctv-heading-north.md b/changelog.d/1441-cctv-heading-north.md new file mode 100644 index 000000000..d65e5c2d8 --- /dev/null +++ b/changelog.d/1441-cctv-heading-north.md @@ -0,0 +1,3 @@ +- Show a CCTV camera facing 359.5° or more as HDG 0°. The HUD heading token + and the camera panel rounded the bearing without wrapping, so a camera + facing 359.6° read "HDG 360°" (Aditya, @Rohilalala, #1441). From 420d64ceccad8f6827cbc97f773f488e7241f454 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:37:01 +0530 Subject: [PATCH 06/10] docs(changelog): add #1451 fragment --- changelog.d/1451-alpr-direction-north.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1451-alpr-direction-north.md diff --git a/changelog.d/1451-alpr-direction-north.md b/changelog.d/1451-alpr-direction-north.md new file mode 100644 index 000000000..74f04b7ab --- /dev/null +++ b/changelog.d/1451-alpr-direction-north.md @@ -0,0 +1,2 @@ +- Show an ALPR camera's mapped bearing of 359.5° or more as DIRECTION 0° + rather than DIRECTION 360° (Aditya, @Rohilalala, #1451). From af79691a190b692963f4ff5793a6f9af787f0963 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 20:53:32 -0800 Subject: [PATCH 07/10] test(cctv): check the north-rounding meta line through presentCctvPanel The panel now renders from the CCTV store, so the meta line is no longer written to a DOM node by _renderCctvState. Assert on presentCctvPanel's meta text instead. --- src/ui/cctvControls.test.mjs | 24 ------------------------ src/ui/cctvPanel.test.mjs | 23 +++++++++++++++++++++++ 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 5389bf0f4..9a5d746d7 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -47,30 +47,6 @@ function fixture(t) { return { controls, requests }; } -test('the panel meta line reads a bearing that rounds to north as HDG 0°', (t) => { - const { controls } = fixture(t); - controls.actions.setPanelCollapsed = () => {}; - controls._cctvMeta = element(); - const meta = (headingDeg) => { - const camera = { - id: 'a', - city: 'Austin', - headingDeg, - fovDeg: 74, - rangeM: 700, - }; - controls._renderCctvState({ - enabled: true, - activeCameraId: camera.id, - activeCamera: camera, - cameras: [camera], - }); - return controls._cctvMeta.textContent; - }; - assert.match(meta(359.4), /· HDG 359° ·/); - assert.match(meta(359.6), /· HDG 0° ·/); -}); - test('a late image completion cannot replace a newer camera preview', (t) => { const { controls, requests } = fixture(t); controls._queueCctvFrame('first.jpg', 'a', true); diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index 1675d6119..a3dbef786 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -490,3 +490,26 @@ test('the scene summary types in, then holds the whole text', async (t) => { await nextTick(); assert.equal(byId('cctv-summary').textContent, text); }); + +test('the panel meta line reads a bearing that rounds to north as HDG 0°', () => { + const meta = (headingDeg) => { + const camera = { + id: 'a', + city: 'Austin', + headingDeg, + fovDeg: 74, + rangeM: 700, + }; + return presentCctvPanel( + { + enabled: true, + activeCameraId: camera.id, + activeCamera: camera, + cameras: [camera], + }, + true, + ).meta; + }; + assert.match(meta(359.4), /· HDG 359° ·/); + assert.match(meta(359.6), /· HDG 0° ·/); +}); From 0e170e0515b646d5250e2eb28ddb1f1a798b801f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 20:53:37 -0800 Subject: [PATCH 08/10] test(vessels): read the north-rounding HUD heading from the published readout The vessel HUD now publishes its text through services.readouts, so the test harness exposes hud.readout rather than a DOM node's textContent. --- src/data/aisLiveVessels.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index f9a897f65..8e12bff94 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1336,7 +1336,7 @@ test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => { try { Object.assign(harness.record, { heading: null, course: 359.7 }); assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true); - assert.match(harness.hud.textContent, /HDG: 0DEG/); + assert.match(harness.hud.readout.text, /HDG: 0DEG/); } finally { harness.cleanup(); } From 7d236a1174c5913787ff5a130b1b7c6da7d7d322 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 20:51:51 -0800 Subject: [PATCH 09/10] build: cache compiled components and load them on the test thread Since the UI moved to single-file components, every unit-test process compiled the views it imports from scratch, and the loader ran off-thread (module.register), adding a round trip to every module a test imports, including the ones that are not components. Core's unit tests took about twice as long, and a host running Core's suite on smaller runners passed its CI time limit. register-vue now installs synchronous hooks on the loading thread (module.registerHooks). Compiled components are kept in the system temp directory, keyed by the file's path and text, the Vue version and the loader's own code, so each one compiles once across test processes; the compiler itself loads only on a cache miss. Cached modules run as code, so the directory is used only when it is a real directory owned by the current user and writable by no one else; on Windows it lives in the user's local app data instead of a temp directory that may be shared. The cache never sits inside the package tree, and GEV_VUE_CACHE=0 compiles every time. On four cores the full suite went from 441 s to 240 s from a cold cache. --- build/registerVue.js | 9 ++- build/vueLoader.js | 129 +++++++++++++++++++++++++++++++-- scripts/format-scope.json | 1 + src/tooling/vueLoader.test.mjs | 111 ++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 src/tooling/vueLoader.test.mjs diff --git a/build/registerVue.js b/build/registerVue.js index eb8dc4736..6db7d017c 100644 --- a/build/registerVue.js +++ b/build/registerVue.js @@ -1,5 +1,8 @@ -import { register } from 'node:module'; +import { registerHooks } from 'node:module'; +import { load } from './vueLoader.js'; // Lets Node import single-file components: `node --import=`. -// Hosts reach it as `/build/register-vue`. -register('./vueLoader.js', import.meta.url); +// Hosts reach it as `/build/register-vue`. The hooks run on the +// loading thread: off-thread hooks (module.register) would add a round trip +// to every module a test imports, not only the components. +registerHooks({ load }); diff --git a/build/vueLoader.js b/build/vueLoader.js index a549fca47..13026dc04 100644 --- a/build/vueLoader.js +++ b/build/vueLoader.js @@ -1,14 +1,32 @@ -import { readFile } from 'node:fs/promises'; +import { createHash, randomBytes } from 'node:crypto'; +import { + lstatSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { compileScript, compileTemplate, parse } from 'vue/compiler-sfc'; import { VUE_TEMPLATE_OPTIONS } from './vueOptions.js'; +const require = createRequire(import.meta.url); + +// The compiler loads on the first component that is not cached, so a test +// process that imports no views, or only cached ones, never pays for it. +let compiler = null; +const sfcCompiler = () => (compiler ??= require('vue/compiler-sfc')); + /** * Compile a single-file component to an ES module for Node, so unit tests (the * application's and a host's) can import components without a bundler. * Styles are dropped: tests read the global stylesheet separately. */ export function compileVueModule(source, filename) { + const { compileScript, compileTemplate, parse } = sfcCompiler(); // The template is parsed with the SFC; parse it as the browser build does. const { descriptor, errors } = parse(source, { filename, @@ -40,14 +58,113 @@ export function compileVueModule(source, filename) { return `${script}\n${template}\nif (typeof render === 'function') __sfc__.render = render;\nexport default __sfc__;\n`; } -/** Node module hook: load `.vue` files as compiled modules. */ -export async function load(url, context, nextLoad) { +// The loader's own code is part of every key, so a change to how components +// compile never reuses output from before it. +let loaderFingerprint = null; +const fingerprint = () => + (loaderFingerprint ??= createHash('sha256') + .update(readFileSync(fileURLToPath(import.meta.url))) + .update(readFileSync(new URL('./vueOptions.js', import.meta.url))) + .update(require('vue/package.json').version) + .digest('hex')); + +/** + * Where compiled components are kept between test processes, never a + * package's own tree (a host's checks refuse files inside Core): the system + * temp directory, or on Windows the per-user local app data directory, since + * a temp directory there may be shared and its ownership is not checked. + * GEV_VUE_CACHE=0 compiles every time. + */ +export function vueCacheDirectory( + env = process.env, + platform = process.platform, +) { + if (env.GEV_VUE_CACHE === '0') return null; + if (platform === 'win32') { + return env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'gev-vue-sfc') : null; + } + return path.join(tmpdir(), `gev-vue-sfc-${process.getuid()}`); +} + +/** The cache entry for one component: its path, its text and the loader. */ +export function vueCacheKey(source, filename) { + return createHash('sha256') + .update(JSON.stringify([fingerprint(), filename])) + .update('\0') + .update(source) + .digest('hex'); +} + +/** + * Compile a component, reusing a previous process's result for the same text. + * A cache that cannot be read or written only costs the compile. + */ +export function cachedVueModule( + source, + filename, + directory = vueCacheDirectory(), +) { + if (!directory || !ownCacheDirectory(directory)) { + return compileVueModule(source, filename); + } + const entry = path.join(directory, `${vueCacheKey(source, filename)}.mjs`); + try { + return readFileSync(entry, 'utf8'); + } catch { + // Not compiled yet. + } + const compiled = compileVueModule(source, filename); + // Concurrent test processes and threads may compile the same component; + // each writes its own new file and the rename makes the entry appear whole. + const partial = `${entry}.${randomBytes(6).toString('hex')}.tmp`; + try { + writeFileSync(partial, compiled, { flag: 'wx', mode: 0o600 }); + renameSync(partial, entry); + } catch { + // An unwritable directory, or a rename another process holds open, + // leaves the cache without this entry. + try { + rmSync(partial, { force: true }); + } catch { + // A partial file that cannot be removed is only clutter. + } + } + return compiled; +} + +/** + * Cached modules run as code, so the directory must be this user's own: a + * real directory (not a link), owned by the current user and writable by no + * one else. In a shared temp directory another user could otherwise create it + * first and plant modules. On Windows it sits in the user's own profile. + */ +const checkedDirectories = new Map(); +export function ownCacheDirectory(directory) { + if (!checkedDirectories.has(directory)) { + checkedDirectories.set(directory, isOwnDirectory(directory)); + } + return checkedDirectories.get(directory); +} + +function isOwnDirectory(directory) { + try { + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const stats = lstatSync(directory); + if (!stats.isDirectory()) return false; + if (process.platform === 'win32') return true; + return stats.uid === process.getuid() && (stats.mode & 0o022) === 0; + } catch { + return false; + } +} + +/** Synchronous module hook (module.registerHooks): `.vue` loads compiled. */ +export function load(url, context, nextLoad) { if (!url.endsWith('.vue')) return nextLoad(url, context); const filename = fileURLToPath(url); - const source = await readFile(filename, 'utf8'); return { format: 'module', - source: compileVueModule(source, filename), + source: cachedVueModule(readFileSync(filename, 'utf8'), filename), shortCircuit: true, }; } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c6ae4b493..240b52e12 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -68,6 +68,7 @@ "server/standalone/vite.config.js", "vite.config.js", "src/tooling/viteBuild.test.mjs", + "src/tooling/vueLoader.test.mjs", "src/tooling/allowedHosts.test.mjs", "server/providers/common/http.js", "server/providers/common/query.js", diff --git a/src/tooling/vueLoader.test.mjs b/src/tooling/vueLoader.test.mjs new file mode 100644 index 000000000..772299c0b --- /dev/null +++ b/src/tooling/vueLoader.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + cachedVueModule, + compileVueModule, + ownCacheDirectory, + vueCacheDirectory, + vueCacheKey, +} from '../../build/vueLoader.js'; + +const SOURCE = + '\n\n'; +const FILE = '/virtual/Greeting.vue'; + +test('a component compiles once and later processes read the cached module', (t) => { + const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const compiled = cachedVueModule(SOURCE, FILE, directory); + assert.equal(compiled, compileVueModule(SOURCE, FILE)); + const entries = readdirSync(directory); + assert.deepEqual(entries, [`${vueCacheKey(SOURCE, FILE)}.mjs`]); + // A cached entry is returned as stored, without compiling again. + writeFileSync(path.join(directory, entries[0]), 'export default "cached";'); + assert.equal( + cachedVueModule(SOURCE, FILE, directory), + 'export default "cached";', + ); +}); + +test('an edited component or another path is a new cache entry', () => { + const key = vueCacheKey(SOURCE, FILE); + assert.notEqual(vueCacheKey(`${SOURCE}\n`, FILE), key); + assert.notEqual(vueCacheKey(SOURCE, '/virtual/Other.vue'), key); + assert.equal(vueCacheKey(SOURCE, FILE), key); +}); + +test('GEV_VUE_CACHE=0 compiles every time; otherwise the cache sits in a per-user directory', () => { + assert.equal(vueCacheDirectory({ GEV_VUE_CACHE: '0' }), null); + if (process.platform !== 'win32') { + const directory = vueCacheDirectory({}); + assert.equal(path.dirname(directory), tmpdir()); + assert.match(path.basename(directory), /^gev-vue-sfc-/); + } + // Windows temp directories may be shared, so the cache lives in the + // user's profile, or not at all. + assert.equal( + vueCacheDirectory( + { LOCALAPPDATA: 'C:\\Users\\a\\AppData\\Local' }, + 'win32', + ), + path.join('C:\\Users\\a\\AppData\\Local', 'gev-vue-sfc'), + ); + assert.equal(vueCacheDirectory({}, 'win32'), null); + assert.equal( + cachedVueModule(SOURCE, FILE, null), + compileVueModule(SOURCE, FILE), + ); +}); + +test('a cache directory that cannot be written still returns the compiled module', (t) => { + const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + // A file where the cache directory should be makes every write fail. + const blocked = path.join(directory, 'blocked'); + writeFileSync(blocked, ''); + assert.equal( + cachedVueModule(SOURCE, FILE, blocked), + compileVueModule(SOURCE, FILE), + ); +}); + +test( + 'a cache directory others can write to, or a link to one, is never read', + { + skip: process.platform === 'win32', + }, + (t) => { + const root = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const shared = path.join(root, 'shared'); + mkdirSync(shared); + chmodSync(shared, 0o777); + // A module planted where the cache would look must not run. + writeFileSync( + path.join(shared, `${vueCacheKey(SOURCE, FILE)}.mjs`), + 'export default "planted";', + ); + assert.equal(ownCacheDirectory(shared), false); + assert.equal( + cachedVueModule(SOURCE, FILE, shared), + compileVueModule(SOURCE, FILE), + ); + const own = path.join(root, 'own'); + mkdirSync(own, { mode: 0o700 }); + const link = path.join(root, 'link'); + symlinkSync(own, link); + assert.equal(ownCacheDirectory(link), false); + assert.equal(ownCacheDirectory(own), true); + }, +); From edf8c0929d2216bd95362af9c9cf5bbb76447576 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 21:37:29 -0800 Subject: [PATCH 10/10] changelog: note the component cache --- changelog.d/1500-vue-loader-cache.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1500-vue-loader-cache.md diff --git a/changelog.d/1500-vue-loader-cache.md b/changelog.d/1500-vue-loader-cache.md new file mode 100644 index 000000000..da2ff7c13 --- /dev/null +++ b/changelog.d/1500-vue-loader-cache.md @@ -0,0 +1,4 @@ +- `build/register-vue` loads components on the test thread and keeps compiled + components between test processes in a per-user cache directory, so Node + tests that import views run about twice as fast. `GEV_VUE_CACHE=0` compiles + every time (samehkhamis, #1500).