import assert from "node:assert/strict"; import { after, before, test } from "node:test"; import { createServer } from "vite"; let createLiveViewerDiagnosticLifecycle; let createAbortFencedBuildVerifier; let createUiBuildStaleCoordinator; let liveViewerDiagnosticBody; let reloadRecordedViewerAfterStaleModuleFailure; let server; let uiBuildIdFromModuleScripts; let verifyLiveViewerClientBuild; before(async () => { server = await createServer({ appType: "custom", logLevel: "silent", server: { middlewareMode: true }, }); ({ createAbortFencedBuildVerifier, createLiveViewerDiagnosticLifecycle, createUiBuildStaleCoordinator, liveViewerDiagnosticBody, reloadRecordedViewerAfterStaleModuleFailure, uiBuildIdFromModuleScripts, verifyLiveViewerClientBuild, } = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts")); }); after(async () => { await server?.close(); }); function createFakeScheduler() { let now = 0; let nextHandle = 1; const jobs = new Map(); const schedule = (callback, delay, interval) => { const handle = nextHandle; nextHandle += 1; jobs.set(handle, { callback, due: now + delay, interval }); return handle; }; const clear = (handle) => jobs.delete(handle); return { scheduler: { setTimeout: (callback, delay) => schedule(callback, delay, null), clearTimeout: clear, setInterval: (callback, delay) => schedule(callback, delay, delay), clearInterval: clear, }, advance(milliseconds) { const target = now + milliseconds; while (true) { const next = [...jobs.entries()] .filter(([, job]) => job.due <= target) .sort((left, right) => left[1].due - right[1].due)[0]; if (!next) break; const [handle, job] = next; now = job.due; if (job.interval === null) jobs.delete(handle); else job.due += job.interval; job.callback(); } now = target; }, pending: () => jobs.size, }; } const lineage = (viewerInstanceId, lifecycleGeneration = 1) => ({ uiBuildId: "/assets/index-abcdefgh.js", documentInstanceId: "00000000-0000-4000-8000-000000000001", viewerInstanceId, lifecycleGeneration, }); test("mounted viewer admission terminally fences 60 seconds of stale timers", () => { const clock = createFakeScheduler(); const callbacks = []; const posts = []; const lifecycle = createLiveViewerDiagnosticLifecycle({ lineage: lineage("00000000-0000-4000-8000-000000000011"), scheduler: clock.scheduler, diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }), buildVerifier: () => undefined, }); lifecycle.armAdmissionTimeout(() => callbacks.push("timeout"), 12_000); lifecycle.armAdmissionInterval(() => callbacks.push("discovery"), 100); lifecycle.markAdmitted(); lifecycle.post({ eventCode: "live_receiver_active_store_admitted" }); clock.advance(60_000); assert.deepEqual(callbacks, []); assert.equal(clock.pending(), 0); assert.equal(posts.length, 1); assert.equal(posts[0].eventLineage.lifecycleGeneration, 1); }); test("two mounted viewers keep timer and diagnostic lineage isolated", () => { const clock = createFakeScheduler(); const posts = []; const first = createLiveViewerDiagnosticLifecycle({ lineage: lineage("00000000-0000-4000-8000-000000000021"), scheduler: clock.scheduler, diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }), buildVerifier: () => undefined, }); const second = createLiveViewerDiagnosticLifecycle({ lineage: lineage("00000000-0000-4000-8000-000000000022", 7), scheduler: clock.scheduler, diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }), buildVerifier: () => undefined, }); first.armAdmissionTimeout(() => { first.post({ eventCode: "live_receiver_error" }); }, 12_000); second.armAdmissionTimeout(() => { second.post({ eventCode: "live_receiver_error" }); }, 12_000); first.markAdmitted(); clock.advance(12_000); assert.equal(posts.length, 1); assert.equal( posts[0].eventLineage.viewerInstanceId, "00000000-0000-4000-8000-000000000022", ); assert.equal(posts[0].eventLineage.lifecycleGeneration, 7); }); test("stale build drift neither fences transports nor reloads the document", () => { const clock = createFakeScheduler(); const sideEffects = []; const posts = []; const lifecycle = createLiveViewerDiagnosticLifecycle({ lineage: lineage("00000000-0000-4000-8000-000000000031"), scheduler: clock.scheduler, diagnosticPoster: (event) => posts.push(event), buildVerifier: () => undefined, }); lifecycle.armAdmissionTimeout(() => { lifecycle.post({ eventCode: "live_receiver_error" }); }, 12_000); const coordinator = createUiBuildStaleCoordinator(); coordinator.subscribe(() => { sideEffects.push("local-transports-closed"); lifecycle.dispose(); }); coordinator.report({ loadedUiBuildId: "/assets/index-abcdefgh.js", expectedUiBuildId: "/assets/index-ijklmnop.js", }); coordinator.report({ loadedUiBuildId: "/assets/index-abcdefgh.js", expectedUiBuildId: "/assets/index-qrstuvwx.js", }); clock.advance(60_000); lifecycle.post({ eventCode: "live_receiver_error" }); assert.deepEqual(sideEffects, []); assert.deepEqual(posts, [ { eventCode: "live_receiver_error" }, { eventCode: "live_receiver_error" }, ]); assert.equal(lifecycle.active(), true); assert.equal(coordinator.stale(), true); }); test("build drift is reported once with the exact loaded build", async () => { const originalFetch = globalThis.fetch; const calls = []; globalThis.fetch = async (url, options) => { calls.push({ url, options }); return new Response(JSON.stringify({ schema_version: "missioncore.live-viewer-client-contract/v1", status: "ready", ui_build_id: "/assets/index-ijklmnop.js", diagnostic_schema_version: "missioncore.live-viewer-diagnostic/v2", }), { status: 200, headers: { "X-MissionCore-UI-Build": "/assets/index-ijklmnop.js" }, }); }; try { const eventLineage = lineage("00000000-0000-4000-8000-000000000032"); verifyLiveViewerClientBuild(eventLineage); await new Promise((resolve) => setTimeout(resolve, 0)); verifyLiveViewerClientBuild(eventLineage); await new Promise((resolve) => setTimeout(resolve, 0)); } finally { globalThis.fetch = originalFetch; } assert.equal(calls.length, 1); assert.equal(calls[0].url, "/api/v1/viewer/client-contract"); assert.equal( calls[0].options.headers["X-MissionCore-UI-Build"], "/assets/index-abcdefgh.js", ); }); test("a recorded viewer reloads only when its failed lazy module belongs to a stale build", async () => { const reloads = []; const loadedUiBuildId = "/assets/index-abcdefgh.js"; const currentUiBuildId = "/assets/index-ijklmnop.js"; const fetcher = async (_url, options) => { assert.equal(options.headers["X-MissionCore-UI-Build"], loadedUiBuildId); return new Response(JSON.stringify({}), { status: 200, headers: { "X-MissionCore-UI-Build": currentUiBuildId }, }); }; assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({ loadedUiBuildId, fetcher, reload: () => reloads.push("reload"), }), true); assert.deepEqual(reloads, ["reload"]); assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({ loadedUiBuildId: currentUiBuildId, fetcher: async () => new Response(JSON.stringify({}), { status: 200, headers: { "X-MissionCore-UI-Build": currentUiBuildId }, }), reload: () => reloads.push("unexpected"), }), false); assert.deepEqual(reloads, ["reload"]); }); test("last unsubscribe fences an already queued build verification callback", () => { const controller = new AbortController(); const observedSignals = []; const queuedVerify = createAbortFencedBuildVerifier( controller.signal, (signal) => observedSignals.push(signal), ); queuedVerify(); // stopBuildMonitor aborts the locally captured controller when the last // mounted viewer unsubscribes. A browser callback already queued before the // interval/listener removal can still run once, but cannot start a fetch. controller.abort(); queuedVerify(); assert.deepEqual(observedSignals, [controller.signal]); assert.equal(observedSignals[0].aborted, true); }); test("diagnostic body and build id retain exact document/viewer/build lineage", () => { const eventLineage = lineage("00000000-0000-4000-8000-000000000041", 9); assert.deepEqual( liveViewerDiagnosticBody( { eventCode: "live_receiver_recovered", streamId: "acquisition-42" }, eventLineage, ), { schema_version: "missioncore.live-viewer-diagnostic/v2", event_code: "live_receiver_recovered", ui_build_id: "/assets/index-abcdefgh.js", document_instance_id: "00000000-0000-4000-8000-000000000001", viewer_instance_id: "00000000-0000-4000-8000-000000000041", lifecycle_generation: 9, stream_id: "acquisition-42", }, ); assert.equal( uiBuildIdFromModuleScripts( ["https://mission.local/assets/index-dT7dN-y4.js"], "https://mission.local/park", ), "/assets/index-dT7dN-y4.js", ); assert.deepEqual( liveViewerDiagnosticBody( { eventCode: "live_camera_transport_restart_requested", failureStage: "camera-queue-capacity", streamId: "camera-preview-2", cameraQueueBytes: 12_000_000, cameraQueueSegments: 96, cameraRetryCount: 3, websocketCloseCode: 4_008, transportEpoch: 7, cameraAppendErrorName: "InvalidStateError", cameraMediaSourceState: "open", cameraVideoErrorCode: 3, }, eventLineage, ), { schema_version: "missioncore.live-viewer-diagnostic/v2", event_code: "live_camera_transport_restart_requested", ui_build_id: "/assets/index-abcdefgh.js", document_instance_id: "00000000-0000-4000-8000-000000000001", viewer_instance_id: "00000000-0000-4000-8000-000000000041", lifecycle_generation: 9, failure_stage: "camera-queue-capacity", stream_id: "camera-preview-2", camera_queue_bytes: 12_000_000, camera_queue_segments: 96, camera_retry_count: 3, websocket_close_code: 4_008, transport_epoch: 7, camera_append_error_name: "InvalidStateError", camera_media_source_state: "open", camera_video_error_code: 3, }, ); });