Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
152 lines
12 KiB
JavaScript
152 lines
12 KiB
JavaScript
const { chromium } = require("playwright");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
require("./control-room-register.cjs");
|
|
const { fixtureChannel } = require("../src/components/control-room/fixtures.ts");
|
|
const web = path.resolve(__dirname, "..");
|
|
const origin = "http://audit.local";
|
|
const workspaceId = "50000000-0000-4000-8000-000000000001";
|
|
const mime = { ".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".html": "text/html", ".rsc": "text/x-component" };
|
|
const results = [];
|
|
const cors = { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Headers": "Authorization,Content-Type,Idempotency-Key", "Access-Control-Allow-Methods": "GET,POST,OPTIONS" };
|
|
const json = (route, body, status = 200) => route.fulfill({ status, headers: cors, json: body });
|
|
async function auditViewport(browser, viewport) {
|
|
const context = await browser.newContext({ viewport, isMobile: viewport.width < 1024, hasTouch: viewport.width < 1024 });
|
|
const page = await context.newPage();
|
|
const errors = []; page.on("pageerror", error => errors.push(error.message));
|
|
let channel = fixtureChannel(), mode = "live", seq = 0, socket;
|
|
const requests = [], keys = new Set();
|
|
const observation = version => ({ workspaceId, channelId: channel.channelId, workerId: channel.workerId, assignmentEpoch: channel.assignmentEpoch, state: "RUNNING", realizedVersion: version, updatedAt: new Date().toISOString(), reason: "Audit worker" });
|
|
channel.observed = observation(1);
|
|
const inject = (kind, data) => socket.send(JSON.stringify({ type: "state", workspaceId, channelId: channel.channelId, seq: ++seq, kind, data }));
|
|
await context.addInitScript(() => localStorage.setItem("tmbh.session", "audit-token"));
|
|
await page.routeWebSocket(/\/realtime$/, ws => {
|
|
socket = ws;
|
|
ws.onMessage(raw => {
|
|
const message = JSON.parse(String(raw));
|
|
if (message.type === "auth") { assert.equal(message.token, "audit-token"); assert.equal(message.workspaceId, workspaceId); ws.send(JSON.stringify({ type: "auth_ack", workspaceId })); }
|
|
if (message.type === "subscribe") {
|
|
ws.send(JSON.stringify({ type: "subscribe_ack", channelId: channel.channelId, seq }));
|
|
inject("desired", channel.desired); inject("observed", channel.observed);
|
|
}
|
|
});
|
|
});
|
|
await page.route("**/*", async route => {
|
|
const request = route.request(), url = new URL(request.url());
|
|
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors });
|
|
if (url.pathname === "/auth/me") return json(route, { user: { id: workspaceId, email: "operator@audit.local", displayName: "Operator" }, workspace: { id: workspaceId, name: "Audit studio", slug: "audit" }, role: "owner", permissions: ["director.take", "director.view", "director.lock_program", "director.override_auto", "director.change_layout", "media.view", "stream.view"], expiresAt: Date.now() + 3600000 });
|
|
if (url.pathname === "/media-control/status") {
|
|
if (mode === "offline") return route.abort();
|
|
if (mode === "forbidden") return json(route, { message: "Missing media.view" }, 403);
|
|
return json(route, { channels: mode === "empty" ? [] : [channel], workers: [] });
|
|
}
|
|
const action = /\/media-control\/channels\/[^/]+\/(pst-select|cut|take|lock|override)$/.exec(url.pathname)?.[1];
|
|
if (action && request.method() === "POST" || url.pathname.startsWith("/production/overlays/") && request.method() === "POST") {
|
|
const key = request.headers()["idempotency-key"]; assert.match(key, /^[\x20-\x7e]{1,128}$/); assert.match(key, /^[0-9a-f-]{36}$/); assert.ok(!keys.has(key)); keys.add(key);
|
|
const body = request.postDataJSON(); requests.push({ action: action ?? "overlay", body, key });
|
|
if (action === "pst-select") channel.desired.production.buses.preview = body.sceneId;
|
|
if (action === "cut" || action === "take") channel.desired.production.buses.program = channel.desired.production.buses.preview;
|
|
if (action === "lock") channel.desired.director.locked = body.enabled;
|
|
if (action === "override") channel.desired.director.override = body.mode ? { mode: body.mode, expiresAt: null } : null;
|
|
if (!action) channel.desired.production.overlays.find(item => url.pathname.endsWith(item.id)).revisionId = "60000000-0000-4000-8000-000000000001";
|
|
channel.desired.desiredVersion += 1;
|
|
await json(route, { desiredVersion: channel.desired.desiredVersion, commandId: key });
|
|
inject("desired", channel.desired); return;
|
|
}
|
|
if (url.pathname.includes("/preview/")) return json(route, { message: "No audit media" }, 404);
|
|
if (url.hostname !== "audit.local") return route.abort();
|
|
let file;
|
|
if (url.pathname.startsWith("/_next/")) file = path.join(web, ".next", url.pathname.slice(7));
|
|
else if (url.pathname.startsWith("/brand/")) file = path.join(web, "public", url.pathname);
|
|
else {
|
|
const name = url.pathname === "/" ? "index" : url.pathname.slice(1);
|
|
const rsc = request.headers().rsc === "1" || url.searchParams.has("_rsc");
|
|
file = path.join(web, ".next/server/app", `${name}.${rsc ? "rsc" : "html"}`);
|
|
}
|
|
if (!fs.existsSync(file)) return route.fulfill({ status: 404, body: "Not found" });
|
|
return route.fulfill({ contentType: mime[path.extname(file)] || "application/octet-stream", body: fs.readFileSync(file) });
|
|
});
|
|
const first = channel.desired.production.sources[0].id, second = channel.desired.production.sources[1].id;
|
|
const sceneB = channel.desired.production.scenes[1].id;
|
|
const tally = id => page.locator(`[data-source='${id}']`).getAttribute("data-tally");
|
|
const waitTally = (id, value) => page.waitForFunction(({ id, value }) => document.querySelector(`[data-source='${id}']`)?.getAttribute("data-tally") === value, { id, value });
|
|
const checkLayout = async stage => {
|
|
const layout = await page.evaluate(() => {
|
|
const root = document.querySelector(".control-room");
|
|
const undersized = Array.from(root.querySelectorAll("button, input, select, textarea, a[href]")).filter(el => { const r = el.getBoundingClientRect(); return r.width && r.height && (r.width < 43.5 || r.height < 43.5); }).map(el => ({ text: el.getAttribute("aria-label") || el.textContent.trim().slice(0, 50), width: el.getBoundingClientRect().width, height: el.getBoundingClientRect().height }));
|
|
return { width: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, rootWidth: root.clientWidth, rootScrollWidth: root.scrollWidth, undersized, liveDecodes: document.querySelectorAll("[data-live-decode]").length };
|
|
});
|
|
assert.ok(layout.scrollWidth <= layout.width, `${stage}: document overflow`);
|
|
assert.ok(layout.rootScrollWidth <= layout.rootWidth, `${stage}: control room overflow`);
|
|
assert.deepEqual(layout.undersized, [], `${stage}: tap targets`);
|
|
assert.ok(layout.liveDecodes <= (viewport.width < 1024 ? 1 : 4));
|
|
results.push({ viewport, stage, ...layout });
|
|
};
|
|
await page.goto(`${origin}/control-room`, { waitUntil: "networkidle" });
|
|
await waitTally(first, "program"); await checkLayout("initial");
|
|
await page.getByRole("combobox", { name: "PST scene", exact: true }).selectOption(sceneB);
|
|
await page.getByRole("status").filter({ hasText: "Request accepted" }).waitFor();
|
|
assert.equal(await tally(first), "program"); assert.equal(await tally(second), "none");
|
|
inject("observed", observation(channel.desired.desiredVersion));
|
|
await waitTally(second, "preview");
|
|
await page.getByRole("button", { name: "CUT", exact: true }).click();
|
|
await page.waitForFunction(() => document.querySelector("[role='status']")?.textContent.includes("version 3"));
|
|
assert.equal(await tally(first), "program"); assert.equal(await tally(second), "preview");
|
|
const injectedAt = Date.now();
|
|
inject("observed", observation(3)); await waitTally(second, "program"); assert.equal(await tally(first), "none");
|
|
const observedToTallyMs = Date.now() - injectedAt;
|
|
assert.ok(observedToTallyMs < 300, "observed frame to tally must take under 300 ms");
|
|
await page.getByRole("button", { name: "TAKE", exact: true }).click();
|
|
await page.getByRole("button", { name: "Lock program", exact: true }).click();
|
|
await page.getByRole("button", { name: "Unlock program", exact: true }).waitFor();
|
|
await page.getByRole("combobox", { name: "Operator override" }).selectOption("five_minutes");
|
|
await page.getByRole("combobox", { name: "Operator override" }).selectOption("auto");
|
|
for (const template of ["lower_third", "chyron", "logo"]) {
|
|
const slot = channel.desired.production.overlays.find(item => item.kind === template);
|
|
await page.getByRole("combobox", { name: "Overlay slot" }).selectOption(slot.id);
|
|
if (template === "logo") await page.getByRole("textbox", { name: "Logo SVG" }).fill('<svg width="64" height="64"><rect width="64" height="64" fill="#FF1610"/></svg>');
|
|
else await page.getByRole("textbox", { name: template === "chyron" ? /Chyron text/ : /Title/ }).fill("Audit title");
|
|
await page.getByRole("button", { name: "Render overlay" }).click();
|
|
await page.getByRole("status").filter({ hasText: "Render accepted" }).waitFor();
|
|
assert.equal(requests.at(-1).body.templateId, template);
|
|
}
|
|
for (const tier of ["2x1", "2x2", "3x3", "4x4", "custom"]) {
|
|
await page.getByRole("combobox", { name: "Multiview layout" }).selectOption(tier);
|
|
await checkLayout(tier);
|
|
}
|
|
if (viewport.width >= 1024) {
|
|
await page.getByRole("combobox", { name: "Multiview layout" }).selectOption("4x4");
|
|
await page.getByRole("button", { name: "Focus Sample source 1", exact: true }).click();
|
|
const upgrades = page.getByRole("button", { name: "Upgrade live", exact: true });
|
|
await upgrades.nth(0).click(); await upgrades.nth(0).click();
|
|
assert.ok(await upgrades.nth(0).isDisabled(), "5th decode must be refused");
|
|
}
|
|
const resyncBefore = requests.length;
|
|
socket.send(JSON.stringify({ type: "RESYNC_REQUIRED", channelId: channel.channelId }));
|
|
await page.waitForTimeout(100); assert.equal(requests.length, resyncBefore);
|
|
mode = "empty"; await page.getByRole("button", { name: "Reconnect", exact: true }).click();
|
|
await page.getByRole("heading", { name: "No production scenes configured" }).waitFor();
|
|
assert.equal(await page.locator("[data-tally='program']").count(), 0); assert.equal(await page.getByText("FIXTURE · sample data", { exact: true }).count(), 0);
|
|
await checkLayout("empty");
|
|
mode = "forbidden"; await page.getByRole("button", { name: "Reconnect", exact: true }).click();
|
|
await page.getByRole("status").filter({ hasText: "Missing media.view" }).waitFor();
|
|
assert.equal(await page.getByText("FIXTURE · sample data", { exact: true }).count(), 0);
|
|
mode = "offline"; await page.getByRole("button", { name: "Reconnect", exact: true }).click();
|
|
await page.getByText("FIXTURE · sample data", { exact: true }).waitFor();
|
|
assert.equal(await page.locator("[data-tally='program']").count(), 0);
|
|
const before = requests.length;
|
|
await page.getByRole("button", { name: "CUT", exact: true }).click();
|
|
assert.equal(requests.length, before); assert.equal(await page.locator("[data-tally='program']").count(), 0);
|
|
await checkLayout("offline-fixture");
|
|
assert.deepEqual(errors, [], "browser runtime errors");
|
|
results.push({ viewport, flow: "PST→observed preview→CUT→observed program", actions: requests.map(item => item.action), uniqueKeys: keys.size, observedToTallyMs, runtimeErrors: errors });
|
|
await context.close();
|
|
}
|
|
async function run() {
|
|
const browser = await chromium.launch({ headless: true });
|
|
try { for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) await auditViewport(browser, viewport); }
|
|
finally { await browser.close(); }
|
|
console.log(JSON.stringify(results, null, 2));
|
|
}
|
|
run().catch(error => { console.error(error); process.exitCode = 1; });
|