Files
pleno-vue/playwright.global-setup.mjs

488 lines
15 KiB
JavaScript

import { execFile, spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
const devHost = process.env.PLAYWRIGHT_DEV_HOST || "127.0.0.1";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${devPort}`;
const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`)
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "-");
const serverLogDir = path.resolve(process.cwd(), "output/playwright");
const pidFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.json`);
const stdoutLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.stdout.log`);
const stderrLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.stderr.log`);
const viteCliPath = path.resolve(process.cwd(), "node_modules/vite/bin/vite.js");
const serverOutputLimit = 80;
const activeOutputReaders = [];
const viteServerEnv = {
...process.env,
PLAYWRIGHT: "1",
...(process.env.CI
? {
CHOKIDAR_INTERVAL: process.env.CHOKIDAR_INTERVAL || "300",
CHOKIDAR_USEPOLLING: process.env.CHOKIDAR_USEPOLLING || "true",
}
: {}),
};
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
const viteDevArgs = [
...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []),
...(process.platform === "win32" ? ["--configLoader", "runner"] : []),
"--host",
devHost,
"--port",
String(devPort),
"--strictPort",
];
async function getListeningProcessOnWindows(port) {
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], {
cwd: process.cwd(),
});
const match = stdout.match(new RegExp(`^\\s*TCP\\s+[^\\s]+:${port}\\s+[^\\s]+\\s+LISTENING\\s+(\\d+)\\s*$`, "mi"));
if (!match) {
return null;
}
const pid = Number(match[1]);
const command = `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`;
const processResult = await execFileAsync("powershell", ["-NoProfile", "-Command", command], {
cwd: process.cwd(),
}).catch(() => ({ stdout: "" }));
return {
Id: pid,
CommandLine: processResult.stdout.trim(),
};
}
async function readUnixProcessDetails(pid, fallbackCommandLine = "") {
const [rawCommandLine, currentWorkingDirectory] = await Promise.all([
fs.readFile(`/proc/${pid}/cmdline`).catch(() => Buffer.alloc(0)),
fs.readlink(`/proc/${pid}/cwd`).catch(() => ""),
]);
const commandLine = rawCommandLine.toString("utf8").split("\0").filter(Boolean).join(" ").trim();
const commandContext = [commandLine || fallbackCommandLine, currentWorkingDirectory].filter(Boolean).join(" | ");
return {
Id: pid,
CommandLine: commandContext || fallbackCommandLine,
};
}
function parseSsListeningProcess(stdout, port) {
const line = stdout
.split(/\r?\n/)
.find((entry) => entry.includes("LISTEN") && entry.includes(`:${port}`) && entry.includes("pid="));
if (!line) {
return null;
}
const pidMatch = line.match(/pid=(\d+)/);
if (!pidMatch) {
return null;
}
const commandMatch = line.match(/users:\(\("([^"]+)"/);
return {
pid: Number(pidMatch[1]),
commandLine: commandMatch ? commandMatch[1] : "",
};
}
function parseNetstatListeningProcess(stdout, port) {
const line = stdout
.split(/\r?\n/)
.find((entry) => entry.includes("LISTEN") && entry.match(new RegExp(`:${port}\\s`)));
if (!line) {
return null;
}
const pidMatch = line.match(/\s(\d+)\/([^\s]+)\s*$/);
if (!pidMatch) {
return null;
}
return {
pid: Number(pidMatch[1]),
commandLine: pidMatch[2],
};
}
async function getListeningProcessOnUnix(port) {
try {
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp", "-Fc"], {
cwd: process.cwd(),
});
const pidMatch = stdout.match(/^p(\d+)$/m);
const commandMatch = stdout.match(/^c(.+)$/m);
if (!pidMatch) {
return null;
}
return readUnixProcessDetails(Number(pidMatch[1]), commandMatch ? commandMatch[1] : "");
} catch {
// Fall through to other tools that are commonly available on Linux runners.
}
try {
const { stdout } = await execFileAsync("ss", ["-ltnp", `sport = :${port}`], {
cwd: process.cwd(),
});
const processMatch = parseSsListeningProcess(stdout, port);
if (processMatch) {
return readUnixProcessDetails(processMatch.pid, processMatch.commandLine);
}
} catch {
// Fall through to netstat.
}
try {
const { stdout } = await execFileAsync("netstat", ["-ltnp"], {
cwd: process.cwd(),
});
const processMatch = parseNetstatListeningProcess(stdout, port);
if (processMatch) {
return readUnixProcessDetails(processMatch.pid, processMatch.commandLine);
}
} catch {
// No more fallbacks available.
}
return null;
}
async function getListeningProcess(port) {
if (process.platform === "win32") {
return getListeningProcessOnWindows(port);
}
return getListeningProcessOnUnix(port);
}
async function killProcessTree(pid) {
if (!pid) {
return;
}
if (process.platform === "win32") {
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], { cwd: process.cwd() }).catch(() => {});
return;
}
try {
process.kill(-pid, "SIGTERM");
} catch {
try {
process.kill(pid, "SIGTERM");
} catch {
// ignore
}
}
}
function captureProcessOutput(stream, lines, logStream) {
const reader = readline.createInterface({ input: stream });
reader.on("line", (line) => {
lines.push(line);
logStream.write(`${line}\n`);
if (lines.length > serverOutputLimit) {
lines.splice(0, lines.length - serverOutputLimit);
}
});
reader.on("close", () => {
logStream.end();
});
return reader;
}
function formatProcessOutputSection(label, lines) {
if (lines.length === 0) {
return "";
}
return `\n${label}:\n${lines.join("\n")}`;
}
function formatServerExitError(url, exitInfo, stdoutLines, stderrLines) {
const exitLabel = exitInfo.signal ? `signal ${exitInfo.signal}` : `code ${exitInfo.code ?? 0}`;
return new Error(
`The local Playwright dev server exited before ${url} became ready (${exitLabel}).${formatProcessOutputSection(
"stdout",
stdoutLines
)}${formatProcessOutputSection("stderr", stderrLines)}`
);
}
async function waitForServerReady(url, serverProcess, stdoutLines, stderrLines, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
let exitInfo = null;
const handleExit = (code, signal) => {
exitInfo = {
code,
signal,
};
};
serverProcess.on("exit", handleExit);
try {
while (Date.now() < deadline) {
if (exitInfo || serverProcess.exitCode !== null || serverProcess.signalCode !== null) {
throw formatServerExitError(
url,
exitInfo || {
code: serverProcess.exitCode,
signal: serverProcess.signalCode,
},
stdoutLines,
stderrLines
);
}
try {
const response = await fetch(url, { redirect: "manual" });
if (response.status < 500) {
return;
}
} catch {
// keep polling until ready
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(
`Timed out waiting for the local Playwright dev server at ${url}.${formatProcessOutputSection(
"stdout",
stdoutLines
)}${formatProcessOutputSection("stderr", stderrLines)}`
);
} finally {
serverProcess.off("exit", handleExit);
}
}
async function buildWarmupTargets() {
return [
{ pathname: "/admin/12/modules/pos?step=1", expectedContentType: "text/html" },
{ pathname: "/admin/12/modules/pos/orders/54518", expectedContentType: "text/html" },
{ pathname: "/@vite/client", expectedContentType: "javascript" },
{ pathname: "/src/main.js", expectedContentType: "javascript" },
{ pathname: "/src/router.js", expectedContentType: "javascript" },
];
}
async function warmUpAsset(url, expectedContentType, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
const perRequestTimeoutMs = 10_000;
while (Date.now() < deadline) {
try {
const response = await fetch(url, {
redirect: "manual",
signal: AbortSignal.timeout(perRequestTimeoutMs),
});
const contentType = response.headers.get("content-type") || "";
if (response.ok && contentType.toLowerCase().includes(expectedContentType)) {
await response.arrayBuffer();
return;
}
} catch {
// keep polling until the Vite transform is ready
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out warming Playwright dev asset ${url}.`);
}
async function fetchWarmedAsset(url, expectedContentType, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
const perRequestTimeoutMs = 10_000;
while (Date.now() < deadline) {
try {
const response = await fetch(url, {
redirect: "manual",
signal: AbortSignal.timeout(perRequestTimeoutMs),
});
const contentType = response.headers.get("content-type") || "";
if (response.ok && contentType.toLowerCase().includes(expectedContentType)) {
return await response.text();
}
} catch {
// keep polling until the Vite transform is ready
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out warming Playwright dev asset ${url}.`);
}
function extractModuleImports(source) {
const specifiers = new Set();
const importPattern =
/\bimport(?:\s+[^"'()]+?\s+from\s+)?["']([^"']+)["']|\bimport\(\s*["']([^"']+)["']\s*\)/g;
for (const match of source.matchAll(importPattern)) {
const specifier = match[1] || match[2];
if (!specifier) {
continue;
}
if (specifier.startsWith("/src/") || specifier.startsWith("/node_modules/.vite/deps/")) {
specifiers.add(specifier);
}
}
return [...specifiers];
}
function resolveExpectedContentType(specifier) {
const cleanSpecifier = specifier.split("?")[0];
if (cleanSpecifier.endsWith(".css")) {
return "css";
}
if (cleanSpecifier.endsWith(".json")) {
// Vite serves imported JSON modules as JavaScript ("?import" transform),
// so warming should verify a JavaScript content type.
return "javascript";
}
return "javascript";
}
async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {}) {
const queue = [{ url: entryUrl, depth: 0 }];
const visited = new Set();
while (queue.length > 0) {
const current = queue.shift();
if (!current || visited.has(current.url)) {
continue;
}
visited.add(current.url);
const source = await fetchWarmedAsset(current.url, "javascript", timeoutMs);
if (current.depth >= depth) {
continue;
}
for (const specifier of extractModuleImports(source)) {
if (specifier.startsWith("/node_modules/.vite/deps/")) {
continue;
}
const expectedContentType = resolveExpectedContentType(specifier);
const importUrl = new URL(specifier, current.url).toString();
if (expectedContentType === "javascript" && specifier.startsWith("/src/")) {
queue.push({
url: importUrl,
depth: current.depth + 1,
});
continue;
}
await warmUpAsset(importUrl, expectedContentType, timeoutMs);
}
}
}
async function warmUpDevServer(url) {
const warmupTargets = await buildWarmupTargets();
const selfServeEntryModules = [
"/src/views/dashboards/userDashboard/wash/MyWash.vue",
"/src/views/dashboards/userDashboard/wash/MyWashStart.vue",
"/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue",
"/src/views/guest/book/GuestBookExteriorWash.vue",
];
const smokeRouteEntryModules = [
{ path: "/src/components/page/wrappers/LayoutV2.vue", depth: 2 },
{ path: "/src/views/pages/LandingPage.vue", depth: 2 },
{ path: "/src/views/dashboards/superUserDashboard/selfserve/EdgeAgents.vue", depth: 1 },
{ path: "/src/features/edgeGateways/EdgeGatewayManager.vue", depth: 2 },
{ path: "/src/features/edgeGateways/EdgeGatewayTerminalPage.vue", depth: 2 },
{ path: "/src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue", depth: 1 },
{ path: "/src/features/edgeGateways/EdgeGatewayDepartmentWorkspace.vue", depth: 2 },
{ path: "/src/views/dashboards/superUserDashboard/CollectedOrderInvoices.vue", depth: 1 },
{ path: "/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionOverview.vue", depth: 2 },
{ path: "/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue", depth: 2 },
];
for (const target of warmupTargets) {
await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType);
}
await warmModuleGraph(new URL("/src/main.js", url).toString());
for (const entryModule of selfServeEntryModules) {
await warmModuleGraph(new URL(entryModule, url).toString(), { depth: 2 });
}
for (const { path: entryModule, depth } of smokeRouteEntryModules) {
await warmModuleGraph(new URL(entryModule, url).toString(), { depth });
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
export default async function globalSetup() {
if (process.env.PLAYWRIGHT_BASE_URL) {
return;
}
await fs.mkdir(path.dirname(pidFile), { recursive: true });
const existing = await getListeningProcess(devPort);
if (existing) {
if (!/vite(?:\.js)?/i.test(existing.CommandLine || "")) {
throw new Error(`Port ${devPort} is already in use by a non-Vite process: ${existing.CommandLine}`);
}
await killProcessTree(existing.Id);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], {
cwd: process.cwd(),
detached: true,
env: viteServerEnv,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const stdoutLines = [];
const stderrLines = [];
const stdoutLogStream = createWriteStream(stdoutLogFile, { flags: "w" });
const stderrLogStream = createWriteStream(stderrLogFile, { flags: "w" });
serverProcess.on("exit", (code, signal) => {
stderrLogStream.write(`[playwright-global-setup] vite exited with code ${code ?? "null"} signal ${signal ?? "null"}\n`);
});
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines, stdoutLogStream);
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines, stderrLogStream);
activeOutputReaders.push(stdoutReader, stderrReader);
serverProcess.unref();
await fs.writeFile(pidFile, JSON.stringify({ pid: serverProcess.pid, port: devPort }), "utf8");
try {
await waitForServerReady(baseURL, serverProcess, stdoutLines, stderrLines);
await warmUpDevServer(baseURL);
} catch (error) {
await killProcessTree(serverProcess.pid);
throw error;
}
}