259 lines
7.1 KiB
TypeScript
259 lines
7.1 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",
|
|
".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<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 };
|