fix(ci): free stale E2E ports before Playwright bind on Linux

Self-hosted evoo-ci runners can retain listeners on harness ports
(3110 API, 5180 web, 4321 marketing, 3111 device control) after a
failed run, causing EADDRINUSE before the next job. Add a guarded
free-e2e-ports.sh that uses fuser on Linux only and never touches
live dev ports 3100/5173. Wire it into the Playwright workflow and
the E2E harness with clear logging.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-09-23 21:51:52 +00:00
parent d2df25fd9d
commit f1bd94dda2
4 changed files with 103 additions and 0 deletions

View File

@ -111,6 +111,8 @@ jobs:
env:
BOX_API_KEY: ${{ secrets.BOX_API_KEY }}
run: test -n "$BOX_API_KEY"
- name: Free stale E2E ports
run: bash scripts/free-e2e-ports.sh
- name: Run Playwright tests
id: playwright_tests
env:
@ -121,6 +123,9 @@ jobs:
run: pnpm test:e2e -- --sandbox="$SANDBOX_TEST_PROVIDER"
# Marketing homepage only: separate config so product chromium does not start Astro.
# Runs after product e2e so shared apps/web/test-results is not wiped by the product run.
- name: Free stale marketing E2E ports
if: always() && (steps.playwright_tests.outcome == 'success' || steps.playwright_tests.outcome == 'failure')
run: bash scripts/free-e2e-ports.sh
- name: Run marketing homepage E2E
id: www_playwright_tests
if: always() && (steps.playwright_tests.outcome == 'success' || steps.playwright_tests.outcome == 'failure')

View File

@ -85,6 +85,10 @@ async function main() {
cwd: path.resolve("packages/db"),
});
if (e2e && process.platform === "linux") {
execSync("bash scripts/free-e2e-ports.sh", { stdio: "inherit", env: process.env });
}
if (integration) {
const suites = [
"packages/testkit/src/pi-offline.postgres.test.ts",

View File

@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const scriptPath = path.join(repoRoot, "scripts/free-e2e-ports.sh");
const playwrightWorkflow = readFileSync(
path.join(repoRoot, ".github/workflows/playwright.yml"),
"utf8",
);
function portsFromShellArray(source: string, name: string): number[] {
const match = source.match(new RegExp(`readonly ${name}=\\(([^)]*)\\)`));
expect(match, `${name} array`).toBeTruthy();
return [...match![1].matchAll(/\b(\d+)\b/g)].map(([, port]) => Number(port));
}
describe("free-e2e-ports.sh", () => {
const script = readFileSync(scriptPath, "utf8");
it("declares dedicated E2E ports separate from live dev ports", () => {
const livePorts = portsFromShellArray(script, "LIVE_PORTS");
const e2ePorts = portsFromShellArray(script, "E2E_PORTS");
expect(livePorts).toEqual([3100, 5173]);
expect(e2ePorts).toEqual([3110, 3111, 5180, 4321]);
for (const livePort of livePorts) {
expect(e2ePorts).not.toContain(livePort);
}
});
it("uses fuser and skips non-Linux hosts", () => {
expect(script).toContain('fuser -k "$spec"');
expect(script).toContain('uname -s)" != "Linux"');
});
it("is wired into the Playwright workflow before product and marketing E2E", () => {
const occurrences = playwrightWorkflow.match(/free-e2e-ports\.sh/g) ?? [];
expect(occurrences.length).toBeGreaterThanOrEqual(2);
expect(playwrightWorkflow.indexOf("free-e2e-ports.sh")).toBeLessThan(
playwrightWorkflow.indexOf("Run Playwright tests"),
);
expect(playwrightWorkflow.lastIndexOf("free-e2e-ports.sh")).toBeLessThan(
playwrightWorkflow.indexOf("Run marketing homepage E2E"),
);
});
});

47
scripts/free-e2e-ports.sh Executable file
View File

@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Free stale listeners on dedicated Playwright/E2E ports before harness bind.
# Never touches live dev stack ports 3100 (API) or 5173 (web).
set -euo pipefail
readonly LIVE_PORTS=(3100 5173)
readonly E2E_PORTS=(3110 3111 5180 4321)
if [[ "$(uname -s)" != "Linux" ]]; then
echo "free-e2e-ports: skip (not Linux)"
exit 0
fi
if ! command -v fuser >/dev/null 2>&1; then
echo "free-e2e-ports: warning: fuser not found; cannot pre-free E2E ports" >&2
exit 0
fi
for port in "${LIVE_PORTS[@]}"; do
for e2e_port in "${E2E_PORTS[@]}"; do
if [[ "$port" -eq "$e2e_port" ]]; then
echo "free-e2e-ports: internal error: live port $port must not be in E2E_PORTS" >&2
exit 1
fi
done
done
echo "free-e2e-ports: clearing stale listeners on E2E ports (${E2E_PORTS[*]}); live ports ${LIVE_PORTS[*]} untouched"
for port in "${E2E_PORTS[@]}"; do
spec="${port}/tcp"
if fuser "$spec" >/dev/null 2>&1; then
echo "free-e2e-ports: port $port in use:"
fuser -v "$spec" 2>&1 || true
echo "free-e2e-ports: killing processes on port $port (fuser -k)"
fuser -k "$spec" 2>&1 || true
sleep 0.25
if fuser "$spec" >/dev/null 2>&1; then
echo "free-e2e-ports: warning: port $port still in use after fuser -k" >&2
else
echo "free-e2e-ports: port $port is now free"
fi
else
echo "free-e2e-ports: port $port already free"
fi
done