// 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; });