304 lines
11 KiB
JavaScript
304 lines
11 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import { after, before, test } from "node:test";
|
|
|
|
import { createServer } from "vite";
|
|
|
|
let server;
|
|
let fetchVerifiedRecordedMediaArchive;
|
|
let recordedMediaPresentationState;
|
|
let recordedMediaSeekableCoverage;
|
|
let appendRecordedMediaBuffer;
|
|
|
|
before(async () => {
|
|
server = await createServer({
|
|
appType: "custom",
|
|
logLevel: "silent",
|
|
server: { middlewareMode: true },
|
|
});
|
|
({
|
|
fetchVerifiedRecordedMediaArchive,
|
|
recordedMediaPresentationState,
|
|
recordedMediaSeekableCoverage,
|
|
appendRecordedMediaBuffer,
|
|
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
|
|
});
|
|
|
|
after(async () => {
|
|
await server?.close();
|
|
});
|
|
|
|
function digest(payload) {
|
|
return createHash("sha256").update(payload).digest("hex");
|
|
}
|
|
|
|
function fixture() {
|
|
const manifestUrl = "/api/v1/observation-sessions/session-1/media/camera-1/manifest";
|
|
const init = Buffer.from("canonical-init");
|
|
const first = Buffer.from("canonical-first-fragment");
|
|
const second = Buffer.from("canonical-second-fragment");
|
|
const generation = "a".repeat(64);
|
|
const segmentPrefix = manifestUrl.replace("/manifest", "/epochs/1/segments/");
|
|
const manifest = {
|
|
schema_version: "missioncore.observation-recorded-media/v2",
|
|
source_id: "recorded.camera.camera-1",
|
|
generation_sha256: generation,
|
|
byte_length: init.byteLength + first.byteLength + second.byteLength,
|
|
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: manifestUrl.replace("/manifest", "/epochs/1/init.mp4"),
|
|
init_byte_length: init.byteLength,
|
|
init_sha256: digest(init),
|
|
segment_count: 2,
|
|
segment_url_prefix: segmentPrefix,
|
|
segments: [first, second].map((payload, index) => ({
|
|
sequence: index + 1,
|
|
url: `${segmentPrefix}${index + 1}.m4s`,
|
|
byte_length: payload.byteLength,
|
|
sha256: digest(payload),
|
|
})),
|
|
}],
|
|
};
|
|
const source = {
|
|
id: manifest.source_id,
|
|
label: "Записанная камера",
|
|
modality: "video",
|
|
manifestUrl,
|
|
manifestGenerationSha256: generation,
|
|
byteLength: manifest.byte_length,
|
|
mediaType: "video/mp4",
|
|
timelineStartSeconds: 0,
|
|
timelineEndSeconds: 20,
|
|
seekable: true,
|
|
synchronization: "host-arrival-best-effort",
|
|
};
|
|
return { source, manifest, generation, init, first, second };
|
|
}
|
|
|
|
function jsonResponse(payload, generation) {
|
|
return new Response(JSON.stringify(payload), {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
ETag: `"sha256:${generation}"`,
|
|
},
|
|
});
|
|
}
|
|
|
|
function mediaResponse(payload, sha = digest(payload), length = payload.byteLength) {
|
|
return new Response(payload, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Length": String(length),
|
|
ETag: `"sha256:${sha}"`,
|
|
},
|
|
});
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
async function flushAsync() {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
test("recorded camera archive stays pending until every canonical byte is verified", async () => {
|
|
const { source, manifest, generation, init, first, second } = fixture();
|
|
const finalSegment = deferred();
|
|
const requested = [];
|
|
let manifestRequests = 0;
|
|
const fetcher = async (input, request = {}) => {
|
|
const url = String(input);
|
|
requested.push(url);
|
|
const headers = new Headers(request.headers);
|
|
if (url === source.manifestUrl) {
|
|
manifestRequests += 1;
|
|
assert.equal(headers.get("If-Match"), `"sha256:${generation}"`);
|
|
return jsonResponse(manifest, generation);
|
|
}
|
|
const epoch = manifest.epochs[0];
|
|
if (url === epoch.init_url) {
|
|
assert.equal(headers.get("If-Match"), `"sha256:${epoch.init_sha256}"`);
|
|
return mediaResponse(init);
|
|
}
|
|
if (url === epoch.segments[0].url) return mediaResponse(first);
|
|
if (url === epoch.segments[1].url) return finalSegment.promise;
|
|
throw new Error(`Unexpected URL ${url}`);
|
|
};
|
|
|
|
let settled = false;
|
|
const archivePromise = fetchVerifiedRecordedMediaArchive(source, { fetcher })
|
|
.then((archive) => {
|
|
settled = true;
|
|
return archive;
|
|
});
|
|
await flushAsync();
|
|
|
|
assert.equal(settled, false, "init and a partial segment prefix must not publish the camera");
|
|
assert.deepEqual(requested.slice(0, 4), [
|
|
source.manifestUrl,
|
|
manifest.epochs[0].init_url,
|
|
manifest.epochs[0].segments[0].url,
|
|
manifest.epochs[0].segments[1].url,
|
|
]);
|
|
|
|
finalSegment.resolve(mediaResponse(second));
|
|
const archive = await archivePromise;
|
|
assert.equal(manifestRequests, 2, "the immutable generation is revalidated after transfer");
|
|
assert.equal(archive.byteLength, init.byteLength + first.byteLength + second.byteLength);
|
|
assert.equal(archive.epochs[0].segments.length, 2);
|
|
});
|
|
|
|
test("first camera manifest request is launch-generation bound and rejects replacement", async () => {
|
|
const { source, manifest, generation } = fixture();
|
|
const replacementGeneration = "d".repeat(64);
|
|
let requests = 0;
|
|
const fetcher = async (input, request = {}) => {
|
|
requests += 1;
|
|
assert.equal(String(input), source.manifestUrl);
|
|
assert.equal(
|
|
new Headers(request.headers).get("If-Match"),
|
|
`"sha256:${generation}"`,
|
|
);
|
|
return jsonResponse(
|
|
{ ...manifest, generation_sha256: replacementGeneration },
|
|
replacementGeneration,
|
|
);
|
|
};
|
|
|
|
await assert.rejects(
|
|
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
|
/несовместим|заменён/,
|
|
);
|
|
assert.equal(requests, 1);
|
|
});
|
|
|
|
test("recorded camera archive fails closed on truncation despite plausible headers", async () => {
|
|
const { source, manifest, generation, init, first, second } = fixture();
|
|
const fetcher = async (input) => {
|
|
const url = String(input);
|
|
if (url === source.manifestUrl) return jsonResponse(manifest, generation);
|
|
const epoch = manifest.epochs[0];
|
|
if (url === epoch.init_url) return mediaResponse(init);
|
|
if (url === epoch.segments[0].url) return mediaResponse(first);
|
|
if (url === epoch.segments[1].url) {
|
|
return mediaResponse(second.subarray(0, second.byteLength - 1), digest(second), second.byteLength);
|
|
}
|
|
throw new Error(`Unexpected URL ${url}`);
|
|
};
|
|
|
|
await assert.rejects(
|
|
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
|
/усечён/,
|
|
);
|
|
});
|
|
|
|
test("recorded camera archive fails closed when bytes are replaced under an old ETag", async () => {
|
|
const { source, manifest, generation, init, first, second } = fixture();
|
|
const replacement = Buffer.from(second.map((value) => value ^ 0xff));
|
|
assert.equal(replacement.byteLength, second.byteLength);
|
|
const fetcher = async (input) => {
|
|
const url = String(input);
|
|
if (url === source.manifestUrl) return jsonResponse(manifest, generation);
|
|
const epoch = manifest.epochs[0];
|
|
if (url === epoch.init_url) return mediaResponse(init);
|
|
if (url === epoch.segments[0].url) return mediaResponse(first);
|
|
if (url === epoch.segments[1].url) {
|
|
return mediaResponse(replacement, digest(second), second.byteLength);
|
|
}
|
|
throw new Error(`Unexpected URL ${url}`);
|
|
};
|
|
|
|
await assert.rejects(
|
|
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
|
/SHA-256/,
|
|
);
|
|
});
|
|
|
|
test("camera presentation gate opens only for the completely appended selected epoch", () => {
|
|
assert.equal(recordedMediaPresentationState("loading", null, "g1", false), "loading");
|
|
assert.equal(recordedMediaPresentationState("ready", null, "g1", false), "loading");
|
|
assert.equal(recordedMediaPresentationState("ready", "g2", "g1", false), "loading");
|
|
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false), "ready");
|
|
assert.equal(recordedMediaPresentationState("ready", "g1", null, true), "waiting");
|
|
assert.equal(recordedMediaPresentationState("error", "g1", "g1", false), "error");
|
|
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "loading"), "loading");
|
|
assert.equal(recordedMediaPresentationState("ready", "g1", null, true, "loading"), "loading");
|
|
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "error"), "error");
|
|
});
|
|
|
|
test("decoded duration and seekable range cover the complete declared epoch", () => {
|
|
assert.equal(recordedMediaSeekableCoverage(20, 20, 20), true);
|
|
assert.equal(recordedMediaSeekableCoverage(19, 19, 20), true);
|
|
assert.equal(recordedMediaSeekableCoverage(18.99, 20, 20), false);
|
|
assert.equal(recordedMediaSeekableCoverage(20, 18.99, 20), false);
|
|
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1), true);
|
|
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
|
|
});
|
|
|
|
test("aborting SourceBuffer append removes every temporary listener", async () => {
|
|
const listeners = new Map();
|
|
const sourceBuffer = {
|
|
addEventListener(type, listener) {
|
|
const bucket = listeners.get(type) ?? new Set();
|
|
bucket.add(listener);
|
|
listeners.set(type, bucket);
|
|
},
|
|
removeEventListener(type, listener) {
|
|
listeners.get(type)?.delete(listener);
|
|
},
|
|
appendBuffer() {},
|
|
};
|
|
const abort = new AbortController();
|
|
const pending = appendRecordedMediaBuffer(
|
|
sourceBuffer,
|
|
new ArrayBuffer(8),
|
|
abort.signal,
|
|
);
|
|
assert.equal(listeners.get("updateend")?.size, 1);
|
|
assert.equal(listeners.get("error")?.size, 1);
|
|
abort.abort();
|
|
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
|
assert.equal(listeners.get("updateend")?.size, 0);
|
|
assert.equal(listeners.get("error")?.size, 0);
|
|
});
|
|
|
|
test("verified camera archives remain immutable for safe player remount", async () => {
|
|
const source = await readFile(
|
|
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.doesNotMatch(source, /\.init\s*=\s*new ArrayBuffer/);
|
|
assert.doesNotMatch(source, /\.segments\.length\s*=\s*0/);
|
|
});
|
|
|
|
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
|
const css = await readFile(
|
|
new URL("../src/styles/observation.css", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.match(
|
|
css,
|
|
/\.recorded-media-player:not\(\[data-state="ready"\]\) \.observation-media__asset\s*\{[^}]*visibility:\s*hidden/s,
|
|
);
|
|
assert.match(
|
|
css,
|
|
/\.recorded-media-player__notice\s*\{[^}]*inset:\s*0;[^}]*background:\s*#070809/s,
|
|
);
|
|
});
|