Fix Playwright dev server startup on runner

This commit is contained in:
Jeppe Bundgaard
2026-04-21 22:27:33 +02:00
parent 13528b07af
commit 75b8dff0f8
4 changed files with 142 additions and 38 deletions
+2 -1
View File
@@ -2,7 +2,8 @@ import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${devPort}`;
const devHost = process.env.PLAYWRIGHT_DEV_HOST || "127.0.0.1";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${devPort}`;
const isCI = !!process.env.CI;
const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "").trim();
const artifactRoot = artifactNamespace
+127 -22
View File
@@ -1,17 +1,20 @@
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 baseURL = `http://localhost:${devPort}`;
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 posViewsDirectory = path.resolve(process.cwd(), "src/views/dashboards/departmentDashboard/modules/Pos");
const shopComponentsDirectory = path.resolve(process.cwd(), "src/components/shop");
const serverOutputLimit = 80;
async function getListeningProcessOnWindows(port) {
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], {
@@ -52,8 +55,46 @@ async function getListeningProcessOnUnix(port) {
CommandLine: commandMatch ? commandMatch[1] : "",
};
} catch {
return null;
// 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 line = stdout
.split(/\r?\n/)
.find((entry) => entry.includes("LISTEN") && entry.includes(`:${port}`) && entry.includes("pid="));
const pidMatch = line?.match(/pid=(\d+)/);
if (pidMatch) {
return {
Id: Number(pidMatch[1]),
CommandLine: line.match(/users:\(\("([^"]+)"/)?.[1] || "",
};
}
} catch {
// Fall through to netstat.
}
try {
const { stdout } = await execFileAsync("netstat", ["-ltnp"], {
cwd: process.cwd(),
});
const line = stdout
.split(/\r?\n/)
.find((entry) => entry.includes("LISTEN") && entry.match(new RegExp(`:${port}\\s`)));
const pidMatch = line?.match(/\s(\d+)\/([^\s]+)\s*$/);
if (pidMatch) {
return {
Id: Number(pidMatch[1]),
CommandLine: pidMatch[2],
};
}
} catch {
// No more fallbacks available.
}
return null;
}
async function getListeningProcess(port) {
@@ -85,23 +126,82 @@ async function killProcessTree(pid) {
}
}
async function waitForServerReady(url, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { redirect: "manual" });
if (response.status < 500) {
return;
}
} catch {
// keep polling until ready
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;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
function formatProcessOutputSection(label, lines) {
if (lines.length === 0) {
return "";
}
throw new Error(`Timed out waiting for the local Playwright dev server at ${url}.`);
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() {
@@ -180,35 +280,40 @@ export default async function globalSetup() {
const serverProcess =
process.platform === "win32"
? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`], {
? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd run dev -- --host ${devHost} --port ${devPort} --strictPort`], {
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: "ignore",
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
})
: spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], {
: spawn("npm", ["run", "dev", "--", "--host", devHost, "--port", String(devPort), "--strictPort"], {
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: "ignore",
stdio: ["ignore", "pipe", "pipe"],
});
serverProcess.unref();
const stdoutLines = [];
const stderrLines = [];
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines);
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines);
await fs.writeFile(pidFile, JSON.stringify({ pid: serverProcess.pid, port: devPort }), "utf8");
try {
await waitForServerReady(baseURL);
await waitForServerReady(baseURL, serverProcess, stdoutLines, stderrLines);
await warmUpDevServer(baseURL);
} catch (error) {
await killProcessTree(serverProcess.pid);
throw error;
} finally {
stdoutReader.close();
stderrReader.close();
}
}
+2 -5
View File
@@ -1,6 +1,6 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = "http://localhost:4173";
const baseURL = "http://127.0.0.1:4173";
const isCI = !!process.env.CI;
process.env.PLAYWRIGHT_BASE_URL = baseURL;
@@ -13,10 +13,7 @@ export default defineConfig({
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 2 : 3,
reporter: [
["list"],
["html", { open: "never", outputFolder: "output/playwright/prod/report" }],
],
reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/prod/report" }]],
outputDir: "output/playwright/prod/test-results",
use: {
baseURL,
+11 -10
View File
@@ -2,19 +2,20 @@ export const IS_DEV = import.meta.env.VITE_IS_DEV; // Set to false in production
// Development mode
export const POS_STEP_1_VERSION = 2; //(IS_DEV ? 2 : 1);
export const API_URL = (IS_DEV ? 'https://api.truckwash.io:4433' : 'https://api.truckwash.io');
export const API_URL = IS_DEV ? "https://api.truckwash.io:4433" : "https://api.truckwash.io";
// Allowed origins
export const ALLOWED_ORIGINS = [
'https://truckwash.io',
'https://www.truckwash.io',
'http://localhost:5173',
'http://localhost:4173',
'http://localhost:4174',
'http://127.0.0.1:4173',
'http://127.0.0.1:4174',
"https://truckwash.io",
"https://www.truckwash.io",
"http://localhost:5173",
"http://localhost:4173",
"http://localhost:4174",
"http://127.0.0.1:5173",
"http://127.0.0.1:4173",
"http://127.0.0.1:4174",
];
export const MIGRATION_ORIGIN = 'https://truckwash.io';
export const MIGRATION_ORIGIN = "https://truckwash.io";
// Global request queue / retry / progress configuration
export const REQUEST_QUEUE_CONFIG = Object.freeze({
@@ -43,7 +44,7 @@ export const REQUEST_QUEUE_CONFIG = Object.freeze({
jitterMs: 50,
}),
ping: Object.freeze({
endpoint: '/ping',
endpoint: "/ping",
intervalMs: 30000,
timeoutMs: 5000,
}),