From f1bd94dda2764dbde0a442b7070e965b1edfcc02 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 21:51:52 +0000 Subject: [PATCH] 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 --- .github/workflows/playwright.yml | 5 +++ packages/testkit/src/cli/harness.ts | 4 +++ packages/testkit/src/e2e-ports.test.ts | 47 ++++++++++++++++++++++++++ scripts/free-e2e-ports.sh | 47 ++++++++++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 packages/testkit/src/e2e-ports.test.ts create mode 100755 scripts/free-e2e-ports.sh diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 4ce2751..00cd53b 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -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') diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index a9e2f8d..25e36ce 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -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", diff --git a/packages/testkit/src/e2e-ports.test.ts b/packages/testkit/src/e2e-ports.test.ts new file mode 100644 index 0000000..eac409a --- /dev/null +++ b/packages/testkit/src/e2e-ports.test.ts @@ -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"), + ); + }); +}); diff --git a/scripts/free-e2e-ports.sh b/scripts/free-e2e-ports.sh new file mode 100755 index 0000000..754bcf8 --- /dev/null +++ b/scripts/free-e2e-ports.sh @@ -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