320 lines
10 KiB
JavaScript
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,
|
|
},
|
|
);
|
|
});
|