Tactical Media Broadcast Hub — initial release
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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
152
apps/web/scripts/control-room-audit.cjs
Normal file
152
apps/web/scripts/control-room-audit.cjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
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; });
|
||||
11
apps/web/scripts/control-room-register.cjs
Normal file
11
apps/web/scripts/control-room-register.cjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Existing TypeScript compiler only; node:test remains the test runner. No runtime dependency.
|
||||
const ts = require("typescript");
|
||||
const fs = require("node:fs");
|
||||
function compile(module, filename) {
|
||||
const result = ts.transpileModule(fs.readFileSync(filename, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true }, fileName: filename,
|
||||
});
|
||||
module._compile(result.outputText, filename);
|
||||
}
|
||||
require.extensions[".ts"] = compile;
|
||||
require.extensions[".tsx"] = compile;
|
||||
85
apps/web/scripts/manual-shots.mjs
Normal file
85
apps/web/scripts/manual-shots.mjs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const BASE = "http://localhost:3111";
|
||||
const OUT = "runs/manual/shots";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 });
|
||||
const page = await ctx.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e).slice(0, 150)));
|
||||
|
||||
async function shot(name) {
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/${name}.png` });
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
async function safeClick(text, fallback) {
|
||||
try { await page.getByRole("button", { name: text, timeout: 5000 }).click(); }
|
||||
catch { try { await page.getByText(text, { timeout: 3000 }).first().click(); }
|
||||
catch { if (fallback) fallback(); else console.log(` (skip: ${text})`); } }
|
||||
}
|
||||
|
||||
async function safeType(selector, text) {
|
||||
try { const el = page.locator(selector).first(); await el.click({ timeout: 5000 }); await el.fill(text); }
|
||||
catch { console.log(` (skip type: ${selector})`); }
|
||||
}
|
||||
|
||||
// ONBOARDING
|
||||
await page.goto(`${BASE}/onboard`); await page.waitForTimeout(1500);
|
||||
// redirect to /onboarding
|
||||
await page.goto(`${BASE}/onboarding`); await page.waitForTimeout(1500);
|
||||
await shot("onboard-01-start");
|
||||
safeType("input", "Global News Desk");
|
||||
await shot("onboard-02-named");
|
||||
safeClick("Continue");
|
||||
await shot("onboard-03-invite");
|
||||
safeType("textarea", "sarah@globalnews.io, marcus@globalnews.io");
|
||||
await shot("onboard-04-emails-filled");
|
||||
safeClick("Continue");
|
||||
await shot("onboard-05-connect");
|
||||
|
||||
// STREAMS
|
||||
await page.goto(`${BASE}/streams`); await page.waitForTimeout(1000);
|
||||
await shot("streams-01-overview");
|
||||
await page.keyboard.press("j"); await page.keyboard.press("j"); await page.keyboard.press("j");
|
||||
await shot("streams-02-item-selected");
|
||||
await page.keyboard.press("r");
|
||||
await shot("streams-03-repost-toast");
|
||||
await page.keyboard.press("q");
|
||||
await shot("streams-04-quote-popover");
|
||||
try { const ta = page.locator("textarea").first(); if (await ta.isVisible({ timeout: 3000 })) { await ta.fill("This analysis is essential reading for anyone tracking the Fed's next move."); } } catch {}
|
||||
await shot("streams-05-quote-typed");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// COMPOSE
|
||||
await page.goto(`${BASE}/compose`); await page.waitForTimeout(1000);
|
||||
await shot("compose-01-blank");
|
||||
try { const ed = page.locator(".ProseMirror").first(); await ed.click({ timeout: 5000 }); await page.keyboard.type("Breaking: Central bank signals first rate cut of the year as inflation cools to 2.1%. Full analysis at globalnews.io/fed-rates #markets #economy"); } catch { console.log(" (compose editor not found)"); }
|
||||
await shot("compose-02-typed");
|
||||
|
||||
// CONNECTIONS
|
||||
await page.goto(`${BASE}/connections`); await page.waitForTimeout(800);
|
||||
await shot("connections-01-overview");
|
||||
|
||||
// TEAM
|
||||
await page.goto(`${BASE}/team`); await page.waitForTimeout(800);
|
||||
await shot("team-01-overview");
|
||||
|
||||
// INBOX
|
||||
await page.goto(`${BASE}/inbox`); await page.waitForTimeout(800);
|
||||
await shot("inbox-01-overview");
|
||||
|
||||
// AUDIT
|
||||
await page.goto(`${BASE}/audit`); await page.waitForTimeout(800);
|
||||
await shot("audit-01-overview");
|
||||
try { await page.locator("tr").filter({ hasText: "credential" }).first().click({ timeout: 5000 }); } catch {}
|
||||
await shot("audit-02-diff-open");
|
||||
|
||||
await browser.close();
|
||||
console.log(`\nShots: ${OUT}/`);
|
||||
console.log(`Errors: ${errors.length}`);
|
||||
for (const e of errors.slice(0, 5)) console.log(` ${e}`);
|
||||
58
apps/web/scripts/mobile-phase1-audit.cjs
Normal file
58
apps/web/scripts/mobile-phase1-audit.cjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const { chromium } = require("playwright");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const origin = "http://audit.local";
|
||||
const mime = { ".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".html": "text/html", ".rsc": "text/x-component", ".json": "application/json" };
|
||||
|
||||
const routes = ["/streams", "/compose", "/inbox", "/approvals", "/connections", "/team", "/video", "/audit", "/onboarding", "/analytics", "/calendar", "/settings", "/more", "/search"];
|
||||
|
||||
async function run() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
|
||||
await page.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (url.hostname !== "audit.local") return route.abort();
|
||||
let file;
|
||||
if (url.pathname.startsWith("/_next/")) file = path.join(process.cwd(), ".next", url.pathname.slice(7));
|
||||
else if (url.pathname.startsWith("/brand/")) file = path.join(process.cwd(), "public", url.pathname);
|
||||
else {
|
||||
const name = url.pathname === "/" ? "index" : url.pathname.slice(1);
|
||||
const isRsc = request.headers().rsc === "1" || url.searchParams.has("_rsc");
|
||||
file = path.join(process.cwd(), ".next/server/app", `${name}.${isRsc ? "rsc" : "html"}`);
|
||||
}
|
||||
if (!fs.existsSync(file)) return route.fulfill({ status: 404, body: "Not found" });
|
||||
return route.fulfill({ status: 200, contentType: mime[path.extname(file)] || "application/octet-stream", body: fs.readFileSync(file) });
|
||||
});
|
||||
const results = [];
|
||||
for (const route of routes) {
|
||||
await page.goto(`${origin}${route}`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(200);
|
||||
const result = await page.evaluate(() => {
|
||||
const width = document.documentElement.clientWidth;
|
||||
const all = Array.from(document.querySelectorAll("button, a[href], input, select, textarea, [role='tab']"));
|
||||
const undersized = all.filter((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && rect.left < innerWidth && rect.right > 0 && rect.top < innerHeight && rect.bottom > 0 && style.visibility !== "hidden" && style.display !== "none" && (rect.width < 43.5 || rect.height < 43.5);
|
||||
}).map((el) => ({ tag: el.tagName, text: (el.getAttribute("aria-label") || el.textContent || "").trim().slice(0, 40), width: Math.round(el.getBoundingClientRect().width), height: Math.round(el.getBoundingClientRect().height) }));
|
||||
return { title: document.title, width, scrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth, nav: !!document.querySelector("nav[aria-label='Primary navigation']"), undersized };
|
||||
});
|
||||
results.push({ route, ...result });
|
||||
}
|
||||
await page.goto(`${origin}/more`, { waitUntil: "networkidle" });
|
||||
const scroller = page.locator("[data-mobile-main]");
|
||||
await scroller.evaluate((el) => { el.scrollTop = 300; });
|
||||
const before = await scroller.evaluate((el) => el.scrollTop);
|
||||
await page.getByRole("navigation", { name: "Primary navigation" }).getByRole("link", { name: "Home" }).click();
|
||||
await page.waitForURL(`${origin}/streams`);
|
||||
await page.getByRole("navigation", { name: "Primary navigation" }).getByRole("link", { name: "More" }).click();
|
||||
await page.waitForURL(`${origin}/more`);
|
||||
await page.waitForTimeout(100);
|
||||
const after = await scroller.evaluate((el) => el.scrollTop);
|
||||
console.log(JSON.stringify({ routes: results, scrollPreservation: { before, after } }, null, 2));
|
||||
await browser.close();
|
||||
if (results.some((r) => r.scrollWidth > r.width || r.bodyScrollWidth > r.width || r.undersized.length > 0 || !r.nav) || before < 1 || Math.abs(before - after) > 1) process.exitCode = 1;
|
||||
}
|
||||
run().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
121
apps/web/scripts/mobile-phase2-audit.cjs
Normal file
121
apps/web/scripts/mobile-phase2-audit.cjs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// The repo's Node 18 runtime can run this Chromium audit; Playwright 1.63's
|
||||
// bootstrap refuses it before launch, so bypass only that version gate here.
|
||||
if (Number(process.versions.node.split(".")[0]) < 20) Object.defineProperty(process.versions, "node", { value: "20.0.0" });
|
||||
const { chromium } = require("playwright");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const origin = "http://audit.local";
|
||||
const routes = ["/streams", "/search", "/inbox", "/inbox/1", "/approvals", "/connections", "/compose", "/audit"];
|
||||
const mime = { ".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".html": "text/html", ".rsc": "text/x-component", ".json": "application/json" };
|
||||
|
||||
async function serveBuild(context) {
|
||||
await context.route(`${origin}/**`, async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
let file;
|
||||
if (url.pathname.startsWith("/_next/")) file = path.join(process.cwd(), ".next", url.pathname.slice(7));
|
||||
else if (url.pathname.startsWith("/brand/")) file = path.join(process.cwd(), "public", url.pathname);
|
||||
else {
|
||||
const name = url.pathname === "/" ? "index" : url.pathname.slice(1);
|
||||
const isRsc = request.headers().rsc === "1" || url.searchParams.has("_rsc");
|
||||
file = path.join(process.cwd(), ".next/server/app", `${name}.${isRsc ? "rsc" : "html"}`);
|
||||
}
|
||||
if (!fs.existsSync(file)) return route.fulfill({ status: 404, body: "Not found" });
|
||||
return route.fulfill({ status: 200, contentType: mime[path.extname(file)] || "application/octet-stream", body: fs.readFileSync(file) });
|
||||
});
|
||||
}
|
||||
|
||||
async function inspect(page) {
|
||||
return page.evaluate(() => {
|
||||
const width = document.documentElement.clientWidth;
|
||||
const undersized = Array.from(document.querySelectorAll("button, a[href], input, select, textarea, [role='tab']")).filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return rect.width > 0 && rect.height > 0 && rect.left < innerWidth && rect.right > 0 && rect.top < innerHeight && rect.bottom > 0 && style.visibility !== "hidden" && style.display !== "none" && style.opacity !== "0" && !element.classList.contains("sr-only") && (rect.width < 43.5 || rect.height < 43.5);
|
||||
}).map((element) => ({ text: (element.getAttribute("aria-label") || element.textContent || "").trim().slice(0, 36), width: Math.round(element.getBoundingClientRect().width), height: Math.round(element.getBoundingClientRect().height) }));
|
||||
return { width, scrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth, undersized };
|
||||
});
|
||||
}
|
||||
|
||||
async function routeAudit(browser) {
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
|
||||
await serveBuild(context);
|
||||
await context.route("http://localhost:3000/**", (route) => route.abort());
|
||||
const page = await context.newPage();
|
||||
const results = [];
|
||||
for (const route of routes) {
|
||||
const parent = route.startsWith("/inbox/") || route === "/approvals" ? "/inbox" : "/more";
|
||||
await page.goto(`${origin}${parent}`, { waitUntil: "networkidle" });
|
||||
await page.goto(`${origin}${route}`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(150);
|
||||
const sizes = await inspect(page);
|
||||
const back = page.getByRole("button", { name: "Go back" });
|
||||
if (await back.count()) await back.click();
|
||||
else await page.goBack({ waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(100);
|
||||
results.push({ route, ...sizes, backResolved: new URL(page.url()).pathname === parent });
|
||||
}
|
||||
await page.goto(`${origin}/streams`, { waitUntil: "networkidle" });
|
||||
await page.getByRole("tab", { name: /Mastodon/ }).click();
|
||||
await page.getByRole("button", { name: /Amplify @/ }).first().click();
|
||||
const mastodonLabels = await page.getByRole("dialog").innerText();
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await page.getByRole("tab", { name: /Telegram/ }).click();
|
||||
await page.getByRole("button", { name: /Amplify @/ }).first().click();
|
||||
const telegramLabels = await page.getByRole("dialog").innerText();
|
||||
await context.close();
|
||||
return { routes: results, capabilityLabels: { mastodonBoost: mastodonLabels.includes("Boost"), mastodonQuote: mastodonLabels.includes("Quote"), telegramForward: telegramLabels.includes("Forward"), telegramNoQuote: !telegramLabels.includes("Quote") } };
|
||||
}
|
||||
|
||||
async function approvalAudit(browser) {
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
await serveBuild(context);
|
||||
await context.addInitScript(() => localStorage.setItem("tmbh.session", "audit-token"));
|
||||
const drafts = ["approve", "reject"].map((id) => ({ id, workspaceId: "w", authorUserId: "contributor-1", state: "in_review", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), post: { text: `${id} this draft`, mediaAssetIds: [], visibility: "public", providerMetadata: { targetConnectionIds: [] } } }));
|
||||
const audit = [];
|
||||
const calls = [];
|
||||
await context.route("http://localhost:3000/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const headers = { "access-control-allow-origin": origin, "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "authorization,content-type", "content-type": "application/json" };
|
||||
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers, body: "" });
|
||||
let body;
|
||||
if (url.pathname === "/auth/me") body = { user: { id: "editor-1", email: "editor@example.test", displayName: "Editor" }, workspace: { id: "w", name: "Test", slug: "test" }, role: "Editor", permissions: ["approval.grant", "draft.view"], expiresAt: Date.now() + 60000 };
|
||||
else if (url.pathname === "/publishing/drafts" && request.method() === "GET") body = drafts.filter((draft) => draft.state === "in_review");
|
||||
else if (url.pathname.match(/^\/publishing\/drafts\/(approve|reject)\/(approve|reject)$/) && request.method() === "POST") {
|
||||
const [, id, decision] = url.pathname.match(/^\/publishing\/drafts\/(approve|reject)\/(approve|reject)$/);
|
||||
const draft = drafts.find((item) => item.id === id);
|
||||
draft.state = decision === "approve" ? "approved" : "rejected";
|
||||
calls.push({ id, decision, path: url.pathname });
|
||||
audit.unshift({ id: `audit-${audit.length}`, seq: String(audit.length + 1), at: new Date().toISOString(), actorUserId: "editor-1", actorEmail: "editor@example.test", actorRole: "Editor", action: `draft.${decision === "approve" ? "approved" : "rejected"}`, targetType: "draft", targetId: id, diff: null });
|
||||
body = draft;
|
||||
} else if (url.pathname === "/audit") body = { entries: audit, nextCursor: null, facets: { actions: [...new Set(audit.map((item) => item.action))], actors: [{ id: "editor-1", email: "editor@example.test" }] } };
|
||||
else if (url.pathname === "/connections") body = [];
|
||||
else body = [];
|
||||
return route.fulfill({ status: 200, headers, body: JSON.stringify(body) });
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${origin}/approvals`, { waitUntil: "networkidle" });
|
||||
await page.getByRole("button", { name: "Approve", exact: true }).first().click();
|
||||
await page.getByRole("button", { name: "Reject", exact: true }).first().click();
|
||||
await page.getByRole("button", { name: "Needs edits" }).click();
|
||||
await page.getByRole("button", { name: "Reject with reason" }).click();
|
||||
await page.goto(`${origin}/audit`, { waitUntil: "networkidle" });
|
||||
const text = await page.locator("[data-mobile-main]").innerText();
|
||||
await context.close();
|
||||
return { calls, auditActions: audit.map((item) => item.action), visibleInAudit: text.includes("draft.approved") && text.includes("draft.rejected") };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const routes = await routeAudit(browser);
|
||||
const approvals = await approvalAudit(browser);
|
||||
const result = { ...routes, approvals };
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (routes.routes.some((row) => row.scrollWidth > row.width || row.bodyScrollWidth > row.width || row.undersized.length > 0 || !row.backResolved) || Object.values(routes.capabilityLabels).some((value) => !value) || approvals.calls.length !== 2 || !approvals.visibleInAudit) process.exitCode = 1;
|
||||
} finally { await browser?.close(); }
|
||||
}
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
33
apps/web/scripts/mobile-swipe.test.cjs
Normal file
33
apps/web/scripts/mobile-swipe.test.cjs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const Module = require("node:module");
|
||||
const ts = require("typescript");
|
||||
function load(file) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
const loaded = new Module(file, module);
|
||||
loaded._compile(compiled, file);
|
||||
return loaded.exports;
|
||||
}
|
||||
const { swipeIntent, swipeResult } = load("src/lib/gestures.ts");
|
||||
const { nativeAmplifyActions } = load("src/lib/amplify.ts");
|
||||
const { PLATFORMS } = load("src/lib/platforms.ts");
|
||||
|
||||
test("vertical scroll never locks to horizontal reveal", () => {
|
||||
for (const [dx, dy] of [[0, 12], [9, 20], [-20, 60], [15, 50], [-40, 120]]) assert.equal(swipeIntent(dx, dy), "vertical");
|
||||
});
|
||||
test("small motion remains pending and deliberate horizontal motion reveals", () => {
|
||||
assert.equal(swipeIntent(6, 3), "pending");
|
||||
assert.equal(swipeIntent(-40, 10), "horizontal");
|
||||
assert.equal(swipeResult(-50, 0.2, "left"), "closed");
|
||||
assert.equal(swipeResult(-90, 0.2, "left"), "open");
|
||||
assert.equal(swipeResult(-180, 0.2, "left"), "full");
|
||||
});
|
||||
test("native amplify labels follow platform capabilities", () => {
|
||||
assert.deepEqual(nativeAmplifyActions(PLATFORMS.mastodon).map((item) => item.label), ["Boost", "Quote"]);
|
||||
assert.deepEqual(nativeAmplifyActions(PLATFORMS.telegram).map((item) => item.label), ["Forward"]);
|
||||
assert.deepEqual(nativeAmplifyActions(PLATFORMS.facebook).map((item) => item.label), ["Share link"]);
|
||||
assert.deepEqual(nativeAmplifyActions(PLATFORMS.instagram), []);
|
||||
assert.deepEqual(nativeAmplifyActions(PLATFORMS.limited), []);
|
||||
});
|
||||
75
apps/web/scripts/visual-qa.mjs
Normal file
75
apps/web/scripts/visual-qa.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const BASE = process.env.QA_BASE ?? "http://localhost:3111";
|
||||
const OUT = "runs/qa";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
async function shot(name, { fullPage = false } = {}) {
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage });
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
console.log("console errors will be collected at the end");
|
||||
const errors = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") errors.push(msg.text().slice(0, 200));
|
||||
});
|
||||
page.on("pageerror", (err) => errors.push(`PAGEERROR: ${String(err).slice(0, 200)}`));
|
||||
|
||||
await page.goto(`${BASE}/streams`);
|
||||
await shot("01-streams");
|
||||
|
||||
await page.keyboard.press("j");
|
||||
await page.keyboard.press("j");
|
||||
await page.keyboard.press("j");
|
||||
await shot("02-streams-selected");
|
||||
|
||||
await page.keyboard.press("r");
|
||||
await shot("03-streams-repost-toast");
|
||||
|
||||
await page.keyboard.press("q");
|
||||
await shot("04-streams-quote-popover");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.keyboard.press("j");
|
||||
await page.keyboard.press("k");
|
||||
await page.keyboard.press("k");
|
||||
await shot("05-streams-keyboard-moved");
|
||||
|
||||
await page.goto(`${BASE}/compose`);
|
||||
await page.locator(".ProseMirror").click();
|
||||
await page.keyboard.type("Shipping the visual QA pass — capability-gated amplify, keyboard-first streams.");
|
||||
await shot("06-compose-typed");
|
||||
|
||||
await page.goto(`${BASE}/connections`);
|
||||
await shot("07-connections");
|
||||
|
||||
await page.goto(`${BASE}/inbox`);
|
||||
await shot("08-inbox");
|
||||
|
||||
await page.goto(`${BASE}/team`);
|
||||
await shot("09-team");
|
||||
|
||||
await page.goto(`${BASE}/audit`);
|
||||
await shot("10-audit");
|
||||
await page.locator("tr", { hasText: "credential.rotated" }).first().click();
|
||||
await shot("11-audit-diff-open");
|
||||
|
||||
await page.goto(`${BASE}/onboarding`);
|
||||
await shot("12-onboarding-step1");
|
||||
await page.getByPlaceholder("Studio, Newsroom, Acme Social…").fill("Studio");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await shot("13-onboarding-step2");
|
||||
|
||||
await page.goto(`${BASE}/streams`);
|
||||
await page.keyboard.press("Meta+k");
|
||||
await shot("14-command-palette");
|
||||
|
||||
await browser.close();
|
||||
console.log(`\nconsole errors: ${errors.length}`);
|
||||
for (const e of errors.slice(0, 10)) console.log(` - ${e}`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue