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((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((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\//); } 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>(); for (const entry of precacheEntries) { const revisions = revisionsByUrl.get(entry.url) || new Set(); 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('
'); } }); 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('
'); } 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('
'); } }); });