From 02a21a3bb05111e4cc1bc1bc513f0d4da2b7f80a Mon Sep 17 00:00:00 2001 From: Sheygoodbai <143479205+Sheygoodbai@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:29:42 +0800 Subject: [PATCH] fix: harden production deploy smoke coverage --- .github/workflows/deploy.yml | 8 ++- e2e/prod-http-smoke.e2e.test.ts | 84 +++++++++++++++++++++----- e2e/publish-entry-workflows.pw.test.ts | 3 +- 3 files changed, 78 insertions(+), 17 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d5f07606c..512406b95 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -165,12 +165,16 @@ jobs: if: needs.validate-deploy-request.outputs.run_smoke == 'true' run: bunx playwright install --with-deps chromium + - name: Smoke test production HTTP + if: needs.validate-deploy-request.outputs.run_smoke == 'true' + run: bun run test:e2e:prod-http + - name: Write authenticated storage state if: needs.validate-deploy-request.outputs.run_smoke == 'true' && env.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON != '' run: | echo "$PLAYWRIGHT_AUTH_STORAGE_STATE_JSON" > "$RUNNER_TEMP/playwright-auth.json" echo "PLAYWRIGHT_AUTH_STORAGE_STATE=$RUNNER_TEMP/playwright-auth.json" >> "$GITHUB_ENV" - - name: Smoke test production + - name: Smoke test production UI if: needs.validate-deploy-request.outputs.run_smoke == 'true' - run: bunx playwright test e2e/menu-smoke.pw.test.ts e2e/upload-auth-smoke.pw.test.ts + run: bunx playwright test e2e/menu-smoke.pw.test.ts e2e/publish-entry-workflows.pw.test.ts e2e/upload-auth-smoke.pw.test.ts diff --git a/e2e/prod-http-smoke.e2e.test.ts b/e2e/prod-http-smoke.e2e.test.ts index 268242b74..06d82b5e5 100644 --- a/e2e/prod-http-smoke.e2e.test.ts +++ b/e2e/prod-http-smoke.e2e.test.ts @@ -4,6 +4,8 @@ import { Agent, setGlobalDispatcher } from "undici"; import { describe, expect, it } from "vitest"; const REQUEST_TIMEOUT_MS = 15_000; +const MAX_RATE_LIMIT_RETRIES = 3; +const MAX_RATE_LIMIT_WAIT_MS = 15_000; try { setGlobalDispatcher( @@ -39,8 +41,52 @@ async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) { } } +function parsePositiveNumber(value: string | null) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function getRetryDelayMs(response: Response) { + const retryAfterSeconds = parsePositiveNumber(response.headers.get("Retry-After")); + if (retryAfterSeconds !== null) { + return Math.min(retryAfterSeconds * 1000, MAX_RATE_LIMIT_WAIT_MS); + } + + const relativeResetSeconds = parsePositiveNumber(response.headers.get("RateLimit-Reset")); + if (relativeResetSeconds !== null) { + return Math.min(relativeResetSeconds * 1000, MAX_RATE_LIMIT_WAIT_MS); + } + + const absoluteResetSeconds = parsePositiveNumber(response.headers.get("X-RateLimit-Reset")); + if (absoluteResetSeconds !== null) { + return Math.min(Math.max(absoluteResetSeconds * 1000 - Date.now(), 0), MAX_RATE_LIMIT_WAIT_MS); + } + + return 1000; +} + +async function fetchWithRetry(input: RequestInfo | URL, init?: RequestInit) { + let lastResponse: Response | null = null; + + for (let attempt = 0; attempt < MAX_RATE_LIMIT_RETRIES; attempt += 1) { + const response = await fetchWithTimeout(input, init); + if (response.status !== 429) return response; + + lastResponse = response; + if (attempt === MAX_RATE_LIMIT_RETRIES - 1) return response; + + await new Promise((resolve) => setTimeout(resolve, getRetryDelayMs(response))); + } + + if (!lastResponse) { + throw new Error("Expected a response while retrying rate-limited request."); + } + + return lastResponse; +} + async function fetchHtml(pathname: string) { - const response = await fetchWithTimeout(new URL(pathname, getSiteBase()), { + const response = await fetchWithRetry(new URL(pathname, getSiteBase()), { headers: { Accept: "text/html" }, }); expect(response.ok).toBe(true); @@ -48,19 +94,29 @@ async function fetchHtml(pathname: string) { return response.text(); } +type SkillDetailResponse = { + skill: { slug: string; displayName: string; summary: string | null }; + latestVersion: { version: string | null } | null; + owner: { handle: string | null }; +}; + +let skillDetailPromise: Promise | null = null; + async function fetchSkillDetail() { - const response = await fetchWithTimeout( - new URL(`/api/v1/skills/${getSkillSlug()}`, getSiteBase()), - { - headers: { Accept: "application/json" }, - }, - ); - expect(response.ok).toBe(true); - return (await response.json()) as { - skill: { slug: string; displayName: string; summary: string | null }; - latestVersion: { version: string | null } | null; - owner: { handle: string | null }; - }; + if (!skillDetailPromise) { + skillDetailPromise = (async () => { + const response = await fetchWithRetry( + new URL(`/api/v1/skills/${getSkillSlug()}`, getSiteBase()), + { + headers: { Accept: "application/json" }, + }, + ); + expect(response.ok).toBe(true); + return (await response.json()) as SkillDetailResponse; + })(); + } + + return skillDetailPromise; } describe("prod http smoke", () => { @@ -99,7 +155,7 @@ describe("prod http smoke", () => { params.set("version", detail.latestVersion.version); } - const response = await fetchWithTimeout( + const response = await fetchWithRetry( new URL(`/og/skill.png?${params.toString()}`, getSiteBase()), ); diff --git a/e2e/publish-entry-workflows.pw.test.ts b/e2e/publish-entry-workflows.pw.test.ts index e55f24f33..fef1628ec 100644 --- a/e2e/publish-entry-workflows.pw.test.ts +++ b/e2e/publish-entry-workflows.pw.test.ts @@ -5,7 +5,8 @@ test("upload shows signed-out publish gate", async ({ page }) => { const errors = trackRuntimeErrors(page); await page.goto("/upload", { waitUntil: "domcontentloaded" }); - await expect(page.getByText(/Sign in to upload a skill\./i)).toBeVisible(); + await expect(page).toHaveURL(/\/publish-skill$/); + await expect(page.getByText("Sign in to publish a skill.")).toBeVisible(); await expectHealthyPage(page, errors); });