From 0c44846f5a1463ab3156121187276d655bea83a5 Mon Sep 17 00:00:00 2001 From: WPEssential Date: Thu, 8 Oct 2026 17:24:47 +0500 Subject: [PATCH 1/3] ci: supervise Windows Playwright app server --- tools/e2e-server-supervisor.mjs | 160 ++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 tools/e2e-server-supervisor.mjs diff --git a/tools/e2e-server-supervisor.mjs b/tools/e2e-server-supervisor.mjs new file mode 100644 index 00000000..5e07a86f --- /dev/null +++ b/tools/e2e-server-supervisor.mjs @@ -0,0 +1,160 @@ +import { spawn } from 'node:child_process' +import path from 'node:path' +import process from 'node:process' + +const root = path.resolve(import.meta.dirname, '..') +const configuredBase = process.env.WORKINTEL_E2E_BASE_URL || 'http://127.0.0.1:8777' +const base = new URL(configuredBase) +const allowedHosts = new Set(['127.0.0.1', 'localhost', '::1']) + +if (!allowedHosts.has(base.hostname)) { + console.error('[e2e-server-supervisor] Refusing to start a local server for a non-local E2E base URL.') + process.exit(1) +} + +const host = base.hostname === 'localhost' ? '127.0.0.1' : base.hostname +const port = Number(base.port || (base.protocol === 'https:' ? 443 : 80)) +const healthURL = new URL('/health/live', base).toString() +const probeIntervalMs = 2_000 +const probeTimeoutMs = 3_000 +const startupGraceMs = 45_000 +const unhealthyProbeLimit = 3 +const maxRestarts = 2 + +let server = null +let serverSpawnError = null +let serverExit = null +let serverStartedAt = 0 +let consecutiveFailures = 0 +let restartCount = 0 +let stopping = false + +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) +const log = message => console.log(`[e2e-server-supervisor] ${message}`) + +function startServer() { + serverSpawnError = null + serverExit = null + serverStartedAt = Date.now() + server = spawn('php', ['artisan', 'serve', '--no-reload', `--host=${host}`, `--port=${port}`], { + cwd: root, + env: process.env, + stdio: 'inherit', + windowsHide: true, + }) + + server.once('error', error => { + serverSpawnError = error + console.error(`[e2e-server-supervisor] Laravel server process error: ${error.message}`) + }) + server.once('exit', (code, signal) => { + serverExit = { code, signal } + if (!stopping) log(`Laravel server exited (code=${code ?? 'null'}, signal=${signal ?? 'none'}).`) + }) + log(`Started Laravel server PID ${server.pid ?? 'unavailable'} at http://${host}:${port}.`) +} + +async function isHealthy() { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), probeTimeoutMs) + + try { + const response = await fetch(healthURL, { signal: controller.signal }) + await response.body?.cancel() + return response.ok + } catch { + return false + } finally { + clearTimeout(timeout) + } +} + +async function terminateServer() { + const current = server + if (!current) return + + const exited = new Promise(resolve => { + if (current.exitCode !== null || current.signalCode !== null) { + resolve() + } else { + current.once('exit', resolve) + } + }) + + if (current.pid && process.platform === 'win32') { + await new Promise(resolve => { + const killer = spawn('taskkill.exe', ['/pid', String(current.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true, + }) + killer.once('error', () => { + current.kill('SIGTERM') + resolve() + }) + killer.once('close', resolve) + }) + } else { + current.kill('SIGTERM') + } + + await Promise.race([exited, delay(5_000)]) + if (current.exitCode === null && current.signalCode === null) current.kill('SIGKILL') + if (server === current) server = null +} + +async function stop(exitCode = 0) { + if (stopping) return + stopping = true + await terminateServer() + process.exitCode = exitCode +} + +async function recover(reason) { + if (restartCount >= maxRestarts) { + console.error(`[e2e-server-supervisor] Circuit breaker stopped recovery after ${restartCount} restarts: ${reason}`) + await stop(1) + return + } + + restartCount += 1 + console.error(`[e2e-server-supervisor] Restarting Laravel server (${restartCount}/${maxRestarts}): ${reason}`) + await terminateServer() + await delay(500 * restartCount) + consecutiveFailures = 0 + startServer() +} + +async function supervise() { + startServer() + + while (!stopping) { + await delay(probeIntervalMs) + if (stopping) break + + if (serverSpawnError || serverExit || !server || server.exitCode !== null || server.signalCode !== null) { + const reason = serverSpawnError?.message || `process exit ${JSON.stringify(serverExit)}` + await recover(reason) + continue + } + + const healthy = await isHealthy() + if (healthy) { + consecutiveFailures = 0 + continue + } + + if (Date.now() - serverStartedAt < startupGraceMs) continue + consecutiveFailures += 1 + if (consecutiveFailures >= unhealthyProbeLimit) { + await recover(`health endpoint failed ${consecutiveFailures} consecutive probes`) + } + } +} + +process.once('SIGINT', () => { void stop(0) }) +process.once('SIGTERM', () => { void stop(0) }) + +supervise().catch(async error => { + console.error(`[e2e-server-supervisor] Fatal supervisor error: ${error.stack || error.message}`) + await stop(1) +}) From c5b6ebc39fd9f7e021985a1b2c71d73f5773ff02 Mon Sep 17 00:00:00 2001 From: WPEssential Date: Thu, 8 Oct 2026 17:24:49 +0500 Subject: [PATCH 2/3] ci: restart unhealthy Playwright app server --- tools/playwright.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/playwright.config.mjs b/tools/playwright.config.mjs index 9cc8ca18..50d8b530 100644 --- a/tools/playwright.config.mjs +++ b/tools/playwright.config.mjs @@ -68,7 +68,7 @@ export default defineConfig({ video: 'off', }, webServer: { - command: 'php artisan serve --no-reload --host=127.0.0.1 --port=8777', + command: 'node tools/e2e-server-supervisor.mjs', cwd: root, url: `${baseURL}/health/live`, reuseExistingServer: false, From dab1ad7af440334d6ed7a7847665659216fedb69 Mon Sep 17 00:00:00 2001 From: WPEssential Date: Thu, 8 Oct 2026 17:36:51 +0500 Subject: [PATCH 3/3] fix(ci): harden local E2E server shutdown and URL validation --- tools/e2e-server-supervisor.mjs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tools/e2e-server-supervisor.mjs b/tools/e2e-server-supervisor.mjs index 5e07a86f..926e4247 100644 --- a/tools/e2e-server-supervisor.mjs +++ b/tools/e2e-server-supervisor.mjs @@ -5,15 +5,16 @@ import process from 'node:process' const root = path.resolve(import.meta.dirname, '..') const configuredBase = process.env.WORKINTEL_E2E_BASE_URL || 'http://127.0.0.1:8777' const base = new URL(configuredBase) +const hostname = base.hostname.replace(/^\[|\]$/g, '') const allowedHosts = new Set(['127.0.0.1', 'localhost', '::1']) -if (!allowedHosts.has(base.hostname)) { - console.error('[e2e-server-supervisor] Refusing to start a local server for a non-local E2E base URL.') +if (base.protocol !== 'http:' || !allowedHosts.has(hostname)) { + console.error('[e2e-server-supervisor] Refusing to start a local server unless the E2E base URL is local HTTP.') process.exit(1) } -const host = base.hostname === 'localhost' ? '127.0.0.1' : base.hostname -const port = Number(base.port || (base.protocol === 'https:' ? 443 : 80)) +const host = hostname === 'localhost' ? '127.0.0.1' : hostname +const port = Number(base.port || 80) const healthURL = new URL('/health/live', base).toString() const probeIntervalMs = 2_000 const probeTimeoutMs = 3_000 @@ -51,7 +52,7 @@ function startServer() { serverExit = { code, signal } if (!stopping) log(`Laravel server exited (code=${code ?? 'null'}, signal=${signal ?? 'none'}).`) }) - log(`Started Laravel server PID ${server.pid ?? 'unavailable'} at http://${host}:${port}.`) + log(`Started Laravel server PID ${server.pid ?? 'unavailable'} at http://${host.includes(':') ? `[${host}]` : host}:${port}.`) } async function isHealthy() { @@ -74,14 +75,14 @@ async function terminateServer() { if (!current) return const exited = new Promise(resolve => { - if (current.exitCode !== null || current.signalCode !== null) { + if (current.exitCode !== null || current.signalCode !== null || serverExit) { resolve() } else { current.once('exit', resolve) } }) - if (current.pid && process.platform === 'win32') { + if (current.pid && process.platform === 'win32' && !serverExit) { await new Promise(resolve => { const killer = spawn('taskkill.exe', ['/pid', String(current.pid), '/t', '/f'], { stdio: 'ignore', @@ -93,12 +94,12 @@ async function terminateServer() { }) killer.once('close', resolve) }) - } else { + } else if (!serverExit) { current.kill('SIGTERM') } await Promise.race([exited, delay(5_000)]) - if (current.exitCode === null && current.signalCode === null) current.kill('SIGKILL') + if (current.exitCode === null && current.signalCode === null && !serverExit) current.kill('SIGKILL') if (server === current) server = null }