diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d30da524..90118bcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,6 +131,12 @@ jobs: name: Build runs-on: ubuntu-latest needs: [lint, typecheck] + # The build must not depend on a live backend. `next build` prerenders + # routes (e.g. app/sitemap.ts reads the directory), so point the API base at + # an unreachable sentinel: the reads fail fast and degrade to static output, + # instead of hanging the build when staging is slow or down. + env: + NEXT_PUBLIC_API_BASE_URL: http://127.0.0.1:9999 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index af874a42..9373f100 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,6 +12,13 @@ jobs: e2e: name: Playwright runs-on: ubuntu-latest + # The Playwright suite mocks the backend (page.route) or skips live-only + # specs, so it must not reach staging. Build and serve against an unreachable + # sentinel API base: the app never talks to a real backend, and the mocked + # specs intercept requests to this origin. Keeps CI hermetic and staging + # outages from failing the build/tests. + env: + NEXT_PUBLIC_API_BASE_URL: http://127.0.0.1:9999 steps: - uses: actions/checkout@v4 diff --git a/website/app/sitemap.ts b/website/app/sitemap.ts index a50fd2dc..153de6a3 100644 --- a/website/app/sitemap.ts +++ b/website/app/sitemap.ts @@ -1,6 +1,6 @@ import type { MetadataRoute } from "next"; -import { createClient } from "@src/common/api-client"; +import { createReadOnlyClient } from "@src/common/api-client"; import { profileHref } from "@src/common/profile-link"; import { SITE_URL } from "@src/common/site"; @@ -48,7 +48,9 @@ const STATIC_ROUTES: Array<{ */ async function fetchProfileEntries(now: Date): Promise { try { - const client = createClient(); + // Bounded, retry-free client: a directory outage at build time degrades + // to static routes within seconds instead of hanging `next build`. + const client = createReadOnlyClient(); const { agents } = await client.directory.listAgents({ limit: MAX_PROFILES, }); diff --git a/website/e2e/smoke-mocked-backend.spec.ts b/website/e2e/smoke-mocked-backend.spec.ts new file mode 100644 index 00000000..754db4c7 --- /dev/null +++ b/website/e2e/smoke-mocked-backend.spec.ts @@ -0,0 +1,77 @@ +import { expect, test, type Route } from "@playwright/test"; + +// Hermetic smoke test: the web app must boot and render its shell without any +// live backend. Unlike the other e2e specs (gated behind E2E_LIVE / E2E_X402 / +// API_URL against a real stack), this one ALWAYS runs in CI — it is the guard +// that the build + app never depend on staging. Every backend call is +// intercepted by Playwright before it can reach the network, so no real backend +// is ever contacted; the assertions cover only static, data-independent shell +// content that must render regardless. +// +// The app resolves its API base from NEXT_PUBLIC_API_BASE_URL (an unreachable +// sentinel in CI, staging by default locally). We intercept that origin AND +// staging explicitly, so a real backend is never contacted in any environment. +const API_BASE = + process.env["NEXT_PUBLIC_API_BASE_URL"] ?? "https://staging-api.tiny.place"; + +const BACKEND_GLOBS = [ + `${API_BASE}/**`, + "**/staging-api.tiny.place/**", + "**/127.0.0.1:9999/**", +]; + +const CORS = { + "access-control-allow-origin": "*", + "access-control-allow-headers": "*", + "access-control-allow-methods": "*", +}; + +/** + * Simulate a backend that is simply not there: fulfil CORS preflights, and + * answer everything else with an empty, well-formed body. TanStack Query hooks + * degrade to empty/error states; the static shell renders regardless. We never + * abort (that races page teardown and flakes) — an explicit empty response is + * deterministic. + */ +function emptyBackend(route: Route): Promise { + const request = route.request(); + if (request.method() === "OPTIONS") { + return route.fulfill({ status: 204, headers: CORS }); + } + // GraphQL clients expect a { data } envelope; REST callers tolerate {}. + const body = request.url().includes("/graphql") ? { data: {} } : {}; + return route.fulfill({ + status: 200, + headers: { ...CORS, "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.describe("mocked-backend smoke", () => { + test.beforeEach(async ({ page }) => { + // Intercepting these globs guarantees no request reaches a real backend: + // Playwright fulfils them before the network layer runs. + for (const glob of BACKEND_GLOBS) { + await page.route(glob, emptyBackend); + } + }); + + test("home shell renders with no live backend", async ({ page }) => { + await page.goto("/"); + + // Static hero copy: present regardless of any backend data. Its presence + // proves the layout + i18n + client bootstrap all work with the backend + // fully mocked out. + await expect( + page.getByText("Enter as a Human", { exact: false }) + ).toBeVisible(); + }); + + test("static content route renders offline", async ({ page }) => { + // /terms is server-rendered static section content — a route with no + // backend dependency that must always serve. + const response = await page.goto("/terms"); + expect(response?.status()).toBeLessThan(400); + await expect(page.locator("body")).toBeVisible(); + }); +}); diff --git a/website/src/common/api-client.ts b/website/src/common/api-client.ts index c1c62b7a..224d0c4f 100644 --- a/website/src/common/api-client.ts +++ b/website/src/common/api-client.ts @@ -5,7 +5,7 @@ import { type Signer, } from "@tinyhumansai/tinyplace"; -const API_BASE_URL = +export const API_BASE_URL = process.env["NEXT_PUBLIC_API_BASE_URL"] ?? "https://staging-api.tiny.place"; /** @@ -28,6 +28,23 @@ export function createClient( }); } +/** + * Builds an unauthenticated client for build-time / crawler reads (sitemap, + * static metadata) with a hard time bound and no retries. The default client + * retries idempotent reads twice at a 30s timeout each (up to ~90s), which + * exceeds Next's per-route build timeout — a slow or down backend would then + * hang `next build` instead of degrading gracefully. Callers must still guard + * with try/catch and fall back to static content: this only bounds *how long* + * an outage is allowed to block, never whether it can. + */ +export function createReadOnlyClient(timeoutMs = 8000): TinyPlaceClient { + return new TinyPlaceClient({ + baseUrl: API_BASE_URL, + timeoutMs, + retry: { retries: 0 }, + }); +} + /** * Builds a signer-less TinyPlace client authorized by a bearer onboarding grant. * The onboarding flow (agnostic of any logged-in wallet) uses this to act on the