Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions website/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -48,7 +48,9 @@ const STATIC_ROUTES: Array<{
*/
async function fetchProfileEntries(now: Date): Promise<MetadataRoute.Sitemap> {
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,
});
Expand Down
77 changes: 77 additions & 0 deletions website/e2e/smoke-mocked-backend.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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: {} } : {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return an error or valid terms payload from the mock

When the /terms smoke runs, this REST mock makes client.docs.terms() resolve successfully to {}. Terms treats any truthy data as a real TermsDocument and calls parseTermsSections(data.text, ...), which throws because text is undefined, so the route can client-crash while the test still passes with only <body> visible. Return a non-2xx response for generic REST failures, or a valid TermsDocument for /terms, so this actually exercises the offline fallback.

Useful? React with 👍 / 👎.

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();
});
});
19 changes: 18 additions & 1 deletion website/src/common/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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
Expand Down
Loading