Files
Portfolio/tests/helpers/serve-dist.ts

257 lines
7.0 KiB
TypeScript

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<Record<string, string>> = {
".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",
".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<void>;
}
export async function expectInsetFocusIndicator(
page: Page,
selector: string,
): Promise<void> {
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<void> {
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<void> {
await revealNavigationLinks(page);
await page.locator(`nav a[href="${href}"]`).click();
}
export async function startDistServer(
distRoot = resolve(process.cwd(), "dist"),
): Promise<DistPreviewServer> {
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<void>((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<void>((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<void> {
if (closed) return;
closed = true;
await new Promise<void>((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 };