Files
NODEDC_MISSION_CORE/apps/control-station/test/liveViewerDiagnostics.test.mjs

320 lines
10 KiB
JavaScript

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,
},
);
});