From 5fe6bfde577bbb5823923135a842dd7ab3d737e2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 9 Jul 2026 12:50:55 +0100 Subject: [PATCH] Harden release integration diagnostics and login retries --- .github/workflows/create-release.yml | 36 ++++++++++++++++++++ tests/integration/tests/helpers.ts | 49 +++++++++++++++++++--------- 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index dd032d5fb..13f99d970 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -582,6 +582,42 @@ jobs: docker compose -f docker-compose.test.yml down -v + - name: Collect integration diagnostics + if: failure() + working-directory: tests/integration + run: | + mkdir -p release-integration-diagnostics + { + echo "=== Docker containers ===" + docker ps -a || true + echo + echo "=== Pulse test server logs ===" + docker logs pulse-test-server 2>&1 || echo "No pulse-test-server container" + echo + echo "=== Mock GitHub server logs ===" + docker logs pulse-mock-github 2>&1 || echo "No pulse-mock-github container" + } | tee release-integration-diagnostics/docker.log + + - name: Upload integration Playwright report + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-integration-playwright-report + path: tests/integration/playwright-report/ + if-no-files-found: ignore + retention-days: 14 + + - name: Upload integration failures + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-integration-failures + path: | + tests/integration/test-results/ + tests/integration/release-integration-diagnostics/ + if-no-files-found: ignore + retention-days: 14 + - name: Cleanup if: always() working-directory: tests/integration diff --git a/tests/integration/tests/helpers.ts b/tests/integration/tests/helpers.ts index 6e12d721b..e1dc0f3d2 100644 --- a/tests/integration/tests/helpers.ts +++ b/tests/integration/tests/helpers.ts @@ -948,10 +948,33 @@ export async function login(page: Page, credentials = E2E_CREDENTIALS) { return "timeout waiting for authenticated app state after login submission"; }; + const prepareFreshLoginAttempt = async (): Promise<"login" | "authenticated" | "unknown"> => { + await waitForPulseReady(page); + await page.goto("/"); + await waitForAppShell(page); + + const retryState = await Promise.race([ + usernameInput + .waitFor({ state: "visible", timeout: 15_000 }) + .then(() => "login" as const) + .catch(() => undefined), + page + .waitForURL(AUTHENTICATED_URL, { timeout: 15_000 }) + .then(() => "authenticated" as const) + .catch(() => undefined), + ]); + + return retryState ?? "unknown"; + }; + + const isRetryableLoginOutcome = (outcome: string): boolean => + /too many|failed to connect to server|server error|timeout waiting/i.test(outcome); + // The backend allows 10 login attempts per minute per IP and counts // successful ones, so bursts of session logins (worker fixtures plus - // per-test session auth) can transiently trip the limiter. Back off and - // retry through a fresh login form. + // per-test session auth) can transiently trip the limiter. CI can also see + // a short transport miss while the compose service is healthy-but-settling. + // Back off and retry through a fresh login form. const MAX_LOGIN_ATTEMPTS = 3; let lastOutcome = "pending"; for (let attempt = 1; attempt <= MAX_LOGIN_ATTEMPTS; attempt++) { @@ -959,23 +982,17 @@ export async function login(page: Page, credentials = E2E_CREDENTIALS) { if (lastOutcome === "authenticated") { return; } - if (/too many/i.test(lastOutcome) && attempt < MAX_LOGIN_ATTEMPTS) { - await page.waitForTimeout(15_000); - await page.goto("/"); - await waitForAppShell(page); - const retryState = await Promise.race([ - usernameInput - .waitFor({ state: "visible", timeout: 15_000 }) - .then(() => "login") - .catch(() => undefined), - page - .waitForURL(AUTHENTICATED_URL, { timeout: 15_000 }) - .then(() => "authenticated") - .catch(() => undefined), - ]); + if (attempt < MAX_LOGIN_ATTEMPTS && isRetryableLoginOutcome(lastOutcome)) { + const backoffMs = /too many/i.test(lastOutcome) ? 15_000 : 2_000 * attempt; + await page.waitForTimeout(backoffMs); + const retryState = await prepareFreshLoginAttempt(); if (retryState === "authenticated") { return; } + if (retryState !== "login") { + lastOutcome = `retry login form unavailable after ${lastOutcome}`; + break; + } continue; } break;