417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import fs from "node:fs/promises";
|
|
import http, { type IncomingMessage, type ServerResponse } from "node:http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const TEST_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
const PROJECT_ROOT = path.resolve(TEST_DIR, "../../..");
|
|
const DIST_DIR = path.join(PROJECT_ROOT, "dist");
|
|
const PUBLIC_DIR = path.join(PROJECT_ROOT, "public");
|
|
const HTACCESS_PATH = path.join(PROJECT_ROOT, "public", ".htaccess");
|
|
const NGINX_CONFIG_PATH = path.join(PROJECT_ROOT, "nginx.coolify-frontend.conf");
|
|
|
|
const STATIC_DIRECTORIES = ["assets", "resources", "favicons", "icons", "img", "sounds", ".well-known"];
|
|
const STATIC_FILES = [
|
|
"index.html",
|
|
"manifest.json",
|
|
"manifest.webmanifest",
|
|
"favicon.ico",
|
|
"favicon_default.ico",
|
|
"pleno-favicon.ico",
|
|
"release-entry.json",
|
|
"release-manifest.json",
|
|
"registerSW.js",
|
|
"sw.js",
|
|
];
|
|
const RELEASE_FRONTEND_BASE_PATH = "/master/frontend";
|
|
const GENERATED_HTML_STATIC_PATHS = [
|
|
"./assets/favicons/favicon-96x96.png",
|
|
"./assets/favicons/favicon.svg",
|
|
"./assets/favicons/favicon.ico",
|
|
"./assets/favicons/apple-touch-icon.png",
|
|
"./assets/manifest.webmanifest",
|
|
];
|
|
|
|
const MIME_TYPES = new Map([
|
|
[".css", "text/css; charset=utf-8"],
|
|
[".html", "text/html; charset=utf-8"],
|
|
[".ico", "image/x-icon"],
|
|
[".js", "application/javascript; charset=utf-8"],
|
|
[".json", "application/json; charset=utf-8"],
|
|
[".mp3", "audio/mpeg"],
|
|
[".png", "image/png"],
|
|
[".svg", "image/svg+xml"],
|
|
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
|
[".woff2", "font/woff2"],
|
|
]);
|
|
|
|
let server: http.Server;
|
|
let serverBaseUrl = "";
|
|
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
function isStaticSingleFile(segment: string) {
|
|
return STATIC_FILES.includes(segment) || /^workbox-[^/]+\.js$/.test(segment);
|
|
}
|
|
|
|
function staticDirectoryPath(requestPath: string) {
|
|
const staticPattern = new RegExp(`(?:^|/)((?:${STATIC_DIRECTORIES.map(escapeRegExp).join("|")})/.+)$`);
|
|
return requestPath.match(staticPattern)?.[1] || "";
|
|
}
|
|
|
|
function staticSingleFilePath(requestPath: string) {
|
|
const segment = requestPath.split("/").pop() || "";
|
|
return isStaticSingleFile(segment) ? segment : "";
|
|
}
|
|
|
|
function escapeRegExp(value: string) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function safePath(rootDirectory: string, relativePath: string) {
|
|
const normalized = relativePath.replace(/^\/+/, "");
|
|
const resolved = path.resolve(rootDirectory, normalized);
|
|
const root = path.resolve(rootDirectory);
|
|
const lowerResolved = resolved.toLowerCase();
|
|
const lowerRoot = root.toLowerCase();
|
|
|
|
if (lowerResolved !== lowerRoot && !lowerResolved.startsWith(`${lowerRoot.toLowerCase()}${path.sep}`)) {
|
|
return null;
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
function safeDistPath(relativePath: string) {
|
|
return safePath(DIST_DIR, relativePath);
|
|
}
|
|
|
|
function safePublicPath(relativePath: string) {
|
|
return safePath(PUBLIC_DIR, relativePath);
|
|
}
|
|
|
|
function releaseRootStaticPath(staticPath: string) {
|
|
if (staticPath === RELEASE_FRONTEND_BASE_PATH) {
|
|
return "/";
|
|
}
|
|
if (staticPath.startsWith(`${RELEASE_FRONTEND_BASE_PATH}/`)) {
|
|
return staticPath.slice(RELEASE_FRONTEND_BASE_PATH.length);
|
|
}
|
|
if (staticPath.startsWith("./")) {
|
|
return `/${staticPath.slice(2)}`;
|
|
}
|
|
if (!staticPath.startsWith("/") && !/^[a-z][a-z\d+.-]*:/i.test(staticPath)) {
|
|
return `/${staticPath}`;
|
|
}
|
|
return staticPath;
|
|
}
|
|
|
|
async function fileExists(filePath: string) {
|
|
try {
|
|
const stat = await fs.stat(filePath);
|
|
return stat.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function requestPath(request: IncomingMessage) {
|
|
const pathname = new URL(request.url || "/", "http://localhost").pathname;
|
|
return decodeURIComponent(pathname).replace(/^\/+/, "");
|
|
}
|
|
|
|
async function serveFile(response: ServerResponse, filePath: string) {
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
const contentType = MIME_TYPES.get(extension) || "application/octet-stream";
|
|
response.writeHead(200, { "Content-Type": contentType });
|
|
response.end(await fs.readFile(filePath));
|
|
}
|
|
|
|
function serveNotFound(response: ServerResponse) {
|
|
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
response.end("Not found");
|
|
}
|
|
|
|
async function handleRequest(request: IncomingMessage, response: ServerResponse) {
|
|
const pathName = requestPath(request);
|
|
const exactPath = safeDistPath(pathName || "index.html");
|
|
|
|
if (exactPath && (await fileExists(exactPath))) {
|
|
await serveFile(response, exactPath);
|
|
return;
|
|
}
|
|
|
|
const mappedStaticPath = staticDirectoryPath(pathName) || staticSingleFilePath(pathName);
|
|
if (mappedStaticPath) {
|
|
const candidates = [
|
|
safeDistPath(mappedStaticPath),
|
|
safePublicPath(mappedStaticPath),
|
|
safeDistPath(path.join("dist", mappedStaticPath)),
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (candidate && (await fileExists(candidate))) {
|
|
await serveFile(response, candidate);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const prefixedSingleFile = staticSingleFilePath(pathName);
|
|
if (prefixedSingleFile) {
|
|
const singleFileCandidates = [
|
|
safePublicPath(prefixedSingleFile),
|
|
safeDistPath(path.join("dist", prefixedSingleFile)),
|
|
];
|
|
|
|
for (const candidate of singleFileCandidates) {
|
|
if (candidate && (await fileExists(candidate))) {
|
|
await serveFile(response, candidate);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
serveNotFound(response);
|
|
return;
|
|
}
|
|
|
|
if (/\.[^/]+$/.test(pathName)) {
|
|
serveNotFound(response);
|
|
return;
|
|
}
|
|
|
|
await serveFile(response, path.join(DIST_DIR, "index.html"));
|
|
}
|
|
|
|
async function distStaticPaths() {
|
|
const releaseEntry = JSON.parse(await fs.readFile(path.join(DIST_DIR, "release-entry.json"), "utf8"));
|
|
const releaseManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "release-manifest.json"), "utf8"));
|
|
const pwaManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "assets", "manifest.webmanifest"), "utf8"));
|
|
const indexHtml = await fs.readFile(path.join(DIST_DIR, "index.html"), "utf8");
|
|
const rootFiles = await fs.readdir(DIST_DIR);
|
|
const workboxFile = rootFiles.find((fileName) => /^workbox-[^/]+\.js$/.test(fileName));
|
|
const bundledStaticFiles = (
|
|
await Promise.all(
|
|
["assets", "resources"].map(async (directory) => {
|
|
try {
|
|
return (await fs.readdir(path.join(DIST_DIR, directory))).map((fileName) => `/${directory}/${fileName}`);
|
|
} catch {
|
|
return [];
|
|
}
|
|
})
|
|
)
|
|
).flat();
|
|
const cssFile = bundledStaticFiles.find((fileName) => fileName.endsWith(".css"));
|
|
const indexStaticPaths = Array.from(indexHtml.matchAll(/\b(?:href|src)="([^"]+)"/g))
|
|
.map((match) => match[1])
|
|
.filter((url) => (url.startsWith("/") && !url.startsWith("//")) || url.startsWith("./"));
|
|
const manifestIconPaths = (pwaManifest.icons || []).map((icon: { src?: string }) => {
|
|
if (!icon.src) {
|
|
return "";
|
|
}
|
|
|
|
return new URL(icon.src, "https://example.test/assets/manifest.webmanifest").pathname;
|
|
});
|
|
|
|
return Array.from(
|
|
new Set(
|
|
[
|
|
...indexStaticPaths.map(releaseRootStaticPath),
|
|
...GENERATED_HTML_STATIC_PATHS.map(releaseRootStaticPath),
|
|
...manifestIconPaths,
|
|
"/manifest.json",
|
|
"/manifest.webmanifest",
|
|
"/release-manifest.json",
|
|
"/favicon.ico",
|
|
"/favicons/favicon-96x96.png",
|
|
"/favicons/favicon.svg",
|
|
"/.well-known/assetlinks.json",
|
|
"/registerSW.js",
|
|
"/sw.js",
|
|
workboxFile ? `/${workboxFile}` : "",
|
|
releaseEntry.entry ? `/${releaseEntry.entry}` : "",
|
|
...(releaseEntry.css || []).map((fileName: string) => `/${fileName}`),
|
|
...(releaseManifest.index_asset_urls || []).map(releaseRootStaticPath),
|
|
...(releaseManifest.pwa_asset_urls || []).map(releaseRootStaticPath),
|
|
...(releaseManifest.asset_urls || []).map(releaseRootStaticPath),
|
|
cssFile || "",
|
|
].filter(Boolean)
|
|
)
|
|
);
|
|
}
|
|
|
|
test.beforeAll(async () => {
|
|
const indexPath = path.join(DIST_DIR, "index.html");
|
|
if (!(await fileExists(indexPath))) {
|
|
throw new Error("dist/index.html is missing. Run `npm run build` before this test.");
|
|
}
|
|
|
|
server = http.createServer((request, response) => {
|
|
void handleRequest(request, response).catch((error) => {
|
|
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
response.end(error instanceof Error ? error.message : String(error));
|
|
});
|
|
});
|
|
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Could not start public .htaccess test server.");
|
|
}
|
|
serverBaseUrl = `http://127.0.0.1:${address.port}`;
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
if (!server.listening) {
|
|
return;
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
});
|
|
|
|
test.describe("public .htaccess static fallback", () => {
|
|
test("keeps SPA fallback base agnostic and static misses as 404", async () => {
|
|
const source = await fs.readFile(HTACCESS_PATH, "utf8");
|
|
const sourceWithoutRegexEscapes = source.replace(/\\/g, "");
|
|
|
|
expect(source).not.toMatch(/RewriteBase\s+\//);
|
|
expect(source).not.toContain("/index.html");
|
|
expect(source).toContain("index.html [L]");
|
|
expect(source).toContain("R=404");
|
|
expect(source).toContain("AddType application/manifest+json .webmanifest");
|
|
|
|
for (const token of [...STATIC_DIRECTORIES, ...STATIC_FILES, "workbox-"]) {
|
|
expect(sourceWithoutRegexEscapes).toContain(token);
|
|
}
|
|
});
|
|
|
|
test("keeps nginx static fallbacks aligned with the public .htaccess", async () => {
|
|
const source = await fs.readFile(NGINX_CONFIG_PATH, "utf8");
|
|
const sourceWithoutRegexEscapes = source.replace(/\\/g, "");
|
|
|
|
expect(source).toContain("static_asset_path");
|
|
expect(source).toContain("static_file_path");
|
|
expect(source).toContain("=404");
|
|
expect(source).toContain("application/manifest+json webmanifest");
|
|
|
|
for (const token of [...STATIC_DIRECTORIES, ...STATIC_FILES, "workbox-"]) {
|
|
expect(sourceWithoutRegexEscapes).toContain(token);
|
|
}
|
|
});
|
|
|
|
test("emits favicon and manifest links from generated assets", async () => {
|
|
const indexHtml = await fs.readFile(path.join(DIST_DIR, "index.html"), "utf8");
|
|
const releaseManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "release-manifest.json"), "utf8"));
|
|
const legacyManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "manifest.json"), "utf8"));
|
|
const rootWebManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "manifest.webmanifest"), "utf8"));
|
|
|
|
for (const staticPath of GENERATED_HTML_STATIC_PATHS) {
|
|
expect(indexHtml).toContain(`href="${staticPath}"`);
|
|
}
|
|
|
|
expect(indexHtml).toContain('src="./assets/');
|
|
expect(indexHtml).toContain('src="./registerSW.js"');
|
|
expect(indexHtml).toContain("window.location.pathname.match(/^\\/[^/]+\\/frontend");
|
|
expect(indexHtml).not.toContain('href="/assets/');
|
|
expect(indexHtml).not.toContain('src="/assets/');
|
|
expect(indexHtml).not.toContain('src="/registerSW.js"');
|
|
expect(indexHtml).not.toContain(`href="${RELEASE_FRONTEND_BASE_PATH}/assets/`);
|
|
expect(indexHtml).not.toContain(`src="${RELEASE_FRONTEND_BASE_PATH}/assets/`);
|
|
expect(indexHtml).not.toContain(`src="${RELEASE_FRONTEND_BASE_PATH}/registerSW.js"`);
|
|
expect(indexHtml).not.toContain('href="/favicons/');
|
|
expect(indexHtml).not.toContain('href="/favicon.ico"');
|
|
expect(indexHtml).not.toContain('href="/manifest.json"');
|
|
|
|
for (const icon of [...(legacyManifest.icons || []), ...(rootWebManifest.icons || [])]) {
|
|
expect(icon.src).toMatch(/^(?:assets|\/master\/frontend\/assets)\/favicons\//);
|
|
}
|
|
expect(legacyManifest.id).toBe("/");
|
|
expect(rootWebManifest.id).toBe("/");
|
|
|
|
for (const assetPath of [
|
|
releaseManifest.entry,
|
|
...(releaseManifest.css || []),
|
|
...(releaseManifest.index_asset_urls || []),
|
|
...(releaseManifest.pwa_asset_urls || []),
|
|
...(releaseManifest.asset_urls || []),
|
|
].filter(Boolean)) {
|
|
expect(assetPath, "release manifest asset paths must be base-relative").not.toMatch(/^\/(?!master\/frontend\/)/);
|
|
}
|
|
});
|
|
|
|
test("does not emit conflicting Workbox precache URLs", async () => {
|
|
const serviceWorker = await fs.readFile(path.join(DIST_DIR, "sw.js"), "utf8");
|
|
const precacheEntries = Array.from(serviceWorker.matchAll(/\{url:"([^"]+)",revision:(null|"[^"]+")/g), (match) => ({
|
|
url: match[1],
|
|
revision: match[2],
|
|
}));
|
|
const revisionsByUrl = new Map<string, Set<string>>();
|
|
|
|
for (const entry of precacheEntries) {
|
|
const revisions = revisionsByUrl.get(entry.url) || new Set<string>();
|
|
revisions.add(entry.revision);
|
|
revisionsByUrl.set(entry.url, revisions);
|
|
}
|
|
|
|
const conflictingUrls = Array.from(revisionsByUrl.entries())
|
|
.filter(([, revisions]) => revisions.size > 1)
|
|
.map(([url]) => url);
|
|
|
|
expect(precacheEntries.length).toBeGreaterThan(0);
|
|
expect(conflictingUrls).toEqual([]);
|
|
expect(precacheEntries.filter((entry) => entry.url === "assets/manifest.webmanifest")).toHaveLength(1);
|
|
});
|
|
|
|
test("serves the app shell from root, deep links, and release-prefixed paths", async ({ request }) => {
|
|
for (const appPath of [
|
|
"/",
|
|
"/guest/book/wash",
|
|
"/canary/frontend",
|
|
"/canary/frontend/index.html",
|
|
"/canary/frontend/guest/book/wash",
|
|
]) {
|
|
const response = await request.get(`${serverBaseUrl}${appPath}`);
|
|
expect(response.status(), appPath).toBe(200);
|
|
expect(response.headers()["content-type"], appPath).toContain("text/html");
|
|
expect(await response.text(), appPath).toContain('<div id="app"></div>');
|
|
}
|
|
});
|
|
|
|
test("serves copied and generated static inclusions below any base path", async ({ request }) => {
|
|
const staticPaths = await distStaticPaths();
|
|
|
|
for (const staticPath of staticPaths) {
|
|
for (const prefix of ["", "/canary/frontend", "/canary/frontend/guest/book/wash"]) {
|
|
const response = await request.get(`${serverBaseUrl}${prefix}${staticPath}`);
|
|
expect(response.status(), `${prefix}${staticPath}`).toBe(200);
|
|
if (staticPath === "/index.html") {
|
|
expect(response.headers()["content-type"], `${prefix}${staticPath}`).toContain("text/html");
|
|
expect(await response.text(), `${prefix}${staticPath}`).toContain('<div id="app"></div>');
|
|
} else {
|
|
expect(response.headers()["content-type"], `${prefix}${staticPath}`).not.toContain("text/html");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test("does not rewrite missing static-looking requests to the SPA shell", async ({ request }) => {
|
|
for (const missingPath of [
|
|
"/missing.js",
|
|
"/canary/frontend/assets/missing.js",
|
|
"/canary/frontend/guest/book/wash/resources/missing.css",
|
|
"/canary/frontend/not-real.png",
|
|
]) {
|
|
const response = await request.get(`${serverBaseUrl}${missingPath}`);
|
|
expect(response.status(), missingPath).toBe(404);
|
|
expect(response.headers()["content-type"], missingPath).not.toContain("text/html");
|
|
expect(await response.text(), missingPath).not.toContain('<div id="app"></div>');
|
|
}
|
|
});
|
|
});
|