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 use only authoritative ready, processing and error 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)), "error"); 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("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* { 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/, ); });