919 lines
32 KiB
JavaScript
919 lines
32 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { readFile } from "node:fs/promises";
|
|
import { after, before, test } from "node:test";
|
|
|
|
import { createServer } from "vite";
|
|
|
|
let server;
|
|
let decodeObservationSessionCatalog;
|
|
let decodeObservationSessionReplay;
|
|
let decodeObservationSessionPreparation;
|
|
let fetchObservationSessionCatalog;
|
|
let deleteObservationSession;
|
|
let replayObservationSession;
|
|
let fetchObservationSessionPreparation;
|
|
let cancelObservationSessionPreparation;
|
|
let ObservationSessionApiError;
|
|
let ObservationSessionContractError;
|
|
let decodeObservationRecordedMediaManifest;
|
|
let createObservationReplayCoordinator;
|
|
let resolveObservationSessionReplay;
|
|
let waitForObservationReplayPreparation;
|
|
let storeObservationReplayPreparation;
|
|
let loadObservationReplayPreparation;
|
|
let observationSessionVisualState;
|
|
let observationSessionVisualLabel;
|
|
|
|
before(async () => {
|
|
server = await createServer({
|
|
appType: "custom",
|
|
logLevel: "silent",
|
|
server: { middlewareMode: true },
|
|
});
|
|
({
|
|
decodeObservationSessionCatalog,
|
|
decodeObservationSessionReplay,
|
|
decodeObservationSessionPreparation,
|
|
fetchObservationSessionCatalog,
|
|
deleteObservationSession,
|
|
replayObservationSession,
|
|
fetchObservationSessionPreparation,
|
|
cancelObservationSessionPreparation,
|
|
ObservationSessionApiError,
|
|
ObservationSessionContractError,
|
|
decodeObservationRecordedMediaManifest,
|
|
} = await server.ssrLoadModule("/src/core/observation/sessionArchive.ts"));
|
|
({
|
|
createObservationReplayCoordinator,
|
|
resolveObservationSessionReplay,
|
|
waitForObservationReplayPreparation,
|
|
storeObservationReplayPreparation,
|
|
loadObservationReplayPreparation,
|
|
} = await server.ssrLoadModule(
|
|
"/src/core/observation/useObservationSessions.ts",
|
|
));
|
|
({ observationSessionVisualState, observationSessionVisualLabel } = await server.ssrLoadModule(
|
|
"/src/components/ObservationSessionSelect.tsx",
|
|
));
|
|
});
|
|
|
|
after(async () => {
|
|
await server?.close();
|
|
});
|
|
|
|
function session(overrides = {}) {
|
|
return {
|
|
id: "20260716T205632Z_viewer_live",
|
|
label: "K1 · наблюдение 16 июля",
|
|
started_at_utc: "2026-07-16T20:56:32.635Z",
|
|
completed_at_utc: "2026-07-16T21:20:43.379Z",
|
|
status: "ready",
|
|
modalities: ["point-cloud", "pose"],
|
|
duration_seconds: 1_450.744,
|
|
replayable: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function replay(overrides = {}) {
|
|
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
|
const sourceUrl = overrides.source_url ??
|
|
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording.rrd`;
|
|
const sha256 = overrides.sha256 ?? "a".repeat(64);
|
|
return {
|
|
schema_version: "missioncore.observation-session-replay/v2",
|
|
launch: {
|
|
kind: "rerun-recording",
|
|
session_id: sessionId,
|
|
source_url: sourceUrl,
|
|
viewer_source_url: overrides.viewer_source_url ?? `${sourceUrl}?generation=${sha256}`,
|
|
media_type: "application/vnd.rerun.rrd",
|
|
timeline: "session_time",
|
|
timeline_start_seconds: 0,
|
|
timeline_end_seconds: 1_450.744,
|
|
seekable: true,
|
|
byte_length: 123_456,
|
|
sha256,
|
|
playback: { speed: 1, loop: false },
|
|
media_sources: [],
|
|
...overrides,
|
|
},
|
|
};
|
|
}
|
|
|
|
function preparation(overrides = {}) {
|
|
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
|
return {
|
|
schema_version: "missioncore.observation-session-preparation/v1",
|
|
preparation: {
|
|
preparation_id: "prepare-20260717T131400Z",
|
|
session_id: sessionId,
|
|
state: "queued",
|
|
progress: null,
|
|
updated_at_utc: "2026-07-17T10:14:00Z",
|
|
status_url: `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording-preparation`,
|
|
cancellable: true,
|
|
...overrides,
|
|
},
|
|
};
|
|
}
|
|
|
|
const preparationEtag = '"prepare-20260717T131400Z"';
|
|
|
|
test("session catalog decodes canonical snake_case into a path-free camelCase model", () => {
|
|
const catalog = decodeObservationSessionCatalog({ items: [session()] });
|
|
|
|
assert.deepEqual(catalog.items, [{
|
|
id: "20260716T205632Z_viewer_live",
|
|
label: "K1 · наблюдение 16 июля",
|
|
startedAtUtc: "2026-07-16T20:56:32.635Z",
|
|
completedAtUtc: "2026-07-16T21:20:43.379Z",
|
|
status: "ready",
|
|
modalities: ["point-cloud", "pose"],
|
|
durationSeconds: 1_450.744,
|
|
replayable: true,
|
|
preparation: null,
|
|
}]);
|
|
assert.equal("raw_capture" in catalog.items[0], false);
|
|
assert.equal("path" in catalog.items[0], false);
|
|
});
|
|
|
|
test("opened archive is named in the scene header and trash hover has no pill", async () => {
|
|
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
|
const styles = await readFile(
|
|
new URL("../src/styles/observation-sessions.css", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const spatialControls = await readFile(
|
|
new URL(
|
|
"../../../plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx",
|
|
import.meta.url,
|
|
),
|
|
"utf8",
|
|
);
|
|
const acquisitionPipeline = await readFile(
|
|
new URL(
|
|
"../../../plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx",
|
|
import.meta.url,
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
assert.match(appSource, /setRecordedReplayLabel\(session\.label\)/);
|
|
assert.match(appSource, /`\$\{activeDefinition\.title\}: \$\{recordedReplayLabel\}`/);
|
|
const deleteStyle = styles.slice(
|
|
styles.indexOf(".observation-session-option__delete"),
|
|
styles.indexOf(".observation-session-option__delete:disabled"),
|
|
);
|
|
assert.match(deleteStyle, /background:\s*transparent/);
|
|
assert.doesNotMatch(deleteStyle, /danger-rgb\) \/ 0\.12/);
|
|
assert.doesNotMatch(spatialControls, /Индикатор постоянно зелёный/);
|
|
assert.doesNotMatch(acquisitionPipeline, /Индикатор постоянно зелёный/);
|
|
});
|
|
|
|
test("session catalog exposes authoritative background preparation state", () => {
|
|
const catalog = decodeObservationSessionCatalog({
|
|
items: [session({
|
|
preparation: {
|
|
preparation_id: "prepare-catalog-1",
|
|
state: "exporting",
|
|
progress: 0.42,
|
|
updated_at_utc: "2026-07-17T10:14:00Z",
|
|
cancellable: true,
|
|
retryable: false,
|
|
},
|
|
})],
|
|
});
|
|
assert.deepEqual(catalog.items[0].preparation, {
|
|
preparationId: "prepare-catalog-1",
|
|
state: "exporting",
|
|
progress: 0.42,
|
|
updatedAtUtc: "2026-07-17T10:14:00Z",
|
|
cancellable: true,
|
|
retryable: false,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("saved-session rows distinguish durable, active, cold and failed states", () => {
|
|
const makeDecoded = (state, overrides = {}) => decodeObservationSessionCatalog({
|
|
items: [session({
|
|
status: "interrupted",
|
|
preparation: state === null ? null : {
|
|
preparation_id: `prepare-${state}`,
|
|
state,
|
|
progress: state === "ready" ? 1 : 0.5,
|
|
updated_at_utc: "2026-07-17T10:14:00Z",
|
|
cancellable: ["queued", "validating", "exporting", "finalizing"].includes(state),
|
|
retryable: state === "failed",
|
|
...(state === "failed" ? { error: "export failed" } : {}),
|
|
},
|
|
...overrides,
|
|
})],
|
|
}).items[0];
|
|
|
|
assert.equal(observationSessionVisualState(makeDecoded("ready")), "ready");
|
|
for (const state of ["queued", "validating", "exporting", "finalizing"]) {
|
|
assert.equal(observationSessionVisualState(makeDecoded(state)), "processing");
|
|
}
|
|
assert.equal(observationSessionVisualState(makeDecoded("failed")), "error");
|
|
assert.equal(observationSessionVisualState(makeDecoded("cancelled")), "error");
|
|
assert.equal(
|
|
observationSessionVisualState(makeDecoded("failed", { status: "recording" })),
|
|
"error",
|
|
);
|
|
assert.equal(observationSessionVisualState(makeDecoded(null)), "cold");
|
|
assert.equal(
|
|
observationSessionVisualState(makeDecoded(null, { status: "recording" })),
|
|
"processing",
|
|
);
|
|
assert.equal(
|
|
observationSessionVisualState(makeDecoded("ready", { replayable: false })),
|
|
"error",
|
|
);
|
|
assert.equal(
|
|
observationSessionVisualState(makeDecoded("ready"), { failed: true }),
|
|
"error",
|
|
);
|
|
assert.equal(
|
|
observationSessionVisualState(makeDecoded("failed"), { pending: true, failed: true }),
|
|
"error",
|
|
);
|
|
assert.equal(observationSessionVisualLabel("ready"), "Готово");
|
|
assert.equal(observationSessionVisualLabel("processing"), "Обработка");
|
|
assert.equal(observationSessionVisualLabel("cold"), "Подготовить");
|
|
assert.equal(observationSessionVisualLabel("error"), "Ошибка");
|
|
});
|
|
|
|
test("saved-session lamps use green or dim gray only, never warning or danger colors", async () => {
|
|
const css = await readFile(
|
|
new URL("../src/styles/observation-sessions.css", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const start = css.indexOf(".observation-session-option i {");
|
|
const end = css.indexOf(".observation-session-option__state", start);
|
|
assert.notEqual(start, -1);
|
|
assert.notEqual(end, -1);
|
|
const lampRules = css.slice(start, end);
|
|
assert.match(lampRules, /data-session-visual-state="ready"[\s\S]*--nodedc-success-rgb/);
|
|
assert.match(lampRules, /data-session-visual-state="processing"[\s\S]*animation:/);
|
|
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*--nodedc-text-muted/);
|
|
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*opacity:\s*0\.48/);
|
|
assert.doesNotMatch(lampRules, /\b(?:warning|danger|yellow|red)\b/i);
|
|
});
|
|
|
|
test("session catalog sorts newest first and uses id as a deterministic tie-breaker", () => {
|
|
const catalog = decodeObservationSessionCatalog({
|
|
items: [
|
|
session({ id: "same-b", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
|
|
session({ id: "newest", started_at_utc: "2026-07-17T00:00:00Z", completed_at_utc: null }),
|
|
session({ id: "same-a", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
|
|
],
|
|
});
|
|
|
|
assert.deepEqual(catalog.items.map(({ id }) => id), ["newest", "same-a", "same-b"]);
|
|
});
|
|
|
|
test("session catalog rejects raw storage fields, unsafe ids and duplicate ids", () => {
|
|
assert.throws(
|
|
() => decodeObservationSessionCatalog({
|
|
items: [session({ raw_capture: "sessions/private/mqtt.raw.k1mqtt" })],
|
|
}),
|
|
ObservationSessionContractError,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionCatalog({ items: [session({ id: "../private" })] }),
|
|
ObservationSessionContractError,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionCatalog({ items: [session(), session()] }),
|
|
/повторяющийся id/,
|
|
);
|
|
});
|
|
|
|
test("session catalog rejects invalid temporal, status and duration values", () => {
|
|
assert.throws(
|
|
() => decodeObservationSessionCatalog({
|
|
items: [session({ started_at_utc: "16 July 2026" })],
|
|
}),
|
|
/ISO-датой/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionCatalog({ items: [session({ status: "complete" })] }),
|
|
/неизвестное состояние/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionCatalog({ items: [session({ duration_seconds: -1 })] }),
|
|
/неотрицательным числом/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionCatalog({
|
|
items: [session({ completed_at_utc: "2026-07-16T19:00:00Z" })],
|
|
}),
|
|
/раньше времени начала/,
|
|
);
|
|
});
|
|
|
|
test("catalog API preserves HTTP detail without accepting a malformed success body", async () => {
|
|
await assert.rejects(
|
|
fetchObservationSessionCatalog({
|
|
fetcher: async () => new Response(JSON.stringify({ detail: "storage unavailable" }), {
|
|
status: 503,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
}),
|
|
(error) => error instanceof ObservationSessionApiError &&
|
|
error.status === 503 && error.message === "storage unavailable",
|
|
);
|
|
|
|
await assert.rejects(
|
|
fetchObservationSessionCatalog({
|
|
fetcher: async () => new Response(JSON.stringify({ items: "not-an-array" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
}),
|
|
ObservationSessionContractError,
|
|
);
|
|
});
|
|
|
|
test("delete API uses one opaque same-origin target and requires an empty 204", async () => {
|
|
const calls = [];
|
|
await deleteObservationSession("session-20260716T205632Z", {
|
|
fetcher: async (input, init) => {
|
|
calls.push({ input: String(input), init });
|
|
return new Response(null, { status: 204 });
|
|
},
|
|
});
|
|
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(
|
|
calls[0].input,
|
|
"/api/v1/observation-sessions/session-20260716T205632Z",
|
|
);
|
|
assert.equal(calls[0].init.method, "DELETE");
|
|
await assert.rejects(
|
|
deleteObservationSession("../private", { fetcher: async () => new Response(null) }),
|
|
ObservationSessionContractError,
|
|
);
|
|
await assert.rejects(
|
|
deleteObservationSession("session-20260716T205632Z", {
|
|
fetcher: async () => new Response(JSON.stringify({ detail: "recording is open" }), {
|
|
status: 409,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
}),
|
|
(error) => error instanceof ObservationSessionApiError &&
|
|
error.status === 409 && error.message === "recording is open",
|
|
);
|
|
});
|
|
|
|
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
|
|
const calls = [];
|
|
const launch = await replayObservationSession("session-20260716T205632Z", {
|
|
fetcher: async (input, init) => {
|
|
calls.push({ input: String(input), init });
|
|
return new Response(JSON.stringify(replay()), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
},
|
|
});
|
|
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].input, "/api/v1/observation-sessions/session-20260716T205632Z/replay");
|
|
assert.equal(calls[0].init.method, "POST");
|
|
assert.equal(launch.kind, "ready");
|
|
assert.equal(launch.launch.timeline, "session_time");
|
|
assert.equal(
|
|
launch.launch.sourceUrl,
|
|
"/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
|
|
);
|
|
await assert.rejects(
|
|
replayObservationSession("../private", { fetcher: async () => new Response(null) }),
|
|
ObservationSessionContractError,
|
|
);
|
|
});
|
|
|
|
test("preparation contract is strict, session-scoped and same-origin", async () => {
|
|
const sessionId = "session-20260716T205632Z";
|
|
const decoded = decodeObservationSessionPreparation(preparation(), sessionId);
|
|
assert.equal(decoded.sessionId, sessionId);
|
|
assert.equal(decoded.state, "queued");
|
|
assert.equal(decoded.progress, null);
|
|
|
|
assert.throws(
|
|
() => decodeObservationSessionPreparation(
|
|
preparation({ status_url: "https://invalid.test/status" }),
|
|
sessionId,
|
|
),
|
|
/канонический same-origin/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionPreparation(preparation({ debug_path: "/tmp/raw" }), sessionId),
|
|
/неизвестные поля/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionPreparation(preparation({ progress: 1.1 }), sessionId),
|
|
/недопустимое число/,
|
|
);
|
|
await assert.rejects(
|
|
replayObservationSession(sessionId, {
|
|
fetcher: async () => new Response(JSON.stringify(preparation()), {
|
|
status: 202,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
}),
|
|
/preparation ETag/,
|
|
);
|
|
});
|
|
|
|
test("202 preparation polls through explicit phases and reveals launch only when ready", async () => {
|
|
const sessionId = "session-20260716T205632Z";
|
|
const calls = [];
|
|
const phases = [];
|
|
let poll = 0;
|
|
let clock = 0;
|
|
let previousViewerMounted = true;
|
|
const launch = await resolveObservationSessionReplay(sessionId, {
|
|
signal: new AbortController().signal,
|
|
requestTimeoutMs: 1_000,
|
|
heartbeatStallMs: 5_000,
|
|
maximumWaitMs: 10_000,
|
|
initialPollIntervalMs: 50,
|
|
maximumPollIntervalMs: 50,
|
|
now: () => clock,
|
|
sleep: async (milliseconds) => {
|
|
assert.equal(previousViewerMounted, true);
|
|
clock += milliseconds;
|
|
},
|
|
onUpdate: (value) => {
|
|
assert.equal(previousViewerMounted, true);
|
|
phases.push([value.state, value.progress]);
|
|
},
|
|
fetcher: async (input, init) => {
|
|
calls.push([String(input), init.method, new Headers(init.headers).get("If-Match")]);
|
|
if (init.method === "POST") {
|
|
return new Response(JSON.stringify(preparation()), {
|
|
status: 202,
|
|
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
|
});
|
|
}
|
|
poll += 1;
|
|
if (poll === 1) {
|
|
return new Response(JSON.stringify(preparation({
|
|
state: "exporting",
|
|
progress: 0.5,
|
|
updated_at_utc: "2026-07-17T10:14:01Z",
|
|
})), {
|
|
status: 202,
|
|
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify(replay({ session_id: sessionId })), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
|
});
|
|
},
|
|
});
|
|
// This is the point where App is allowed to unmount the old viewer.
|
|
previousViewerMounted = false;
|
|
assert.equal(launch.sessionId, sessionId);
|
|
assert.deepEqual(phases, [["queued", null], ["exporting", 0.5]]);
|
|
assert.deepEqual(calls.map((entry) => entry[1]), ["POST", "GET", "GET"]);
|
|
assert.deepEqual(calls.map((entry) => entry[2]), [null, preparationEtag, preparationEtag]);
|
|
});
|
|
|
|
test("status-ready response must match the preparation ETag before launch is accepted", async () => {
|
|
const decoded = decodeObservationSessionPreparation(
|
|
preparation({ state: "finalizing", progress: 0.95 }),
|
|
"session-20260716T205632Z",
|
|
);
|
|
let receivedIfMatch;
|
|
await assert.rejects(
|
|
fetchObservationSessionPreparation(decoded, {
|
|
fetcher: async (_input, init) => {
|
|
receivedIfMatch = new Headers(init.headers).get("If-Match");
|
|
return new Response(JSON.stringify(replay()), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json", ETag: '"replacement-job"' },
|
|
});
|
|
},
|
|
}),
|
|
/актуальный preparation ETag/,
|
|
);
|
|
assert.equal(receivedIfMatch, preparationEtag);
|
|
});
|
|
|
|
test("preparation cancellation is conditional on the same preparation ETag", async () => {
|
|
const decoded = decodeObservationSessionPreparation(
|
|
preparation({ state: "exporting", progress: 0.5 }),
|
|
"session-20260716T205632Z",
|
|
);
|
|
let request;
|
|
const result = await cancelObservationSessionPreparation(decoded, {
|
|
fetcher: async (input, init) => {
|
|
request = {
|
|
input: String(input),
|
|
method: init.method,
|
|
ifMatch: new Headers(init.headers).get("If-Match"),
|
|
};
|
|
return new Response(null, { status: 204 });
|
|
},
|
|
});
|
|
assert.equal(result, null);
|
|
assert.deepEqual(request, {
|
|
input: decoded.statusUrl,
|
|
method: "DELETE",
|
|
ifMatch: preparationEtag,
|
|
});
|
|
});
|
|
|
|
test("preparation polling fails explicitly when the server heartbeat stalls", async () => {
|
|
const decoded = decodeObservationSessionPreparation(
|
|
preparation({ state: "exporting", progress: 0.1 }),
|
|
"session-20260716T205632Z",
|
|
);
|
|
let clock = 0;
|
|
await assert.rejects(
|
|
waitForObservationReplayPreparation(decoded, {
|
|
signal: new AbortController().signal,
|
|
requestTimeoutMs: 1_000,
|
|
heartbeatStallMs: 250,
|
|
maximumWaitMs: 5_000,
|
|
initialPollIntervalMs: 150,
|
|
maximumPollIntervalMs: 150,
|
|
now: () => clock,
|
|
sleep: async (milliseconds) => { clock += milliseconds; },
|
|
fetcher: async () => new Response(JSON.stringify(preparation({
|
|
state: "exporting",
|
|
progress: 0.1,
|
|
})), {
|
|
status: 202,
|
|
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
|
}),
|
|
}),
|
|
/перестала обновляться/,
|
|
);
|
|
});
|
|
|
|
test("queued preparation can wait behind the bounded backend worker without false heartbeat failure", async () => {
|
|
const decoded = decodeObservationSessionPreparation(
|
|
preparation({ state: "queued", progress: 0 }),
|
|
"session-20260716T205632Z",
|
|
);
|
|
let clock = 0;
|
|
let polls = 0;
|
|
const launch = await waitForObservationReplayPreparation(decoded, {
|
|
signal: new AbortController().signal,
|
|
requestTimeoutMs: 1_000,
|
|
heartbeatStallMs: 250,
|
|
maximumWaitMs: 5_000,
|
|
initialPollIntervalMs: 150,
|
|
maximumPollIntervalMs: 150,
|
|
now: () => clock,
|
|
sleep: async (milliseconds) => { clock += milliseconds; },
|
|
fetcher: async () => {
|
|
polls += 1;
|
|
if (polls <= 2) {
|
|
return new Response(JSON.stringify(preparation({ state: "queued", progress: 0 })), {
|
|
status: 202,
|
|
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify(replay()), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
|
});
|
|
},
|
|
});
|
|
assert.equal(clock, 450);
|
|
assert.equal(launch.sessionId, "session-20260716T205632Z");
|
|
});
|
|
|
|
test("terminal preparation failure is explicit and retryable", async () => {
|
|
const sessionId = "session-20260716T205632Z";
|
|
const response = await replayObservationSession(sessionId, {
|
|
fetcher: async () => new Response(JSON.stringify(preparation({
|
|
state: "failed",
|
|
progress: 0.4,
|
|
cancellable: false,
|
|
retryable: true,
|
|
error: "Повреждён индекс исходной записи.",
|
|
})), {
|
|
status: 409,
|
|
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
|
}),
|
|
});
|
|
assert.equal(response.kind, "preparing");
|
|
assert.equal(response.preparation.retryable, true);
|
|
assert.equal(response.preparation.error, "Повреждён индекс исходной записи.");
|
|
});
|
|
|
|
test("pending preparation survives reload only through its strict canonical contract", () => {
|
|
const values = new Map();
|
|
const storage = {
|
|
get length() { return values.size; },
|
|
clear: () => values.clear(),
|
|
getItem: (key) => values.get(key) ?? null,
|
|
key: (index) => [...values.keys()][index] ?? null,
|
|
removeItem: (key) => values.delete(key),
|
|
setItem: (key, value) => values.set(key, String(value)),
|
|
};
|
|
const decoded = decodeObservationSessionPreparation(
|
|
preparation({ state: "exporting", progress: 0.7 }),
|
|
"session-20260716T205632Z",
|
|
);
|
|
storeObservationReplayPreparation(decoded, storage);
|
|
assert.deepEqual(loadObservationReplayPreparation(storage), decoded);
|
|
|
|
const key = storage.key(0);
|
|
storage.setItem(key, JSON.stringify({ ...preparation(), raw_path: "/private/raw" }));
|
|
assert.equal(loadObservationReplayPreparation(storage), null);
|
|
assert.equal(storage.length, 0);
|
|
});
|
|
|
|
test("replay API forwards cancellation and preserves AbortError semantics", async () => {
|
|
const controller = new AbortController();
|
|
let receivedSignal;
|
|
const pending = replayObservationSession("session-20260716T205632Z", {
|
|
signal: controller.signal,
|
|
fetcher: async (_input, init) => {
|
|
receivedSignal = init.signal;
|
|
return await new Promise((_resolve, reject) => {
|
|
init.signal.addEventListener("abort", () => {
|
|
reject(new DOMException("cancelled", "AbortError"));
|
|
}, { once: true });
|
|
});
|
|
},
|
|
});
|
|
|
|
controller.abort();
|
|
assert.equal(receivedSignal, controller.signal);
|
|
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
|
});
|
|
|
|
test("replay coordinator makes rapid saved-session selection latest-request-wins", () => {
|
|
const coordinator = createObservationReplayCoordinator();
|
|
const first = coordinator.begin();
|
|
assert.equal(first.isCurrent(), true);
|
|
|
|
const second = coordinator.begin();
|
|
assert.equal(first.signal.aborted, true);
|
|
assert.equal(first.isCurrent(), false);
|
|
assert.equal(first.finish(), false);
|
|
assert.equal(second.isCurrent(), true);
|
|
assert.equal(second.finish(), true);
|
|
assert.equal(second.isCurrent(), false);
|
|
|
|
const third = coordinator.begin();
|
|
coordinator.cancel();
|
|
assert.equal(third.signal.aborted, true);
|
|
assert.equal(third.isCurrent(), false);
|
|
assert.equal(third.finish(), true);
|
|
assert.equal(third.finish(), false);
|
|
});
|
|
|
|
test("an acquisition guard cancellation keeps settlement ownership", () => {
|
|
const coordinator = createObservationReplayCoordinator();
|
|
const archiveSwitch = coordinator.begin();
|
|
let replacementBlanked = false;
|
|
let replacementSettled = false;
|
|
|
|
// Models the point after onReplayBegin has released the previous viewer.
|
|
replacementBlanked = true;
|
|
coordinator.cancel();
|
|
|
|
assert.equal(archiveSwitch.signal.aborted, true);
|
|
assert.equal(archiveSwitch.isCurrent(), false);
|
|
if (archiveSwitch.finish()) replacementSettled = true;
|
|
assert.equal(replacementBlanked, true);
|
|
assert.equal(replacementSettled, true);
|
|
});
|
|
|
|
test("switching saved sessions aborts only local polling and never cancels shared backend work", async () => {
|
|
const hookSource = await readFile(
|
|
new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const selectorSource = await readFile(
|
|
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.doesNotMatch(hookSource, /cancelObservationSessionPreparation|method:\s*["']DELETE/);
|
|
assert.doesNotMatch(selectorSource, /cancelReplay|>\s*Отменить\s*</);
|
|
});
|
|
|
|
test("replay descriptor rejects path leaks, mismatched sessions and non-seekable data", () => {
|
|
assert.throws(
|
|
() => decodeObservationSessionReplay(replay({ source_url: "file:///private/session.rrd" })),
|
|
/same-origin/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionReplay(replay({ source_url: "/api/v1/observation-sessions/other/recording.rrd" })),
|
|
/same-origin/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionReplay(replay({ seekable: false })),
|
|
/seekable/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionReplay(replay({
|
|
viewer_source_url: "/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
|
|
})),
|
|
/канонически привязан/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionReplay(replay({
|
|
viewer_source_url: `https://foreign.test/recording.rrd?generation=${"a".repeat(64)}`,
|
|
})),
|
|
/канонически привязан/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionReplay(replay({ raw_path: "/private/raw.k1mqtt" })),
|
|
/неизвестные поля/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionReplay(replay({ byte_length: Number.MAX_SAFE_INTEGER + 1 })),
|
|
/недопустимое число/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionReplay(replay({ playback: { speed: 101, loop: false } })),
|
|
/недопустимое число/,
|
|
);
|
|
});
|
|
|
|
test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest", () => {
|
|
const sessionId = "session-20260716T205632Z";
|
|
const source = {
|
|
id: "recorded.camera.5a5a5a5a5a5a5a5a",
|
|
label: "Записанная камера 1",
|
|
modality: "video",
|
|
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
|
|
manifest_generation_sha256: "c".repeat(64),
|
|
byte_length: 3_266,
|
|
media_type: "video/mp4",
|
|
timeline_start_seconds: 0.25,
|
|
timeline_end_seconds: 20,
|
|
seekable: true,
|
|
synchronization: "host-arrival-best-effort",
|
|
};
|
|
const decoded = decodeObservationSessionReplay(replay({
|
|
session_id: sessionId,
|
|
timeline_end_seconds: 20,
|
|
media_sources: [source],
|
|
}));
|
|
assert.equal(
|
|
decoded.viewerSourceUrl,
|
|
`/api/v1/observation-sessions/${sessionId}/recording.rrd?generation=${"a".repeat(64)}`,
|
|
);
|
|
assert.equal(decoded.mediaSources.length, 1);
|
|
assert.equal(decoded.mediaSources[0].id, source.id);
|
|
assert.equal(decoded.mediaSources[0].manifestUrl, source.manifest_url);
|
|
assert.equal(
|
|
decoded.mediaSources[0].manifestGenerationSha256,
|
|
source.manifest_generation_sha256,
|
|
);
|
|
|
|
const manifest = decodeObservationRecordedMediaManifest({
|
|
schema_version: "missioncore.observation-recorded-media/v2",
|
|
source_id: source.id,
|
|
generation_sha256: "c".repeat(64),
|
|
byte_length: 3_266,
|
|
timeline_start_seconds: 0.25,
|
|
timeline_end_seconds: 20,
|
|
synchronization: "host-arrival-best-effort",
|
|
epochs: [{
|
|
ordinal: 1,
|
|
timeline_start_seconds: 0.25,
|
|
timeline_end_seconds: 20,
|
|
media_type: 'video/mp4; codecs="avc1.640028"',
|
|
init_url: source.manifest_url.replace("/manifest", "/epochs/1/init.mp4"),
|
|
init_byte_length: 128,
|
|
init_sha256: "a".repeat(64),
|
|
segment_count: 12,
|
|
segment_url_prefix: source.manifest_url.replace("/manifest", "/epochs/1/segments/"),
|
|
segments: Array.from({ length: 12 }, (_, index) => ({
|
|
sequence: index + 1,
|
|
url: source.manifest_url.replace("/manifest", `/epochs/1/segments/${index + 1}.m4s`),
|
|
byte_length: 256 + index,
|
|
sha256: "b".repeat(64),
|
|
})),
|
|
}],
|
|
}, decoded.mediaSources[0]);
|
|
assert.equal(manifest.epochs[0].segmentCount, 12);
|
|
assert.equal(manifest.epochs[0].segments.length, 12);
|
|
assert.equal(manifest.epochs[0].timelineStartSeconds, 0.25);
|
|
assert.throws(
|
|
() => decodeObservationRecordedMediaManifest({
|
|
...{
|
|
schema_version: "missioncore.observation-recorded-media/v1",
|
|
source_id: source.id,
|
|
generation_sha256: "c".repeat(64),
|
|
byte_length: 3_266,
|
|
timeline_start_seconds: 0.25,
|
|
timeline_end_seconds: 20,
|
|
synchronization: "host-arrival-best-effort",
|
|
epochs: [],
|
|
},
|
|
}, decoded.mediaSources[0]),
|
|
/несовместим/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationRecordedMediaManifest({
|
|
schema_version: "missioncore.observation-recorded-media/v2",
|
|
source_id: source.id,
|
|
generation_sha256: "c".repeat(64),
|
|
byte_length: 3_267,
|
|
timeline_start_seconds: 0.25,
|
|
timeline_end_seconds: 20,
|
|
synchronization: "host-arrival-best-effort",
|
|
epochs: [],
|
|
}, decoded.mediaSources[0]),
|
|
/несовместим|launch descriptor/,
|
|
);
|
|
});
|
|
|
|
test("recorded camera contracts reject foreign origins, path escapes and unknown fields", () => {
|
|
const sessionId = "session-20260716T205632Z";
|
|
const base = {
|
|
id: "recorded.camera.5a5a5a5a5a5a5a5a",
|
|
label: "Записанная камера 1",
|
|
modality: "video",
|
|
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
|
|
manifest_generation_sha256: "c".repeat(64),
|
|
byte_length: 384,
|
|
media_type: "video/mp4",
|
|
timeline_start_seconds: 0,
|
|
timeline_end_seconds: 20,
|
|
seekable: true,
|
|
synchronization: "host-arrival-best-effort",
|
|
};
|
|
assert.throws(
|
|
() => decodeObservationSessionReplay(replay({
|
|
session_id: sessionId,
|
|
timeline_end_seconds: 20,
|
|
media_sources: [{ ...base, manifest_url: "https://invalid.test/private" }],
|
|
})),
|
|
/same-origin/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionReplay(replay({
|
|
session_id: sessionId,
|
|
timeline_end_seconds: 20,
|
|
media_sources: [{ ...base, path: "/private/archive" }],
|
|
})),
|
|
/неизвестные поля/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionReplay(replay({
|
|
session_id: sessionId,
|
|
timeline_end_seconds: 20,
|
|
media_sources: [{ ...base, manifest_generation_sha256: undefined }],
|
|
})),
|
|
/immutable generation/,
|
|
);
|
|
assert.throws(
|
|
() => decodeObservationSessionReplay(replay({
|
|
session_id: sessionId,
|
|
timeline_end_seconds: 20,
|
|
media_sources: [{ ...base, manifest_generation_sha256: "not-a-digest" }],
|
|
})),
|
|
/immutable generation/,
|
|
);
|
|
const decoded = decodeObservationSessionReplay(replay({
|
|
session_id: sessionId,
|
|
timeline_end_seconds: 20,
|
|
media_sources: [base],
|
|
}));
|
|
assert.throws(
|
|
() => decodeObservationRecordedMediaManifest({
|
|
schema_version: "missioncore.observation-recorded-media/v2",
|
|
source_id: base.id,
|
|
generation_sha256: "c".repeat(64),
|
|
byte_length: 384,
|
|
timeline_start_seconds: 0,
|
|
timeline_end_seconds: 20,
|
|
synchronization: "host-arrival-best-effort",
|
|
epochs: [{
|
|
ordinal: 1,
|
|
timeline_start_seconds: 0,
|
|
timeline_end_seconds: 20,
|
|
media_type: 'video/mp4; codecs="avc1.640028"',
|
|
init_url: "file:///private/init.mp4",
|
|
init_byte_length: 128,
|
|
init_sha256: "a".repeat(64),
|
|
segment_count: 1,
|
|
segment_url_prefix: "file:///private/segments/",
|
|
segments: [{
|
|
sequence: 1,
|
|
url: "file:///private/segments/1.m4s",
|
|
byte_length: 256,
|
|
sha256: "b".repeat(64),
|
|
}],
|
|
}],
|
|
}, decoded.mediaSources[0]),
|
|
/небезопасный API URL/,
|
|
);
|
|
});
|