From f1bdc44ea548b9d71577ee9d9553c7072af0d2cb Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:35:27 +0100 Subject: [PATCH] test: isolate shell integration runs and scope cleanup Shared image tags, container names and host ports let concurrent checkout qualification collide or inspect another runtime. Give each shell integration invocation its own identities and allocated listeners, and scope failure cleanup to those resources so results cannot silently qualify unrelated bytes. Contract-Neutral: Integration harness isolation and documentation only; no user-visible frontend, product contract or release surface changes. Change-source: pulse-maintainer --- tests/integration/QUICK_START.md | 13 +++++ tests/integration/docker-compose.test.yml | 16 +++--- .../scripts/run-tests-images.test.mjs | 27 +++++++--- tests/integration/scripts/run-tests.sh | 49 ++++++++++++++++--- 4 files changed, 84 insertions(+), 21 deletions(-) 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