594 lines
27 KiB
JavaScript
594 lines
27 KiB
JavaScript
const assert = require("assert");
|
|
const crypto = require("crypto");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { EventEmitter } = require("events");
|
|
const { spawn } = require("child_process");
|
|
const AdmZip = require("adm-zip");
|
|
|
|
const root = path.join(__dirname, "..");
|
|
const {
|
|
ArtifactManager,
|
|
safeArchivePath,
|
|
validateManifestEntry
|
|
} = require("../src/services/artifact-manager");
|
|
const {
|
|
MediaMtxRuntime,
|
|
parseMetrics,
|
|
readResponseText,
|
|
renderConfig,
|
|
sha256Credential,
|
|
validateHlsAsset
|
|
} = require("../src/services/mediamtx-runtime");
|
|
const {
|
|
StreamTestingService,
|
|
INACTIVITY_MS,
|
|
isPrivateAddress,
|
|
probeExternalRtmps,
|
|
resolveIngestConfiguration,
|
|
rewriteManifest,
|
|
sourceFor,
|
|
validateHostname
|
|
} = require("../src/services/stream-testing");
|
|
const {
|
|
DEFAULT_LOCAL_RTMP_PORT,
|
|
DEFAULT_PUBLIC_RTMPS_PORT,
|
|
validateReverseProxyIngestSettings
|
|
} = require("../src/services/stream-test-ingest-settings");
|
|
const protocol = require("../plugins/lumi_transcription/backend/companion/protocol");
|
|
|
|
class FakeRuntime extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.session = null;
|
|
this.running = false;
|
|
this.cleared = 0;
|
|
this.pathReady = true;
|
|
this.inboundBytes = 4096;
|
|
}
|
|
status() {
|
|
return {
|
|
available: true,
|
|
installed: true,
|
|
valid: true,
|
|
version: "v1.18.2",
|
|
tested_version: "v1.18.2",
|
|
source: "managed by Lumi",
|
|
managed_install_supported: true,
|
|
detail: "MediaMTX v1.18.2 is ready.",
|
|
process: { state: this.running ? "ready" : "stopped", running: this.running, restart_count: 0, log_tail: "" }
|
|
};
|
|
}
|
|
async configureSession(session) { this.session = session; this.running = true; }
|
|
async prepareListener(listener) { this.listener = listener; this.running = true; }
|
|
async clearSession() { this.session = null; this.cleared += 1; }
|
|
async diagnostics(sessionPath) {
|
|
return {
|
|
path: { name: sessionPath, ready: this.pathReady, tracks: ["H264", "MPEG4Audio"], bytes_received: this.inboundBytes, bytes_sent: 2048, readers: 1 },
|
|
metrics: { paths_inbound_bytes: this.inboundBytes, paths_outbound_bytes: 2048, hls_sessions: 1 },
|
|
process: { state: "ready", running: true }
|
|
};
|
|
}
|
|
async install() { return this.status(); }
|
|
async repair() { return this.status(); }
|
|
async healthCheck() { return { ok: true, version: "v1.18.2", api_status: 200, metrics_status: 200, hls_status: 404 }; }
|
|
async close() { this.running = false; }
|
|
}
|
|
|
|
function createSuccessfulTlsSocket(options) {
|
|
assert.equal(options.host, "lumi.example.test");
|
|
assert.equal(options.port, 1936);
|
|
assert.equal(options.servername, "lumi.example.test");
|
|
assert.equal(options.rejectUnauthorized, true);
|
|
const socket = new EventEmitter();
|
|
socket.authorized = true;
|
|
socket.writes = [];
|
|
socket.write = (chunk, callback) => {
|
|
socket.writes.push(Buffer.from(chunk));
|
|
callback?.();
|
|
if (socket.writes.length === 1) {
|
|
assert.equal(chunk.length, 1537);
|
|
assert.equal(chunk[0], 3);
|
|
const handshake = Buffer.alloc(3073);
|
|
handshake[0] = 3;
|
|
crypto.randomFillSync(handshake, 1);
|
|
queueMicrotask(() => socket.emit("data", handshake));
|
|
} else {
|
|
assert.equal(chunk.length, 1536);
|
|
}
|
|
return true;
|
|
};
|
|
socket.getPeerCertificate = () => ({
|
|
subject: { CN: "lumi.example.test" },
|
|
issuer: { CN: "Test CA" },
|
|
valid_to: "Jan 1 00:00:00 2030 GMT"
|
|
});
|
|
socket.getProtocol = () => "TLSv1.3";
|
|
socket.getCipher = () => ({ standardName: "TLS_AES_256_GCM_SHA384" });
|
|
socket.destroy = () => {};
|
|
queueMicrotask(() => socket.emit("secureConnect"));
|
|
return socket;
|
|
}
|
|
|
|
async function verifyArtifactManager(tempRoot) {
|
|
assert.throws(() => validateManifestEntry({ id: "bad", url: "http://example.invalid/a.zip", sha256: "0".repeat(64) }), /invalid/);
|
|
assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/);
|
|
const zip = new AdmZip();
|
|
zip.addFile("LICENSE", Buffer.from("MIT"));
|
|
zip.addFile("mediamtx.exe", Buffer.from("fake executable"));
|
|
zip.addFile("mediamtx.yml", Buffer.from("paths: {}"));
|
|
const archive = zip.toBuffer();
|
|
const entry = {
|
|
id: "fake-mediamtx",
|
|
install_id: "mediamtx",
|
|
backend: "mediamtx",
|
|
version: "v1.18.2",
|
|
url: "https://example.invalid/mediamtx.zip",
|
|
filename: "mediamtx.zip",
|
|
sha256: crypto.createHash("sha256").update(archive).digest("hex"),
|
|
bytes: archive.length,
|
|
max_bytes: archive.length + 1,
|
|
max_extracted_bytes: 1024,
|
|
expected_entries: ["LICENSE", "mediamtx.exe", "mediamtx.yml"]
|
|
};
|
|
const manager = new ArtifactManager(path.join(tempRoot, "artifacts"), {
|
|
fetch: async () => new Response(archive, {
|
|
status: 200,
|
|
headers: { "Content-Length": String(archive.length) }
|
|
})
|
|
});
|
|
await assert.rejects(() => manager.download(entry), /explicit setup confirmation/);
|
|
const downloaded = await manager.download(entry, { confirmed: true });
|
|
assert(downloaded.valid, "downloaded artifact must match its pinned SHA-256");
|
|
const installed = manager.installZip(entry, downloaded.path, {
|
|
verify: (staged) => assert(fs.existsSync(path.join(staged, "mediamtx.exe")))
|
|
});
|
|
assert(fs.existsSync(path.join(installed.path, "mediamtx.exe")), "validated archive must install atomically");
|
|
const originalExecutable = fs.readFileSync(path.join(installed.path, "mediamtx.exe"), "utf8");
|
|
const replacementZip = new AdmZip();
|
|
replacementZip.addFile("LICENSE", Buffer.from("MIT replacement"));
|
|
replacementZip.addFile("mediamtx.exe", Buffer.from("broken replacement"));
|
|
replacementZip.addFile("mediamtx.yml", Buffer.from("paths: {}"));
|
|
const replacementArchive = replacementZip.toBuffer();
|
|
const replacementPath = path.join(tempRoot, "replacement.zip");
|
|
fs.writeFileSync(replacementPath, replacementArchive);
|
|
const replacementEntry = {
|
|
...entry,
|
|
sha256: crypto.createHash("sha256").update(replacementArchive).digest("hex"),
|
|
bytes: replacementArchive.length
|
|
};
|
|
assert.throws(
|
|
() => manager.installZip(replacementEntry, replacementPath, {
|
|
verifyInstalled: () => { throw new Error("replacement probe failed"); }
|
|
}),
|
|
/replacement probe failed/
|
|
);
|
|
assert.equal(
|
|
fs.readFileSync(path.join(installed.path, "mediamtx.exe"), "utf8"),
|
|
originalExecutable,
|
|
"a failed post-install probe must restore the last known-good runtime"
|
|
);
|
|
assert.throws(
|
|
() => manager.installZip({ ...entry, sha256: "0".repeat(64) }, downloaded.path),
|
|
/Checksum mismatch/
|
|
);
|
|
}
|
|
|
|
async function verifyManagedRuntime(tempRoot) {
|
|
const runtimeRoot = path.join(tempRoot, "runtime");
|
|
const executable = path.join(runtimeRoot, "mediamtx", "mediamtx.exe");
|
|
fs.mkdirSync(path.dirname(executable), { recursive: true });
|
|
fs.writeFileSync(executable, "fake");
|
|
const fixture = path.join(root, "scripts", "fixtures", "fake-mediamtx.js");
|
|
const runtime = new MediaMtxRuntime({
|
|
runtimeRoot,
|
|
configRoot: path.join(tempRoot, "config"),
|
|
platform: "win32",
|
|
arch: "x64",
|
|
spawn: (_executable, args, options) => spawn(process.execPath, [fixture, args[0]], options),
|
|
spawnSync: () => ({ status: 0, stdout: "v1.18.2\n", stderr: "" })
|
|
});
|
|
await assert.rejects(() => runtime.install(), /administrator confirmation/);
|
|
await assert.rejects(() => runtime.repair(), /administrator confirmation/);
|
|
assert.equal(runtime.probeExecutable(executable).version, "v1.18.2", "manual executable probes must remain bounded and versioned");
|
|
const health = await runtime.healthCheck();
|
|
assert(health.ok && health.restart_verified && health.api_status === 200 && health.metrics_status === 200, "fake MediaMTX listeners and supervised restart must pass the runtime health check");
|
|
const session = {
|
|
path: "lumi-test/00000000-0000-4000-8000-000000000001",
|
|
username: "private-user",
|
|
password: "private-password",
|
|
ingestPort: 29350,
|
|
bindHost: "0.0.0.0"
|
|
};
|
|
await runtime.configureSession(session);
|
|
const generated = fs.readFileSync(runtime.configPath, "utf8");
|
|
assert(!generated.includes(session.username) && !generated.includes(session.password), "generated MediaMTX config must not persist plaintext publisher credentials");
|
|
assert(generated.includes(sha256Credential(session.username)) && generated.includes(sha256Credential(session.password)));
|
|
assert.match(generated, /rtsp: false/);
|
|
assert.match(generated, /webrtc: false/);
|
|
assert.match(generated, /srt: false/);
|
|
assert.match(generated, /hlsVariant: lowLatency/);
|
|
assert.match(generated, /maxReaders: 4/);
|
|
assert.match(generated, /rtmpEncryption: "no"/);
|
|
assert.match(generated, /rtmpAddress: "0\.0\.0\.0:29350"/);
|
|
assert(!generated.includes("rtmpsAddress") && !generated.includes("rtmpServerCert") && !generated.includes("rtmpServerKey"));
|
|
const diagnostic = await runtime.diagnostics(session.path);
|
|
assert.equal(diagnostic.path.name, session.path);
|
|
const playlist = await runtime.fetchHls(session.path, "index.m3u8");
|
|
assert.equal(playlist.status, 200);
|
|
assert.match(await playlist.text(), /segment0\.mp4/);
|
|
assert.throws(() => validateHlsAsset("../secret"), /Invalid media path/);
|
|
await runtime.clearSession();
|
|
const denyConfig = fs.readFileSync(runtime.configPath, "utf8");
|
|
assert.match(denyConfig, /paths: \{\}/);
|
|
assert(!denyConfig.includes("private-user"));
|
|
await runtime.close();
|
|
}
|
|
|
|
async function verifyStreamService() {
|
|
const runtime = new FakeRuntime();
|
|
const service = new StreamTestingService({
|
|
runtime,
|
|
timer: false,
|
|
ingestSettings: () => ({ publicPort: 1936, listenerPort: 19350 }),
|
|
tlsConnect: createSuccessfulTlsSocket
|
|
});
|
|
const sent = [];
|
|
try {
|
|
const created = await service.create(
|
|
{ id: "device-1", name: "Studio PC", pairing_host: "http://localhost:3000" },
|
|
{ source: { width: 2560, height: 1440, fps: 60 } },
|
|
(type, payload) => sent.push({ type, payload })
|
|
);
|
|
assert.match(created.ingest.server, /^rtmp:\/\/localhost:19350\/lumi-test$/);
|
|
assert.equal(runtime.session.ingestPort, 19350);
|
|
assert.match(created.ingest.key, /^[0-9a-f-]{36}\?user=/);
|
|
assert.equal(created.source.variants.length, 1, "MediaMTX handoff must preserve source quality without a synthetic ladder");
|
|
assert.equal(runtime.session.path, `lumi-test/${created.id}`);
|
|
assert(runtime.session.username && runtime.session.password);
|
|
assert.equal(runtime.session.bindHost, "127.0.0.1", "LAN-only RTMP must bind to the resolved private interface");
|
|
await assert.rejects(
|
|
() => service.create({ id: "device-2", pairing_host: "http://localhost:3000" }, {}),
|
|
(error) => error.code === "STREAM_TEST_ACTIVE"
|
|
);
|
|
await service.sweep();
|
|
const status = service.publicStatus();
|
|
assert.equal(status.session.state, "receiving");
|
|
assert.equal(status.session.metrics.receiver.path_ready, true);
|
|
assert(Object.hasOwn(status.receiver.process, "log_tail"), "admin diagnostics must include a bounded sanitized MediaMTX log tail");
|
|
assert(!JSON.stringify(status).includes("127.0.0.1:"), "browser status must not expose loopback MediaMTX origins");
|
|
service.updateObs("device-1", {
|
|
session_id: created.id,
|
|
bitrate_kbps: 6000,
|
|
dropped_frames: 2,
|
|
total_frames: 10000,
|
|
congestion: 0
|
|
});
|
|
service.updateCaptionStatus("device-1", {
|
|
session_id: created.id,
|
|
state: "ready",
|
|
detail: "Speech recognition is ready and listening."
|
|
});
|
|
assert.equal(service.publicStatus().session.metrics.captions.state, "ready");
|
|
const captionId = "4adf31ce-a9de-4e91-9797-0e8c25b6f133";
|
|
service.addCaption("device-1", {
|
|
session_id: created.id,
|
|
caption_id: captionId,
|
|
revision: 1,
|
|
final: false,
|
|
text: "Private",
|
|
start_seconds: 1,
|
|
end_seconds: 4.5,
|
|
delay_ms: 120
|
|
});
|
|
service.addCaption("device-1", {
|
|
session_id: created.id,
|
|
caption_id: captionId,
|
|
revision: 2,
|
|
final: false,
|
|
text: "Private caption",
|
|
stable_text: "Private",
|
|
uncertain_text: "caption",
|
|
start_seconds: 1,
|
|
end_seconds: 5.5,
|
|
delay_ms: 125
|
|
});
|
|
service.addCaption("device-1", {
|
|
session_id: created.id,
|
|
caption_id: captionId,
|
|
revision: 1,
|
|
final: false,
|
|
text: "Stale revision",
|
|
start_seconds: 1,
|
|
end_seconds: 20,
|
|
delay_ms: 125
|
|
});
|
|
service.addCaption("device-1", {
|
|
session_id: created.id,
|
|
caption_id: captionId,
|
|
revision: 3,
|
|
final: true,
|
|
text: "Private caption finalized",
|
|
stable_text: "Private caption finalized",
|
|
uncertain_text: "",
|
|
start_seconds: 1,
|
|
end_seconds: 8,
|
|
delay_ms: 130
|
|
});
|
|
assert.throws(
|
|
() => service.addCaption("device-2", { session_id: created.id, text: "Wrong device" }),
|
|
/not active for this Companion/
|
|
);
|
|
service.reportPlayer({ session_id: created.id, latency_seconds: 1.2, buffer_seconds: 1.5, stalls: 0, errors: 0 });
|
|
assert.match(service.captionFile(created.id), /Private caption finalized/);
|
|
assert.deepStrictEqual(service.publicStatus().session.caption_cues.map((cue) => cue.text), ["Private caption finalized"]);
|
|
assert.equal(service.publicStatus().session.caption_cues[0].stable_text, "Private caption finalized");
|
|
assert(Number.isFinite(service.publicStatus().session.caption_cues[0].expires_at));
|
|
assert.equal(service.publicStatus().session.metrics.captions.revisions, 3, "stale progressive revisions must not inflate delivery metrics");
|
|
assert.equal(service.publicStatus().session.metrics.captions.delivered, 1);
|
|
service.addCaption("device-1", {
|
|
session_id: created.id,
|
|
caption_id: "56fa22c9-1f39-4e17-a217-2dfa38793547",
|
|
revision: 1,
|
|
final: false,
|
|
text: "Next utterance",
|
|
start_seconds: 6,
|
|
end_seconds: 10,
|
|
delay_ms: 110
|
|
});
|
|
const progressiveCues = service.publicStatus().session.caption_cues;
|
|
assert.deepStrictEqual(progressiveCues.map((cue) => cue.text), ["Private caption finalized", "Next utterance"]);
|
|
assert.equal(progressiveCues[0].end, progressiveCues[1].start, "a new utterance must retire the previous caption instead of stacking");
|
|
service.updateCaptionStatus("device-1", {
|
|
session_id: created.id,
|
|
state: "disabled",
|
|
detail: "Captions are turned off in Lumi Companion."
|
|
});
|
|
assert.equal(service.publicStatus().session.metrics.captions.state, "disabled");
|
|
assert.deepStrictEqual(service.publicStatus().session.caption_cues, [], "turning captions off must immediately clear private-player cues");
|
|
const endedSession = service.active;
|
|
await service.stop({ session_id: created.id, reason: "Verification complete." });
|
|
assert.equal(runtime.cleared, 1);
|
|
assert.equal(endedSession.username, null);
|
|
assert.equal(endedSession.password, null);
|
|
assert.equal(sent.at(-1).type, "stream_test_ended");
|
|
const external = await service.checkExternalAccess("lumi.example.test");
|
|
assert.equal(external.rtmp.handshake_bytes, 3073);
|
|
assert.deepStrictEqual(runtime.listener, { bindHost: "0.0.0.0", ingestPort: 19350 });
|
|
await assert.rejects(
|
|
() => service.create({ id: "device-1", pairing_host: "http://localhost:3000" }, {}),
|
|
(error) => error.code === "STREAM_TEST_RATE_LIMIT"
|
|
);
|
|
|
|
const expiring = await service.create(
|
|
{ id: "device-2", pairing_host: "http://localhost:3000" },
|
|
{}
|
|
);
|
|
service.active.expiresAt = Date.now() - 1;
|
|
await service.sweep();
|
|
assert.equal(service.active, null, "session expiry must revoke the MediaMTX path and end the session");
|
|
|
|
const inactive = await service.create(
|
|
{ id: "device-3", pairing_host: "http://localhost:3000" },
|
|
{}
|
|
);
|
|
service.active.lastInboundBytes = runtime.inboundBytes;
|
|
service.active.lastMediaAt = Date.now() - INACTIVITY_MS - 1;
|
|
await service.sweep();
|
|
assert.equal(service.active, null, "stalled inbound bytes must trigger the inactivity recovery path");
|
|
|
|
const failed = await service.create(
|
|
{ id: "device-4", pairing_host: "http://localhost:3000" },
|
|
{}
|
|
);
|
|
runtime.emit("failed", new Error("fixture process exited"));
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
assert.equal(service.active, null, "an unexpected MediaMTX exit must end the session and request OBS recovery");
|
|
assert([created.id, expiring.id, inactive.id, failed.id].every(Boolean));
|
|
} finally {
|
|
await service.close();
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-stream-testing-"));
|
|
try {
|
|
assert.equal(validateHostname("stream.example.com"), "stream.example.com");
|
|
assert.equal(validateHostname("https://stream.example.com"), "");
|
|
assert.equal(validateHostname("[::1]"), "[::1]");
|
|
assert(isPrivateAddress("127.0.0.1") && isPrivateAddress("192.168.1.20") && !isPrivateAddress("8.8.8.8"));
|
|
assert.deepStrictEqual(
|
|
validateReverseProxyIngestSettings({ publicPort: "1936", listenerPort: "19350" }),
|
|
{ publicPort: DEFAULT_PUBLIC_RTMPS_PORT, listenerPort: DEFAULT_LOCAL_RTMP_PORT }
|
|
);
|
|
assert.throws(
|
|
() => validateReverseProxyIngestSettings({ publicPort: "0", listenerPort: "19350" }),
|
|
/1 to 65535/
|
|
);
|
|
assert.throws(
|
|
() => validateReverseProxyIngestSettings({ publicPort: "1936.5", listenerPort: "19350" }),
|
|
/whole number/
|
|
);
|
|
assert.deepStrictEqual(sourceFor({ width: 1920, height: 1080, fps: 60 }).variants.map((item) => item.name), ["source"]);
|
|
assert.match(rewriteManifest(
|
|
"#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\nsegment0.mp4\n",
|
|
"/admin/stream-testing/media/test/",
|
|
"lumi-test/test"
|
|
), /URI="\/admin\/stream-testing\/media\/test\/init\.mp4"[\s\S]*\/admin\/stream-testing\/media\/test\/segment0\.mp4/);
|
|
assert.throws(() => rewriteManifest("../secret", "/proxy/", "lumi-test/test"), /unsafe playlist URI/);
|
|
assert.throws(() => rewriteManifest("https://foreign.invalid/segment.mp4", "/proxy/", "lumi-test/test"), /foreign playlist URI/);
|
|
assert.deepStrictEqual(
|
|
parseMetrics('paths_inbound_bytes{name="lumi-test/id"} 42\nunapproved_metric 9000\n', "lumi-test/id"),
|
|
{ paths_inbound_bytes: 42 }
|
|
);
|
|
await assert.rejects(
|
|
() => readResponseText(new Response("x".repeat(32), { headers: { "Content-Length": "32" } }), 16),
|
|
/oversized diagnostic response/
|
|
);
|
|
const config = renderConfig({
|
|
apiPort: 10001,
|
|
metricsPort: 10002,
|
|
hlsPort: 10003,
|
|
session: { path: "lumi-test/id", username: "user", password: "pass", transport: "rtmp", ingestPort: 19350 }
|
|
});
|
|
assert(!config.includes('\n pass: "pass"') && config.includes("sha256:"));
|
|
assert(!config.includes("ffmpeg"));
|
|
assert.match(config, /record: false/);
|
|
assert.match(config, /maxReaders: 4/);
|
|
assert.match(config, /paths:\n "lumi-test\/id":/);
|
|
assert(!/action: publish[\s\S]*path: all/.test(config));
|
|
|
|
const configuredPorts = { publicPort: 21936, listenerPort: 29350 };
|
|
const secure = await resolveIngestConfiguration(
|
|
{ pairing_host: "https://stream.example.test" },
|
|
{ settings: configuredPorts }
|
|
);
|
|
assert.deepStrictEqual(secure, {
|
|
host: "stream.example.test",
|
|
transport: "rtmps",
|
|
bindHost: "0.0.0.0",
|
|
ingestPort: 29350,
|
|
publicPort: 21936
|
|
});
|
|
const local = await resolveIngestConfiguration(
|
|
{ pairing_host: "http://localhost:3000" },
|
|
{ settings: configuredPorts }
|
|
);
|
|
assert.equal(local.transport, "rtmp");
|
|
assert.equal(local.host, "localhost");
|
|
assert.equal(local.ingestPort, 29350);
|
|
assert.equal(local.publicPort, 29350, "loopback development must bypass the public RTMPS port");
|
|
const probed = await probeExternalRtmps({
|
|
hostname: "lumi.example.test",
|
|
publicPort: 1936,
|
|
listenerPort: 19350,
|
|
tlsConnect: createSuccessfulTlsSocket
|
|
});
|
|
assert.equal(probed.tls.protocol, "TLSv1.3");
|
|
assert.equal(probed.rtmp.version, 3);
|
|
|
|
for (const type of ["stream_test_create", "stream_test_obs_metrics", "stream_test_caption_status", "stream_test_caption", "stream_test_stop"]) {
|
|
const parsed = protocol.parseEnvelope(Buffer.from(JSON.stringify(protocol.envelope(type, {}, null))));
|
|
assert.strictEqual(parsed.type, type);
|
|
}
|
|
|
|
await verifyArtifactManager(tempRoot);
|
|
await verifyManagedRuntime(tempRoot);
|
|
await verifyStreamService();
|
|
|
|
const server = fs.readFileSync(path.join(root, "src/web/server.js"), "utf8");
|
|
const service = fs.readFileSync(path.join(root, "src/services/stream-testing.js"), "utf8");
|
|
const runtime = fs.readFileSync(path.join(root, "src/services/mediamtx-runtime.js"), "utf8");
|
|
const gateway = fs.readFileSync(path.join(root, "plugins/lumi_transcription/backend/companion/gateway.js"), "utf8");
|
|
const nativeBridge = fs.readFileSync(path.join(root, "companion/native/obs-bridge/src/plugin.cpp"), "utf8");
|
|
const companion = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/CompanionRuntime.cs"), "utf8");
|
|
const companionSettings = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/CompanionSettingsStore.cs"), "utf8");
|
|
const companionWindow = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml"), "utf8");
|
|
const companionWindowCode = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml.cs"), "utf8");
|
|
const companionStyles = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/App.axaml"), "utf8");
|
|
const ingestSettings = fs.readFileSync(path.join(root, "src/services/stream-test-ingest-settings.js"), "utf8");
|
|
const transcriptionContribution = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/TranscriptionPluginContribution.cs"), "utf8");
|
|
const webUi = fs.readFileSync(path.join(root, "src/web/views/admin-stream-testing.ejs"), "utf8");
|
|
const webPlayer = fs.readFileSync(path.join(root, "src/web/public/stream-testing.js"), "utf8");
|
|
const webPlayerCss = fs.readFileSync(path.join(root, "src/web/public/stream-testing.css"), "utf8");
|
|
const packageJson = fs.readFileSync(path.join(root, "package.json"), "utf8");
|
|
assert.match(server, /admin\/stream-testing\/runtime\/install/);
|
|
assert.match(server, /admin\/stream-testing\/media\/:id\/\*/);
|
|
assert.match(server, /admin\/stream-testing\/media\/:id\/\*[\s\S]{0,500}requireRole\("admin"\)/);
|
|
assert.match(server, /streamTestingService\.proxyMedia/);
|
|
assert.match(service, /INACTIVITY_MS/);
|
|
assert.match(service, /caption_delay/);
|
|
assert.match(service, /updateCaptionStatus/);
|
|
assert.match(service, /localDevelopment[\s\S]*transport: "rtmp"/);
|
|
assert.match(service, /transport: "rtmps"/);
|
|
assert.match(service, /probeExternalRtmps/);
|
|
assert.match(service, /servername: bareHost/);
|
|
assert.match(runtime, /rtmpEncryption: \\"no\\"/);
|
|
assert.match(runtime, /bindHost \|\| "0\.0\.0\.0"/);
|
|
assert.match(ingestSettings, /DEFAULT_PUBLIC_RTMPS_PORT = 1936/);
|
|
assert.match(ingestSettings, /DEFAULT_LOCAL_RTMP_PORT = 19350/);
|
|
assert.match(server, /admin\/stream-testing\/reverse-proxy\/check/);
|
|
assert.doesNotMatch(server, /acme-challenge|stream-test-dns|stream-test-certificates/);
|
|
assert(!fs.existsSync(path.join(root, "src/services/stream-test-certificates.js")));
|
|
assert(!fs.existsSync(path.join(root, "src/services/stream-test-dns.js")));
|
|
assert.doesNotMatch(packageJson, /acme-client/);
|
|
assert.match(webUi, /Reverse-proxy ingest/);
|
|
assert.match(webUi, /Check external access/);
|
|
assert.match(webUi, /<details class="lumi-expandable-settings">/);
|
|
assert.doesNotMatch(webUi, /certificate automation|Domeneshop/);
|
|
assert(!/FFMPEG|ffmpegArgs|h264_nvenc|libx264/.test(service));
|
|
assert(!/shell:\s*true/.test(runtime));
|
|
assert.match(gateway, /await service\.create/);
|
|
assert.match(gateway, /Companion disconnected; the private receiver was stopped/);
|
|
assert.match(nativeBridge, /stream_test_snapshot/);
|
|
assert.match(nativeBridge, /stream_test_restore/);
|
|
assert.match(nativeBridge, /obs_process_id/);
|
|
assert.match(nativeBridge, /obs_output_start\(output\)/);
|
|
assert(!nativeBridge.includes("obs_frontend_streaming_start();"));
|
|
assert(!nativeBridge.includes("obs_encoder_release(encoder);"));
|
|
assert.match(companion, /StreamTestRecoveryStore/);
|
|
assert.match(companion, /RestoreObsAfterStreamTestAsync/);
|
|
assert.match(companion, /StreamTestObsRestartRequired/);
|
|
assert.match(companion, /mode = "stream_test"/);
|
|
assert.match(companion, /RunReconnectLoopAsync/);
|
|
assert.match(companion, /background:\s*true/);
|
|
assert.match(companion, /Math\.Min\(60,\s*attempt/);
|
|
assert.match(companion, /TranscriptionEnabled && _settings\.Current\.StartWithObs/);
|
|
assert.match(companion, /SetTranscriptionEnabledAsync/);
|
|
assert.match(companion, /ReportStreamTestCaptionStatusAsync\([^\n]+"disabled"/);
|
|
assert.match(companionSettings, /bool TranscriptionEnabled = true/);
|
|
assert.match(companionWindow, /TranscriptionEnabledToggle/);
|
|
assert.match(companionWindow, /Generate and include captions/);
|
|
assert.match(companionWindow, /OpenStreamTestWebButton/);
|
|
assert.match(companionWindowCode, /OpenStreamTestingWebUi/);
|
|
assert.match(companionWindowCode, /IsVisible = false/);
|
|
assert.match(companionWindowCode, /SetPluginNavigationExpanded/);
|
|
assert.match(companionStyles, /Button\.navRoot/);
|
|
assert.doesNotMatch(companionStyles, /Expander\.pluginRoot/);
|
|
assert.match(transcriptionContribution, /Turn captions off/);
|
|
assert.match(transcriptionContribution, /Turn captions on/);
|
|
assert.match(companion, /StopStreamTestTranscriptionAsync/);
|
|
assert.match(companion, /_streamTestTranscriptionRunning \|\| \(State\.BenchmarkRunning/);
|
|
assert.match(companion, /caption_id = captionId/);
|
|
assert.match(companion, /final \? Math\.Clamp\(2\.5 \+ text\.Length \/ 18\.0, 4, 8\) : 3\.5/);
|
|
assert.match(companion, /display_seconds = displaySeconds/);
|
|
assert.doesNotMatch(companion, /_streamTestTranscriptionRunning && final &&/);
|
|
assert.match(companion, /LastObsProcessId/);
|
|
assert.match(companion, /Restart OBS before trying again/);
|
|
assert.match(webUi, /data-runtime-install/);
|
|
assert.match(webUi, /data-runtime-health/);
|
|
assert.match(webUi, /data-stream-player[^>]*autoplay[^>]*muted/);
|
|
assert.match(webUi, /data-stream-caption-overlay/);
|
|
assert.match(webUi, /layout-bottom[\s\S]*admin\/stream-testing\/hls\.js[\s\S]*stream-testing\.js/);
|
|
assert.match(webPlayer, /async function attemptAutoplay/);
|
|
assert.match(webPlayer, /track\.addCue\(textCue\)/);
|
|
assert.match(webPlayer, /cues\.at\(-1\)\?\.revision/);
|
|
assert.match(webPlayer, /captionCueState\.get\(id\)/);
|
|
assert.match(webPlayer, /textCue\.text = "\\u200B"/);
|
|
assert.match(webPlayer, /if \(desired\.has\(id\)\) continue/);
|
|
assert.match(webPlayer, /renderCaptionOverlay/);
|
|
assert.match(webPlayer, /captionTokens/);
|
|
assert.match(webPlayer, /captionMode !== "showing"/);
|
|
assert.match(webPlayer, /playerShell\.requestFullscreen/);
|
|
assert.match(webPlayerCss, /flex-wrap:\s*wrap/);
|
|
assert.match(webPlayerCss, /stream-caption-reveal/);
|
|
assert.match(webPlayerCss, /stream-test-caption-overlay\[hidden\]/);
|
|
assert.match(webPlayer, /Ready · listening for speech/);
|
|
assert.match(webPlayer, /player\.textTracks\?\..*"change"/);
|
|
assert.match(webPlayer, /captions\.src\s*=\s*`\$\{session\.captions_url\}/);
|
|
assert.doesNotMatch(webPlayer, /captions\.src\s*=.*Date\.now/);
|
|
assert(!webUi.includes("Run test pattern"));
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
const { streamTestingService } = require("../src/services/stream-testing");
|
|
await streamTestingService.close();
|
|
}
|
|
console.log("Managed MediaMTX Stream Testing verification passed.");
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|