NODEDC_MISSION_CORE/apps/control-station/test/observationSessions.test.mjs

853 lines
30 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 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,
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("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("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/,
);
});