diff --git a/tests/integration/QUICK_START.md b/tests/integration/QUICK_START.md index 98d26fb45..d6369c9a9 100644 --- a/tests/integration/QUICK_START.md +++ b/tests/integration/QUICK_START.md @@ -220,3 +220,16 @@ test('my new test', async ({ page }) => { - Review existing test files for examples - Check Docker logs for service issues - Review Playwright documentation: + +### Worktree-safe shell runner + +`scripts/run-tests.sh` builds unique per-invocation image tags and uses a unique +Compose project and container names. Host ports are allocated by Docker on +loopback and discovered after startup; this runner does not honour fixed port +or base-URL overrides. Its EXIT/INT/TERM/HUP cleanup removes only that invocation's +stack, volumes and image tags. SIGKILL or host failure can leave resources behind; +the printed `pulse-e2e-…` project identifies those resources for manual cleanup. + +This isolation applies to the shell runner, not direct `npm test`, +`setup.sh` or hand-written Compose commands, which retain legacy defaults. +Do not use those defaults concurrently for checkout-specific qualification. diff --git a/tests/integration/docker-compose.test.yml b/tests/integration/docker-compose.test.yml index c8bddec06..946cbfeb5 100644 --- a/tests/integration/docker-compose.test.yml +++ b/tests/integration/docker-compose.test.yml @@ -3,7 +3,7 @@ services: # This is only used by the test stack and is safe to keep deterministic. seed-bootstrap-token: image: alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b - container_name: pulse-test-seed-bootstrap-token + container_name: ${PULSE_E2E_SEED_CONTAINER:-pulse-test-seed-bootstrap-token} environment: - PULSE_E2E_BOOTSTRAP_TOKEN=${PULSE_E2E_BOOTSTRAP_TOKEN:-0123456789abcdef0123456789abcdef0123456789abcdef} - PUID=${PUID:-1000} @@ -37,10 +37,10 @@ services: # Mock GitHub API server for controlled testing mock-github: - image: pulse-mock-github:test - container_name: pulse-mock-github + image: ${PULSE_E2E_MOCK_IMAGE:-pulse-mock-github:test} + container_name: ${PULSE_E2E_MOCK_CONTAINER:-pulse-mock-github} ports: - - "${PULSE_E2E_MOCK_GITHUB_PORT:-8080}:8080" + - "127.0.0.1:${PULSE_E2E_MOCK_GITHUB_PORT:-8080}:8080" environment: - PORT=8080 - MOCK_BASE_URL=http://mock-github:8080 @@ -60,11 +60,11 @@ services: # Pulse server under test pulse-test: - image: pulse:test - container_name: pulse-test-server + image: ${PULSE_E2E_SERVER_IMAGE:-pulse:test} + container_name: ${PULSE_E2E_SERVER_CONTAINER:-pulse-test-server} ports: - - "${PULSE_E2E_PORT:-7655}:7655" - - "${PULSE_E2E_AGENT_PORT:-7656}:7656" + - "127.0.0.1:${PULSE_E2E_PORT:-7655}:7655" + - "127.0.0.1:${PULSE_E2E_AGENT_PORT:-7656}:7656" environment: - TZ=UTC # Dedicated agent-ingest listener so E2E can prove the split-port diff --git a/tests/integration/scripts/run-tests-images.test.mjs b/tests/integration/scripts/run-tests-images.test.mjs index c8895f5b7..3bae84e28 100644 --- a/tests/integration/scripts/run-tests-images.test.mjs +++ b/tests/integration/scripts/run-tests-images.test.mjs @@ -18,7 +18,7 @@ printf '%s\\n' "$*" >> "$CALL_LOG" if [ "$1 $2" = 'compose version' ]; then exit 0; fi if [ "$1 $2" = 'image inspect' ]; then exit 0; fi if [ "$1" = build ]; then - [ "$3" != "$FAIL_IMAGE" ] + [[ "$3" != "$FAIL_IMAGE":* ]] exit $? fi exit 91 @@ -27,7 +27,8 @@ exit 91 env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, CALL_LOG: log, FAIL_IMAGE: failImage }, encoding: 'utf8', }); - return { ...result, calls: readFileSync(log, 'utf8').trim().split('\n') }; + const project = result.stdout.match(/Isolated integration project: (pulse-e2e-[a-f0-9]+)/)?.[1]; + return { ...result, project, calls: readFileSync(log, 'utf8').trim().split('\n') }; } finally { rmSync(dir, { recursive: true, force: true }); } @@ -38,17 +39,31 @@ test('rebuilds both test images from this checkout even when local tags exist', assert.equal(result.status, 1); // Deliberately unknown suite: never starts services. assert.ok(result.stdout.includes('Unknown suite: invalid-test-suite')); assert.deepEqual(result.calls.filter(call => call.startsWith('build ')), [ - `build -t pulse-mock-github:test ${repo}/tests/integration/mock-github-server`, - `build -t pulse:test --build-arg GO_BUILD_TAGS= -f ${repo}/Dockerfile ${repo}`, + `build -t pulse-mock-github:${result.project} ${repo}/tests/integration/mock-github-server`, + `build -t pulse:${result.project} --build-arg GO_BUILD_TAGS= -f ${repo}/Dockerfile ${repo}`, ]); }); -for (const image of ['pulse-mock-github:test', 'pulse:test']) { +for (const image of ['pulse-mock-github', 'pulse']) { test(`failed ${image} build cannot fall through to a cached image`, () => { const result = run(image); assert.equal(result.status, 1); - assert.ok(result.calls.some(call => call.startsWith(`build -t ${image} `))); + assert.ok(result.calls.some(call => call.startsWith(`build -t ${image}:`))); assert.ok(!result.calls.some(call => call.includes(' up '))); assert.ok(!result.stdout.includes('Starting test environment')); }); } + +test('independent invocations never reuse image/project identities', () => { + const a = run(); + const b = run(); + assert.match(a.project, /^pulse-e2e-[a-f0-9]+$/); + assert.notEqual(a.project, b.project); +}); + +test('failed startup cleanup is scoped to its own project', () => { + const result = run('not-an-image'); + const composeCalls = result.calls.filter(call => call.startsWith('compose ') && call !== 'compose version'); + assert.ok(composeCalls.some(call => call.endsWith('down -v'))); + for (const call of composeCalls) assert.ok(call.includes(`-p ${result.project} `)); +}); diff --git a/tests/integration/scripts/run-tests.sh b/tests/integration/scripts/run-tests.sh index 11bfa4cb0..3df2dd785 100755 --- a/tests/integration/scripts/run-tests.sh +++ b/tests/integration/scripts/run-tests.sh @@ -26,6 +26,17 @@ echo "" cd "$TEST_ROOT" REPO_ROOT="$(cd "$TEST_ROOT/../.." && pwd)" +# Unique per invocation, including simultaneous runs of the same checkout. +# Never inherit another runner's project, image tags or fixed host ports. +export PULSE_E2E_RUN_ID="pulse-e2e-$(node -e "process.stdout.write(require('crypto').randomBytes(16).toString('hex'))")" +export PULSE_E2E_SERVER_IMAGE="pulse:$PULSE_E2E_RUN_ID" +export PULSE_E2E_MOCK_IMAGE="pulse-mock-github:$PULSE_E2E_RUN_ID" +export PULSE_E2E_SERVER_CONTAINER="$PULSE_E2E_RUN_ID-server" +export PULSE_E2E_MOCK_CONTAINER="$PULSE_E2E_RUN_ID-mock" +export PULSE_E2E_SEED_CONTAINER="$PULSE_E2E_RUN_ID-seed" +export PULSE_E2E_PORT=0 PULSE_E2E_AGENT_PORT=0 PULSE_E2E_MOCK_GITHUB_PORT=0 +echo "Isolated integration project: $PULSE_E2E_RUN_ID" + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then COMPOSE_CMD=(docker compose) else @@ -33,7 +44,7 @@ else fi compose() { - "${COMPOSE_CMD[@]}" -f docker-compose.test.yml "$@" + "${COMPOSE_CMD[@]}" -p "$PULSE_E2E_RUN_ID" -f docker-compose.test.yml "$@" } ensure_test_images() { @@ -41,14 +52,24 @@ ensure_test_images() { # sources; Docker can reuse unchanged layers, but tag existence is not # evidence that the image includes the code we are qualifying. echo "Building test images from: $REPO_ROOT" - docker build -t pulse-mock-github:test "$TEST_ROOT/mock-github-server" + docker build -t "$PULSE_E2E_MOCK_IMAGE" "$TEST_ROOT/mock-github-server" # Test image drops the release build tag so the suite can enable mock # fixtures without a demo entitlement. A build failure must stop the run, # never fall back to a previously tagged image. - docker build -t pulse:test --build-arg GO_BUILD_TAGS="" -f "$REPO_ROOT/Dockerfile" "$REPO_ROOT" + docker build -t "$PULSE_E2E_SERVER_IMAGE" --build-arg GO_BUILD_TAGS="" -f "$REPO_ROOT/Dockerfile" "$REPO_ROOT" } +# EXIT also handles build/start/test failures; signals preserve failure status. +cleanup() { + compose down -v || true + docker image rm "$PULSE_E2E_SERVER_IMAGE" "$PULSE_E2E_MOCK_IMAGE" >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP + # Function to run suite with specific mock config run_suite() { local name="$1" @@ -74,9 +95,7 @@ run_suite() { else unset PULSE_E2E_ENTITLEMENT_PROFILE fi - local pulse_base_url="${PULSE_BASE_URL:-http://localhost:${PULSE_E2E_PORT:-7655}}" - pulse_base_url="${pulse_base_url%/}" - local health_url="${pulse_base_url}/api/health" + export PULSE_E2E_PORT=0 PULSE_E2E_AGENT_PORT=0 PULSE_E2E_MOCK_GITHUB_PORT=0 # Start services echo "Starting test environment..." @@ -87,6 +106,22 @@ run_suite() { return 1 fi + # Resolve Docker-allocated ports only after successful startup. A failed + # bind must never fall back to probing a different worktree's listener. + local binding + binding="$(compose port pulse-test 7655)" || return 1 + [[ "$binding" =~ :([0-9]+)$ ]] || return 1 + export PULSE_BASE_URL="http://127.0.0.1:${BASH_REMATCH[1]}" + local pulse_base_url="$PULSE_BASE_URL" + local health_url="$pulse_base_url/api/health" + binding="$(compose port pulse-test 7656)" || return 1 + [[ "$binding" =~ :([0-9]+)$ ]] || return 1 + export PULSE_E2E_AGENT_PORT="${BASH_REMATCH[1]}" + export PULSE_AGENT_BASE_URL="http://127.0.0.1:${BASH_REMATCH[1]}" + binding="$(compose port mock-github 8080)" || return 1 + [[ "$binding" =~ :([0-9]+)$ ]] || return 1 + export MOCK_GITHUB_URL="http://127.0.0.1:${BASH_REMATCH[1]}" + # Wait for services echo "Waiting for services to be ready..." local health_ok=0 @@ -100,7 +135,7 @@ run_suite() { # Check if the Pulse test container is actually running and reachable. local pulse_running - pulse_running="$(docker inspect -f '{{.State.Running}}' pulse-test-server 2>/dev/null || true)" + pulse_running="$(docker inspect -f '{{.State.Running}}' "$PULSE_E2E_SERVER_CONTAINER" 2>/dev/null || true)" if [ "$health_ok" -ne 1 ] || [ "$pulse_running" != "true" ]; then echo -e "${RED}❌ Services failed to start${NC}" compose ps