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 = []; // 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: { ...process.env, PLAYWRIGHT: "1", }, 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; } }