357 lines
10 KiB
JavaScript
357 lines
10 KiB
JavaScript
import { execFile, spawn } from "node:child_process";
|
|
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 pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
|
|
const serverOutputLimit = 80;
|
|
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
|
|
const viteDevArgs = [
|
|
"run",
|
|
"dev",
|
|
"--",
|
|
"--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) {
|
|
const reader = readline.createInterface({ input: stream });
|
|
reader.on("line", (line) => {
|
|
lines.push(line);
|
|
if (lines.length > serverOutputLimit) {
|
|
lines.splice(0, lines.length - serverOutputLimit);
|
|
}
|
|
});
|
|
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 warmUpDevServer(url) {
|
|
const warmupTargets = await buildWarmupTargets();
|
|
|
|
for (const target of warmupTargets) {
|
|
await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType);
|
|
}
|
|
}
|
|
|
|
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 =
|
|
process.platform === "win32"
|
|
? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd ${viteDevArgs.join(" ")}`], {
|
|
cwd: process.cwd(),
|
|
detached: true,
|
|
env: {
|
|
...process.env,
|
|
PLAYWRIGHT: "1",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
})
|
|
: spawn("npm", viteDevArgs, {
|
|
cwd: process.cwd(),
|
|
detached: true,
|
|
env: {
|
|
...process.env,
|
|
PLAYWRIGHT: "1",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stdoutLines = [];
|
|
const stderrLines = [];
|
|
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines);
|
|
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines);
|
|
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;
|
|
} finally {
|
|
stdoutReader.close();
|
|
stderrReader.close();
|
|
}
|
|
}
|