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/.
121 lines
8.4 KiB
JavaScript
121 lines
8.4 KiB
JavaScript
// 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; });
|