**Path:** `e2e/CLAUDE.md`

# E2E Tests

For test policy (levels, tiers, tag taxonomy, quarantine pipeline, retry budgets,
anti-gaming rules, wait-pattern rules) see **`TESTING.md`** at repo root.

## Architecture

6 Playwright fixtures, each with its own port, build, and `settings.ts`:

| Fixture | Port | Purpose |
|---|---|---|
| sidebar | 4500 | Sidebar persistence, filter |
| i18n | 4501 | Locale fallback, translation, localized generated resources/assets |
| theme | 4502 | Light/dark toggle, hydration |
| smoke | 4503 | General features (search, TOC, code blocks, mermaid, doc history, etc.) |
| versioning | 4504 | Version switcher, banners |
| hostpanel | 4505 | Host-mounted design token panel (`designTokenPanel: false`, package panel off) |

Configured in `playwright.config.ts`. Each fixture's webServer entry runs `zfb preview` against the pre-built `dist/` produced by `setup-fixtures.sh`.

## Adding Tests

**No new fixture needed in most cases.** The `testMatch` pattern is `${name}*.spec.ts`, so:

- `smoke-search.spec.ts` automatically runs against the smoke fixture
- `sidebar-filter.spec.ts` automatically runs against the sidebar fixture

To add a test: create `e2e/{fixture-name}-{feature}.spec.ts`. No config changes needed.

To add content for tests: add MDX files to the fixture's `src/content/docs/` directory, then enable any needed settings in its `src/config/settings.ts`.

## Two Test Patterns

**Static HTML tests** (no browser needed) — read pre-built `dist/` with `readFileSync`:

```typescript
import { readDistFile } from "./smoke-dist-helper";
const html = readDistFile("docs/some-page/index.html");
expect(html).toContain("expected string");
```

**Browser tests** — use Playwright `page` fixture for interactive features:

```typescript
test("feature works", async ({ page }) => {
  await page.goto("/docs/some-page");
  await expect(page.locator('[aria-label="Search"]')).toBeVisible();
});
```

## Fixture Setup Pipeline (`setup-fixtures.sh`)

Each fixture materializes first-party source from the repo root, while keeping
dependency resolution linked and owning its own content and settings. zfb's
module-preprocessing containment contract requires canonical first-party files
to stay inside the fixture root.

- **Copied at fixture root**: `pages/`, `plugins/` (when present)
- **Symlinked at fixture root**: `packages/`, `node_modules/`
- **Copied under `src/`**: `components/`, `lib/`, `styles/`, `types/`, `utils/`
- **Copied files** (relative imports): `zfb.config.ts`, `tsconfig.json`, `src/chrome-bindings.tsx`, and every `src/config/*.ts | *.tsx` *except* `settings.ts`
- **Fixture-owned override** (optional, kept in git): a fixture that tracks `src/chrome-bindings.fixture.tsx` gets that file materialized as `src/chrome-bindings.tsx` *instead of* the repo-root one; helper modules it imports live in fixture-owned `src/` subdirectories (`FIXTURE_OWNED_SRC_DIRS` in `setup-fixtures.sh`), which setup never overwrites. Only `hostpanel` uses this today — `src/chrome-bindings.fixture.tsx` plus `src/host-panel/` — because it needs a `BodyEndIslands` slot and a `headerRightComponents` trigger for its own design-token-panel island, neither of which belongs in the showcase's real bindings. Both the override file and its owned dirs are hashed into `.build-marker.sha256`, so editing either forces a rebuild. Because every `ChromeBindingsInput` slot is optional, a root `src/chrome-bindings.tsx` change the fixture copy misses fails no build — `pnpm check:chrome-bindings-fixture-drift` (`scripts/check-chrome-bindings-fixture-drift.mjs`, #4321) guards against that silently going stale, gated in b4push (guard-region step 6) and CI (own pure-Node `Chrome Bindings Fixture Drift Check` job).
- **Fixture-specific** (kept in git per fixture): `src/config/settings.ts`, `src/content/`, optionally `public/<fixture-only-files>/`
- **Seed file**: `.zfb/doc-history-meta.json` is created as `{}` so the bundler's static `#doc-history-meta` import resolves on the first run; the doc-history plugin's preBuild hook overwrites it on subsequent builds.

The materialized `e2e/fixtures/*/tsconfig.json` is a gitignored byte-copy of the root
`tsconfig.json` and a **zfb build input** — its `paths` block is what resolves `@/*` and
`#doc-history-meta` inside the fixture bundle, and `zfb build` fails without it. It is NOT
a typecheck target: `tsc -p` against it is expected to be red because it checks the copied
showcase tree against a deliberately minimal `settings.ts` (18 errors in `hostpanel` at
2026-09-19, all of that one class). The fixture typecheck is `pnpm check:e2e` →
`e2e/tsconfig.fixtures.json`, which covers only the tracked fixture-owned sources and works
on a clean checkout via `rootDirs`; fixture `settings.ts` files carry `satisfies
ZudoDocConfig`. `pnpm check:e2e` also type-checks the spec sources themselves
(`e2e/tsconfig.json`) and is gated in b4push (step 22, right after the typecheck
cluster) and CI (a step in the `Type Check` job, after `pnpm check:pages`). Local-only quirk: once a fixture is materialized, `rootDirs` resolves the
override's `../pages/lib/*` to the fixture's own (byte-identical, refreshed on every setup
run) copy first, so a stale materialized `pages/` can make a local run differ from CI —
rerun `setup-fixtures.sh` or delete `e2e/fixtures/hostpanel/pages/`.

