Files
pleno-vue/tests/e2e/release/public-htaccess.local-prod.spec.ts
T

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>');
}
});
});