import { readFile, realpath, stat } from "node:fs/promises"; import { createServer, type ServerResponse } from "node:http"; import { extname, isAbsolute, relative, resolve, sep } from "node:path"; import { expect, test as base, type Page } from "@playwright/test"; const LOOPBACK_HOST = "127.0.0.1"; const contentTypes: Readonly> = { ".avif": "image/avif", ".br": "application/octet-stream", ".css": "text/css; charset=utf-8", ".gif": "image/gif", ".html": "text/html; charset=utf-8", ".ico": "image/x-icon", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8", ".mp3": "audio/mpeg", ".opus": "audio/ogg", ".png": "image/png", ".svg": "image/svg+xml; charset=utf-8", ".txt": "text/plain; charset=utf-8", ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2", ".xml": "application/xml; charset=utf-8", }; const sendText = ( response: ServerResponse, status: number, body: string, ): void => { response.writeHead(status, { "Cache-Control": "no-store", "Content-Length": Buffer.byteLength(body), "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff", }); response.end(body); }; const isInside = (root: string, candidate: string): boolean => { const pathFromRoot = relative(root, candidate); return ( pathFromRoot === "" || (!isAbsolute(pathFromRoot) && pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`)) ); }; const decodedRequestPath = (requestTarget: string): string | null => { const rawPath = requestTarget.split(/[?#]/u, 1)[0] ?? "/"; let decoded: string; try { decoded = decodeURIComponent(rawPath); } catch { return null; } if ( !decoded.startsWith("/") || decoded.includes("\0") || decoded.includes("\\") ) { return null; } const segments = decoded.split("/"); if (segments.some((segment) => segment === "." || segment === "..")) { return null; } return decoded === "/" || decoded.endsWith("/") ? `${decoded}index.html` : decoded; }; export interface DistPreviewServer { readonly origin: string; close(): Promise; } export async function expectInsetFocusIndicator( page: Page, selector: string, ): Promise { await expect .poll(() => page.locator(selector).evaluate((element) => { const rect = element.getBoundingClientRect(); const viewport = window.visualViewport; const viewportLeft = viewport?.offsetLeft ?? 0; const viewportTop = viewport?.offsetTop ?? 0; const viewportRight = viewportLeft + (viewport?.width ?? window.innerWidth); const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight); const style = getComputedStyle(element); return { focused: document.activeElement === element, intersectsViewport: rect.right > viewportLeft && rect.left < viewportRight && rect.bottom > viewportTop && rect.top < viewportBottom, insetIndicator: /\binset\b/iu.test(style.boxShadow), }; }), ) .toEqual({ focused: true, intersectsViewport: true, insetIndicator: true, }); } export async function revealNavigationLinks(page: Page): Promise { const summary = page.locator(".site-navigation summary"); if (!(await summary.isVisible())) return; await page.locator(".site-navigation details").evaluate((details) => { if (details instanceof HTMLDetailsElement) details.open = true; }); } export async function clickNavigationLink( page: Page, href: string, ): Promise { await revealNavigationLinks(page); await page.locator(`nav a[href="${href}"]`).click(); } export async function startDistServer( distRoot = resolve(process.cwd(), "dist"), ): Promise { const root = await realpath(distRoot); if (!(await stat(root)).isDirectory()) { throw new Error(`The preview output is not a directory: ${distRoot}`); } const server = createServer((request, response) => { void (async () => { if (request.method !== "GET" && request.method !== "HEAD") { response.setHeader("Allow", "GET, HEAD"); sendText(response, 405, "Method not allowed."); return; } const requestPath = decodedRequestPath(request.url ?? "/"); if (requestPath === null) { sendText(response, 403, "Forbidden."); return; } const candidate = resolve(root, `.${requestPath}`); if (!isInside(root, candidate)) { sendText(response, 403, "Forbidden."); return; } let filePath: string; try { filePath = await realpath(candidate); } catch { sendText(response, 404, "Not found."); return; } if (!isInside(root, filePath)) { sendText(response, 403, "Forbidden."); return; } const file = await stat(filePath); if (!file.isFile()) { sendText(response, 404, "Not found."); return; } const body = await readFile(filePath); response.writeHead(200, { "Cache-Control": "no-store", "Content-Length": body.byteLength, "Content-Type": contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream", "X-Content-Type-Options": "nosniff", }); response.end(request.method === "HEAD" ? undefined : body); })().catch(() => { if (!response.headersSent) { sendText(response, 500, "Internal server error."); } else { response.destroy(); } }); }); await new Promise((resolveListening, reject) => { const onError = (error: Error): void => reject(error); server.once("error", onError); server.listen({ host: LOOPBACK_HOST, port: 0, exclusive: true }, () => { server.off("error", onError); resolveListening(); }); }); const address = server.address(); if ( address === null || typeof address === "string" || address.address !== LOOPBACK_HOST ) { server.closeAllConnections(); await new Promise((resolveClosed) => server.close(() => resolveClosed()), ); throw new Error( "The preview server did not bind the owned loopback listener.", ); } let closed = false; return { origin: `http://${LOOPBACK_HOST}:${address.port}`, async close(): Promise { if (closed) return; closed = true; await new Promise((resolveClosed, reject) => { server.close((error) => error === undefined ? resolveClosed() : reject(error), ); server.closeIdleConnections(); server.closeAllConnections(); }); }, }; } export const test = base.extend<{}, { distUrl: string }>({ distUrl: [ async ({}, use) => { const preview = await startDistServer(); try { await use(preview.origin); } finally { await preview.close(); } }, { scope: "worker" }, ], }); export { expect };