Fixture freshness markers hash the current bytes of all fixture-local `src/content/`,
`src/config/settings.ts`, and `public/` files, the shared `packages/zudo-doc/` and
`packages/doc-history-server/` sources, and both packages' built `dist/` trees,
alongside the copied sources, config, lockfile, and setup script. Missing package
artifacts or an unavailable `shasum` force a rebuild; unchanged bytes (including a
bare `touch`) keep a warm fixture fresh.

The i18n fixture keeps its minimal Claude/Codex generator inputs under
`src/content/resources/` so the existing fixture-content hash covers them. Generated
default-locale bodies and per-locale overview/category indexes are ignored build outputs;
each build regenerates them from those committed inputs. Its `public/assets/` corpus
similarly provides the localized asset-viewer route proof without borrowing smoke-fixture
data.

The `theme` fixture removes the copied `pages/docs/` user stub during setup so
its docs route is package-owned. This is load-bearing for tests of fixture-local
`colorSchemes`: the showcase's legacy user-route context deliberately supplies
only the package defaults, while the package route consumes the preset's
virtualized color-scheme payload.

Minimal-scaffold cutover (epic zudolab/zudo-doc#2651, Wave 7 #2663): `zfb-shim.d.ts`
is gone from the copy list — the ambient `zfb/config` types now ship from
`@takazudo/zudo-doc/zfb-config-shim.d.ts`, pulled in transitively via the copied
`tsconfig.json`'s `extends: "@takazudo/zudo-doc/tsconfig.base.json"`. `src/hooks/`,
`src/mocks/`, `src/plugins/`, `src/scripts/` are gone from the shared-copy list — the
host's own `src/` no longer has those subdirectories. Fixture `settings.ts` files

`./settings-types` shim they used to import from was deleted — byte-identical to
the package export). `src/chrome-bindings.tsx` is the host's real chrome-bindings
module (SearchWidget, DocHistory, frontmatter renderers, …), wired via the
`chromeBindingsModule` setting in the copied `zfb.config.ts`; each fixture gets its
own copy so the doc-route stubs under the copied `pages/` (which all call
`createChrome(routeCtx, chromeBindings)`) resolve real bindings instead of the
package's empty-object default — this is what makes the smoke fixture's
doc-history specs work.

All fixtures are pre-built sequentially with `zfb build` (with `SKIP_DOC_HISTORY=1` for non-smoke fixtures) before Playwright runs; the runner then only spawns `zfb preview` per fixture. The smoke fixture also initialises a git repo for doc-history specs (2 commits) and is built with `GEN_DOC_HISTORY=1` so the per-page JSON manifests land in `dist/doc-history/` — the postBuild JSON generation is opt-in for local builds (#1986), so the smoke fixture requests it explicitly.

## Commands

```bash
pnpm test:e2e                                           # Full suite (setup + all tests)
pnpm test:e2e:ci                                        # CI suite (excludes @flaky + @local-only tests)
```

**A bare `npx playwright test` does NOT build fixtures.** `playwright.config.ts` has
no `globalSetup`, and `setup-fixtures.sh` runs only as the first half of the
`test:e2e*` scripts above. Invoking Playwright directly therefore tests whatever
`e2e/fixtures/<name>/dist/` happens to be on disk — which silently means stale HTML
after any fixture-content edit. Run setup yourself first:

```bash
export E2E_FIXTURES=smoke                               # scopes BOTH setup and the runner
bash e2e/setup-fixtures.sh                              # REQUIRED — cheap no-op when warm
npx playwright test --project smoke                     # all tests for that fixture
npx playwright test e2e/smoke-search.spec.ts --project smoke   # or a single file
```

The staleness itself is guarded — `compute_build_hash()` covers each fixture's
`src/content/`, `public/`, package sources, and built package artifacts — but the
marker is only consulted when the script actually runs. The `test:e2e*` package
scripts first run `pnpm ensure:workspace-build` so those artifacts are present and
current before setup checks the markers.

**Fast path**: `E2E_FIXTURES=<name>` scopes both `setup-fixtures.sh` (builds only that fixture) and `playwright.config.ts` (boots only its webServer, zero stagger); repeated runs skip the build when inputs are unchanged (`e2e/fixtures/<name>/.build-marker.sha256` tracks the hash); `E2E_FORCE_REBUILD=1` forces a full rebuild.

## Sidebar Test Helpers

`e2e/sidebar-helpers.ts` exports `desktopSidebar(page)` and `waitForSidebarHydration(page)` for tests that interact with the sidebar zudo-react island.

`e2e/nav-helpers.ts` exports `spaClick`, `spaClickSelector` (SPA navigation via `zfb:after-swap`), and `waitForSidebarNav` (i18n fixture sidebar hydration wait).

## Console-Error Fixture

`e2e/fixtures.ts` exports an extended `test` with a `consoleErrors` fixture (collects `console` type=error + `pageerror`) and an `assertNoConsoleErrors()` helper; import from `"./fixtures"` instead of `"@playwright/test"` when a spec needs zero-error assertions. Every allowlist entry in the `ALLOWLIST` array must have a `reason` string — do not add entries without justification.

## Nightly Exam Dispatch

To trigger the full nightly suite (`exam.yml`) against any branch on demand:

```bash
gh workflow run exam.yml --ref <branch>
```