From 5a3b447452b51e0018dce189ed94cf6652997696 Mon Sep 17 00:00:00 2001 From: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:18:10 +0200 Subject: [PATCH] web: Flesh out frontend, E2E, agent instructions. (#22388) * Flesh out agent instructions. * Update heading, localization. * Add tooling ignores. --- web/.gitignore | 21 ++++++ web/AGENTS.md | 148 ++++++++++++++++++++++++++++++++++++ web/CLAUDE.md | 1 + web/test/AGENTS.md | 50 +++++++++++++ web/test/CLAUDE.md | 1 + web/test/browser/AGENTS.md | 150 +++++++++++++++++++++++++++++++++++++ web/test/browser/CLAUDE.md | 1 + web/test/unit/AGENTS.md | 95 +++++++++++++++++++++++ web/test/unit/CLAUDE.md | 1 + 9 files changed, 468 insertions(+) create mode 100644 web/AGENTS.md create mode 100644 web/CLAUDE.md create mode 100644 web/test/AGENTS.md create mode 100644 web/test/CLAUDE.md create mode 100644 web/test/browser/AGENTS.md create mode 100644 web/test/browser/CLAUDE.md create mode 100644 web/test/unit/AGENTS.md create mode 100644 web/test/unit/CLAUDE.md diff --git a/web/.gitignore b/web/.gitignore index c10c9b24b6..d3d5c1738f 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -123,3 +123,24 @@ storybook-static/ .wireit custom-elements.json + +### Agents ### + +AGENT.local.md +AGENTS.local.md +CLAUDE.local.md + +.agents/*.local.json +.agents/scheduled_tasks.* +.agents/worktree + +## Claude + +.claude/*.local.json +.claude/scheduled_tasks.* +.claude/worktree + +## Pi + +.pi +.deps-stamp diff --git a/web/AGENTS.md b/web/AGENTS.md new file mode 100644 index 0000000000..bbf51a2619 --- /dev/null +++ b/web/AGENTS.md @@ -0,0 +1,148 @@ +## Project Overview + +This is the **authentik WebUI** — the default web interface for the authentik identity server. It is a TypeScript monorepo using Lit web components and PatternFly 4 design system. + +There are three distinct UI applications, each with its own base URL and router: + +- **Flow** (`/if/flow/`) — Form orchestration for login, signup, password reset, etc. +- **User** (`/if/user/`) — End-user portal for applications and profile settings +- **Admin** (`/if/admin/`) — Server administration and configuration + +All three share three core context objects: + +- **Config** — Server configuration and user permissions +- **CurrentTenant/Brand** — Theme, logos, favicon, default flows +- **SessionUser** — Logged-in user with impersonation support + +## Commands + +### Development + +```bash +npm run watch # Build + watch locales and bundler (main dev workflow) +npm run storybook # Storybook dev server on port 6006 +``` + +### Build + +```bash +npm run build # Production build to dist/ +npm run build-locales # Compile i18n translations +``` + +### Testing + +```bash +npm test # Vitest: unit tests (Node) + browser tests (Chromium/Playwright) +npm run test:e2e # Playwright E2E tests against a running authentik instance +``` + +To run a single test file: + +```bash +npx vitest run path/to/file.test.ts +``` + +### Linting & Formatting + +```bash +npm run lint # ESLint with --fix +npm run lint-check # ESLint, no fixes (CI mode, max-warnings: 0) +npm run lint:types # TypeScript type checking (tsc --noEmit) +npm run prettier # Format all files +npm run format # Combined prettier + lint +npm run precommit # Full pre-commit check (format, lint, types, etc.) +``` + +## Architecture + +### Directory Structure + +``` +src/ + admin/ # Admin interface application + user/ # User portal application + flow/ # Flow execution interface + FlowExecutor + components/ # UI components that use context (depend on app state) + elements/ # Reusable UI elements without context (portable) + common/ # Non-UI shared libraries (API helpers, global state, utils) + styles/ # Global CSS (PatternFly, authentik tokens, locales) + standalone/ # Third-party apps (loading screen, API browser) + rac/ # Remote Access Components (Guacamole-based) + locales/ # Auto-generated i18n (do not edit manually) + +packages/ + core/ # Monorepo utilities (paths, environment, version) + sfe/ # Standalone Frontend Engine (Rollup-based) + +test/ + unit/ # Node.js unit tests (*.test.ts) + browser/ # Playwright browser tests (*.browser.test.ts) + lit/ # Lit test helpers (renderLit, setup.js) + +e2e/ # E2E test fixtures, selectors, auth utilities +bundler/ # Custom ESBuild/Vite plugins +scripts/ # Build scripts (esbuild config, localization) +``` + +### Key Files + +- `src/elements/Base.ts` — `AKElement`: base class for all components +- `src/elements/Interface.ts` — Base interface class with context management +- `src/common/global.ts` — Global authentik config and state +- `src/flow/FlowExecutor.ts` — Flow execution engine +- `scripts/build-web.mjs` — Main ESBuild configuration + +### Conventions + +- **Custom element prefix**: `ak-` (e.g., ``) +- **Context**: Lit Context API via `ContextControllerRegistry` +- **`components/`** depends on app context; **`elements/`** must not +- **Import aliases**: `#elements/*`, `#components/*`, `#common/*`, `#admin/*`, `#user/*`, `#flow/*`, etc. (mapped in `package.json`) + +NEVER call the authentik API in a different way than using the `@goauthentik/api` package. +In no case are you to use Fetch, Axios, or other methods. + +## Tech Stack + +| Concern | Library | +| ------------------ | ----------------------------------------- | +| UI components | Lit 3.x + Web Components | +| Design system | PatternFly 4 | +| Build | ESBuild + Vite 7 | +| Tests | Vitest 4 + Playwright | +| i18n | Lit Localize (runtime mode, 18 languages) | +| API client | `@goauthentik/api` (generated) | +| Linting | ESLint 9 + `@goauthentik/eslint-config` | +| Task orchestration | Wireit | + +## TypeScript Notes + +- `tsconfig.json` uses `"useDefineForClassFields": false` — required for Lit decorators and Storybook; do not change. +- `"moduleResolution": "bundler"` — path aliases resolved at build time via `package.json#imports`. +- Decorators are enabled with `"experimentalDecorators": true`. +- Use `unknown` instead of `any` where possible, and prefer more specific types to both. Avoid `as any` casts. +- When importing a module, prefer an import alias as defined in `package.json` (`#flow/…`, `#elements/…`, `#common/…`) over relative paths into `src/`. This ensures the import will work from any location, including tests. + +## i18n + +Translatable strings use `msg()` from `@lit/localize`. To add new strings, use `msg()` and run: + +```bash +npm run extract-locales # Extract new strings to XLIFF files +npm run pseudolocalize # Generate pseudo-locales for layout testing +``` + +Never edit files in `src/locales/` directly — they are auto-generated. + +### Message ID conventions + +Always provide an explicit `id` to `msg()`; do not rely on auto-generated hashes. IDs follow `..[.]` with kebab-case in every segment. + +- **Feature-first, not component-first.** Use `captcha.*`, `command-palette.*`, `used-by.*` — not `ak-secret-text-input.*` or other element/class names. IDs must survive component renames. +- **Kebab-case in every segment.** No camelCase (`usedBy`, `ariaLabel`, `emailInAngleBrackets`), no snake_case. `used-by.count.one`, `wizard.aria-label.default`, `user.display.email-in-angle-brackets`. +- **Trailing segment is the semantic role**, not the surface wording: `.label`, `.placeholder`, `.description`, `.tooltip`, `.aria-label`, `.alt-text`, `.error`, `.success`. This lets translators filter by role. +- **CLDR plural suffixes** for counts: `.zero`, `.one`, `.two`, `.few`, `.many`, `.other`. +- **Composable fragments** that get concatenated go under `.prefix.*` / `.suffix.*` (see `command-palette.prefix.*`). +- **Shared strings** go under a top-level namespace like `common.actions.*` or `forms.validation.*` rather than being duplicated per feature. +- **No flat kebab IDs** like `command-palette-placeholder` or `drawer-toggle-button-notifications` for new strings. Use the dotted hierarchy: `command-palette.placeholder`, `drawer.toggle-button.notifications`. Migrate legacy flat IDs opportunistically when touching surrounding code; do not do bulk renames. diff --git a/web/CLAUDE.md b/web/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/web/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/web/test/AGENTS.md b/web/test/AGENTS.md new file mode 100644 index 0000000000..7f063034cb --- /dev/null +++ b/web/test/AGENTS.md @@ -0,0 +1,50 @@ +# Test Directory Router + +This directory holds three flavors of automated tests for the authentik WebUI. Each has its own conventions doc — **read the relevant one before writing or modifying tests there.** + +| Directory | What lives here | Runner / environment | Conventions | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------- | +| `test/unit/` | Pure-Node tests for functions, classes, and modules with no DOM dependency. | Vitest, Node environment. | [`test/unit/AGENTS.md`](unit/AGENTS.md) | +| `test/browser/` | End-to-end tests that drive the admin and user UIs in Chromium against a running authentik instance. | Vitest browser provider (Playwright) + `#e2e` fixtures. | [`test/browser/AGENTS.md`](browser/AGENTS.md) | +| `test/lit/` | Shared Lit render helpers (`renderLit`, `LitViteContext`) for component-level browser tests. No tests live here directly. | — | — | +| `test/blueprints/` | YAML blueprints (e.g. `test-admin-user.yaml`) seeded into authentik for browser tests to authenticate against. | — | — | + +## Picking the right flavor + +Walk this list top-to-bottom and stop at the first match: + +1. **Pure function, no DOM, no network?** → `test/unit/`. Cheap, fast, branch-heavy coverage. See [`unit/AGENTS.md`](unit/AGENTS.md). +2. **A feature flow the user actually clicks through** (wizard, dialog, navigation, list table, login)? → `test/browser/`. Drive the real UI; do not write a unit test with a `@goauthentik/api` client to fake it. See [`browser/AGENTS.md`](browser/AGENTS.md). +3. **Regression for a specific bug?** Find the feature suite in `test/browser/` it belongs to and add another `test(...)` there. Do **not** create a new file scoped to the bug. If the bug is in a pure function, add an `it(...)` to the matching `test/unit/` file instead. +4. **Lit component behavior in isolation** (a component's lifecycle, slots, events, reactive updates, with no whole-app context)? Colocate as `Component.browser.test.ts` next to the source — the Vitest config picks up `**/*.browser.test.ts`, and `test/lit/setup.js` exposes `page.renderLit(...)` for mounting. No consumers exist yet, so check with the team before adding the first one. + +If you're tempted to do something that doesn't fit cleanly into one bucket — a unit test that imports a Lit component, a browser test that calls the REST API to seed data — that's a strong signal you've chosen the wrong bucket. Re-read the conventions doc for the bucket you actually want. + +## Cross-cutting rules + +These apply everywhere in `test/`: + +- **No bespoke API clients.** Never build a `fetch`-based admin client inside a test file. Unit tests don't need one; browser tests must drive the UI; if a real seeding gap exists, extend a fixture or blueprint instead. +- **No hard-coded credentials beyond what's already in fixtures.** Browser tests authenticate via `session.login()` using the bootstrap admin from `test/blueprints/test-admin-user.yaml`. Don't read `process.env.AK_TEST_BOOTSTRAP_TOKEN` from a test. +- **Deterministic naming for entities.** When a browser test creates data, use `IDGenerator.randomID(...)` for uniqueness — see browser conventions. Unit tests should never need this. +- **One file per feature / symbol.** Resist creating one-off files named after a bug, a ticket, or a date. +- **Test names are full sentences.** `"returns null once the input is exhausted"`, `"Create application with existing provider"`. Not `"works"`, not `"#22383"`. + +## Running + +```bash +npm test # Both projects (unit + browser) +npx vitest run test/unit # Just unit tests +npx vitest run test/browser # Just browser tests +npx vitest run path/to/single.test.ts # One file +npm run test:e2e # Playwright e2e CLI path (same test/browser sources) +``` + +Browser tests require a running authentik instance reachable at `AK_TEST_RUNNER_PAGE_URL` (defaults to `http://localhost:9000`). The `prerequisites.setup.ts` health check will fail loudly if it isn't up. + +## Where things live + +- Playwright fixtures (`session`, `navigator`, `form`, `pointer`) and the `#e2e` entry point: `e2e/`. +- Lit render helper for component tests: `test/lit/`. +- Seed blueprints (test admin user, etc.): `test/blueprints/`. +- Generators (`IDGenerator`, `randomName`) used by browser tests: `e2e/utils/generators.ts` and `@goauthentik/core/id`. diff --git a/web/test/CLAUDE.md b/web/test/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/web/test/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/web/test/browser/AGENTS.md b/web/test/browser/AGENTS.md new file mode 100644 index 0000000000..cf1d0cf77e --- /dev/null +++ b/web/test/browser/AGENTS.md @@ -0,0 +1,150 @@ +# Browser Test Conventions + +These are Playwright tests run under Vitest's browser runner (Chromium). They exercise the **admin and user UIs end-to-end** against a running authentik instance. Tests live in `test/browser/*.test.ts`; supporting fixtures and helpers live in `e2e/`. + +## Philosophy + +**Drive the UI, not the API.** A test for a feature should exercise the same path a user takes — click "New Provider", fill the form, click "Create", verify it appears. We don't seed entities through the REST API and then click one button to verify a single side effect. If the UI flow breaks, the test must break with it; if we shortcut through the API, regressions in the wizards, modals, navigation, and form bindings go undetected. + +**Cover features, not bugs.** A test file is named after the feature it exercises (`providers.test.ts`, `applications.test.ts`), not the bug it was written for. Regression tests for specific defects belong inside the feature's existing suite as an additional `test(...)` case — not as a one-off file with bespoke API plumbing. + +**No bespoke HTTP clients.** If you find yourself writing a `makeAPIClient` helper inside a test, stop. Either drive the UI to create the prerequisite state, or — if the prerequisite is truly out of scope for the feature under test — extend a fixture so the pattern is reusable. + +**No explicit cleanup.** Entity names are seeded with `IDGenerator.randomID(...)` so each run produces unique slugs. Stale entities from prior runs don't collide and are expected to accumulate in dev environments. Don't add `try/finally` cleanup blocks — they obscure the assertion at the end of the test and tend to swallow the real failure when the UI flow breaks. + +## Imports + +Tests import from the `#e2e` alias, never from `@playwright/test` directly: + +```ts +import { expect, test } from "#e2e"; +import { randomName } from "#e2e/utils/generators"; + +import { IDGenerator } from "@goauthentik/core/id"; +import { series } from "@goauthentik/core/promises"; +``` + +The `#e2e` entry (`e2e/index.ts`) re-exports `expect` from Playwright and exports a `test` that has been extended with our fixtures. + +## Fixtures + +Destructure what you need from the test callback. All are constructed per-test: + +| Fixture | Purpose | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `session` | `login({ to, username?, password?, rememberMe? })`, `toLoginPage()`, `checkAuthenticated()`. Defaults to `test-admin@goauthentik.io` / `test-runner`. | +| `navigator` | `navigate(to)` and `waitForPathname(to)` — use these over `page.goto` so URL waits are consistent. | +| `form` | `fill(label, value, ctx?)`, `search(query, ctx?)`, `selectSearchValue(label, pattern, ctx?)`, `setInputCheck(label, bool, ctx?)`, `setRadio(group, name, ctx?)`, `setFormGroup(pattern, open, ctx?)`. Knows about `ak-switch-input`, `ak-form-group`, and search-select dropdowns. | +| `pointer` | `click(name, role?, ctx?)` — high-level click by accessible name; defaults to buttons/links. | +| `page` | Raw Playwright `Page` for anything the fixtures don't cover. Shadow DOM is pierced automatically. | +| `baseURL` | The instance URL, from `AK_TEST_RUNNER_PAGE_URL` (defaults to `http://localhost:9000`). | + +Most steps in most tests should go through `form` and `pointer`. Reach for `page.locator(...)` only when there isn't a fixture method that fits. + +## Shape of a test + +```ts +test.describe("Feature name", () => { + const names = new Map(); + + test.beforeEach("Seed names", async ({ page: _page }, { testId }) => { + const seed = IDGenerator.randomID(6); + names.set(testId, `${randomName(seed)} (${seed})`); + }); + + test("Do the thing", async ({ session, navigator, form, pointer, page }, testInfo) => { + const name = names.get(testInfo.testId)!; + const { fill, search, selectSearchValue } = form; + const { click } = pointer; + + await test.step("Authenticate", async () => { + await session.login({ to: "/if/admin/#/core/providers" }); + }); + + const dialog = page.getByRole("dialog", { name: "New Provider Wizard" }); + + await test.step("Open wizard", async () => { + await expect(dialog, "Wizard is initially closed").toBeHidden(); + await click("New Provider"); + await expect(dialog, "Wizard opens").toBeVisible(); + }); + + await test.step("Fill form", async () => { + await series( + [click, "OAuth2/OpenID", "option"], + [fill, "Provider Name", name], + [ + selectSearchValue, + "Authorization Flow", + /default-provider-authorization-explicit-consent/, + ], + [click, "Create"], + ); + }); + + await test.step("Verify created", async () => { + await expect(await search(name), "Provider is visible").toBeVisible(); + }); + }); +}); +``` + +Conventions baked in above: + +- **`test.describe` per feature**, plain imperative names per test. +- **`test.step(...)` for every meaningful phase** — these show up in traces and HTML reports and make failures self-locating. +- **Names keyed by `testId`** in a module-scoped `Map`, populated in `beforeEach`. +- **`series([fn, ...args], ...)`** for ordered form-fill sequences. Reads top-to-bottom as a script of user actions. +- **Dialog locator captured once**, then passed as the `ctx?` argument to scope `fill`/`click`/`selectSearchValue` inside it. +- **Every `expect` has a message** as the second argument — it shows up in the failure output. Phrase it as the property being asserted ("Wizard opens", "Provider is visible"), not as a restatement of the matcher. +- **First parameter must be a destructure pattern**, even when you don't reference any fixture — write `async ({ page: _page }, { testId }) => {…}`. A bare identifier (`async (_, { testId }) => {…}`) throws `First argument must use the object destructuring pattern` at runtime because Playwright inspects the parameter pattern to decide which fixtures to inject, and an empty destructure (`async ({}, { testId }) => {…}`) trips ESLint's `no-empty-pattern`. Destructure-and-rename is the only form that satisfies both. + +## Locator preferences + +In order, prefer: + +1. **ARIA role queries** — `page.getByRole("button", { name: "Create" })`, `page.getByRole("dialog", { name: /Launch Endpoint/i })`, `page.getByLabel("Username")`. These survive style/markup changes and document intent. +2. **Web component tags** — `page.locator("ak-stage-identification")`, `page.locator("ak-form-group", { hasText: /Advanced/ })`. Stable element contracts. +3. **`data-test-id`** — `page.getByTestId("...")`. The Playwright config sets `testIdAttribute: "data-test-id"`. Only add a new test id when role/label queries can't disambiguate. +4. **CSS selectors** — last resort. + +Shadow DOM works transparently — don't write `.shadowRoot` traversals; Playwright pierces. + +## Assertions + +```ts +await expect(dialog, "Dialog is initially closed").toBeHidden(); +await expect(dialog, "Dialog opens").toBeVisible(); +await expect(row, "Endpoint row appears without manual refresh").toBeVisible({ timeout: 5_000 }); +await expect(input, "Input has expected value").toHaveValue("foo"); +await expect(checkbox, "Checkbox is checked").toBeChecked(); +``` + +- Always pass a message. +- Use explicit `{ timeout: ... }` only when the default (5s) genuinely isn't enough — generally for the first assertion after an async UI transition like a dialog mount or a navigation. +- Don't add `page.waitForTimeout` — wait for the locator condition you actually care about. + +## Anti-patterns (do not do these) + +- **Bespoke API clients in test files.** No `makeAPIClient`, no raw `fetch(`${baseURL}/api/v3/...`)` for setup. See [Philosophy](#philosophy). +- **Reading `process.env.AK_TEST_BOOTSTRAP_TOKEN`** from a test. Tests authenticate as a real user via `session.login()`. +- **One-file regression tests for a single bug.** Add a `test(...)` case to the relevant feature suite instead. +- **`try/finally` cleanup blocks.** Names are randomized; let entities accumulate. +- **`page.goto` with no wait.** Use `navigator.navigate(to)` or `session.login({ to })`. +- **Asserting against CSS selectors when a role/label exists.** If you find yourself writing `.locator('button[type="submit"]')`, check whether `getByRole("button", { name: ... })` works first. +- **Skipping `test.step`.** Long flat tests are hard to debug; wrap each phase. + +## Adding new coverage + +When extending an existing suite, follow the surrounding patterns — same fixture destructure, same `Map` style, same dialog-as-context idiom. When introducing a new suite, model the structure on `applications.test.ts` or `providers.test.ts`; those are the canonical examples. + +If you need a helper that doesn't exist yet (a new form input shape, a new common navigation), extend the fixture in `e2e/fixtures/` rather than duplicating logic in tests. + +## Running + +```bash +npm test # All Vitest (unit + browser) +npx vitest run test/browser/foo.test.ts # Single browser test file +``` + +The Playwright config (`playwright.config.js`) is also present for the `npm run test:e2e` path and configures Chromium with traces on first retry and a dark color scheme. The browser tests through Vitest use `@vitest/browser-playwright` and target the same `test/browser/` directory. diff --git a/web/test/browser/CLAUDE.md b/web/test/browser/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/web/test/browser/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/web/test/unit/AGENTS.md b/web/test/unit/AGENTS.md new file mode 100644 index 0000000000..ac1e2fc083 --- /dev/null +++ b/web/test/unit/AGENTS.md @@ -0,0 +1,95 @@ +# Unit Test Conventions + +Pure-Node, no-browser tests for individual functions, pure logic, and modules with no DOM dependencies. Runs under Vitest's Node environment — no Playwright, no Lit rendering, no live authentik instance. + +## When a unit test is the right tool + +- The thing under test is a **plain function or class** with no DOM, network, or component lifecycle. +- You want to cover **branches, edge cases, error paths, and invariants** thoroughly and fast. +- The behavior is deterministic given inputs — no timers, no external services, no `customElements.define`. + +If the answer involves rendering a Lit component, clicking something, awaiting network, or asserting against the DOM, it does not belong here. Push it to a colocated Lit component test or to `test/browser/`. + +## File layout + +- Files live in `test/unit/*.test.ts`. +- One file per module/feature under test — name it after the symbol or module (`lexer.test.ts`, `authenticator-validate-challenge-selection.test.ts`). +- The Vitest config also picks up `**/*.unit.test.ts` anywhere in the workspace, so a tightly-coupled test may be colocated next to its source as `foo.unit.test.ts` when that's clearer than a parallel `test/unit/` file. + +## Imports + +```ts +import { describe, expect, it, vi } from "vitest"; + +import { shouldResetSelectedChallenge } from "#flow/stages/authenticator_validate/challenge-selection"; +``` + +- Use `describe` / `it` / `expect` from `vitest`. Do **not** import `test`/`expect` from `#e2e` — that's for browser tests and pulls in Playwright. +- Reach into source via the package `#alias` imports (`#flow/…`, `#elements/…`, `#common/…`) — never relative paths into `src/`. +- Use `vi` for spies, mocks, and timers. Prefer real implementations; only mock at module boundaries that actually pose a problem (network, time, randomness). + +## Shape of a test + +```ts +describe("shouldResetSelectedChallenge", () => { + it("returns true when the previously selected challenge is no longer allowed", () => { + const selected = makeDeviceChallenge(DeviceClassesEnum.Email, "email-1"); + const allowed = [ + makeDeviceChallenge(DeviceClassesEnum.Totp, "totp-1"), + makeDeviceChallenge(DeviceClassesEnum.Webauthn, "webauthn-1"), + ]; + + expect(shouldResetSelectedChallenge(selected, allowed)).toBe(true); + }); + + it("returns false when the previously selected challenge is still allowed", () => { ... }); + it("returns false when there was no selected challenge", () => { ... }); +}); +``` + +Conventions: + +- **`describe(symbolName)`** at the top, optionally nested by method or behavior (`describe("addRule")`, `describe("tokenization")`, `describe("states")` — see `lexer.test.ts`). +- **`it("returns X when Y")`** — full sentences starting with the verb. State both the outcome and the precondition. Bad: `"works"`, `"handles nulls"`. Good: `"returns null once the input is exhausted"`, `"rolls back the lexer index when an action rejects"`. +- **Arrange / act / assert** with a blank line between phases where it improves scanability. Inline factories like `makeDeviceChallenge(...)` for repeated test-data shapes — keep them at the top of the file, not in shared helpers, until two files need the same one. +- **One concept per `it`.** If you reach for "and" in the name, split it. +- **No assertion messages** on `expect()` in unit tests. The test name and matcher already describe intent; Vitest's output is sufficient. + +## Assertions + +Plain Vitest matchers — `toBe`, `toEqual`, `toBeNull`, `toBeTruthy`, `toThrow(/regex/)`, `toHaveBeenCalledTimes`, etc. Use: + +- `toBe` for primitives and reference identity. +- `toEqual` for structural equality. +- `toThrow(/regex/)` for error paths — match a stable fragment of the message, not the whole thing. +- `.mock.calls[i]?.[j]` to assert on spy arguments precisely. + +## Mocking and spies + +```ts +const defunct = vi.fn((chr: string) => `?${chr}`); +expect(defunct).toHaveBeenCalledTimes(2); +expect(defunct.mock.calls[0]?.[0]).toBe("@"); +``` + +- Prefer constructing test doubles inline with `vi.fn()` over module-level `vi.mock(...)`. +- Reach for `vi.useFakeTimers()` only when the code under test reads the clock — don't preemptively fake time. +- If you need `vi.mock("module")`, hoist it to the top of the file and explain _why_ in a one-line comment if the reason isn't obvious from the import. + +## What NOT to do here + +- **Do not import from `@playwright/test` or `#e2e`.** Those are for browser tests. +- **Do not call `customElements.define` or import Lit components.** The Node environment has no DOM. Component coverage belongs in `test/browser/` (or a `.browser.test.ts` colocated with the component, once the Lit render helper has a real consumer). +- **Do not hit the network or filesystem.** Pure-function tests; if the unit needs IO, you're testing the wrong layer. +- **Do not silently pass on `try/catch`.** Use `expect(() => …).toThrow(...)` for error paths so a missing throw fails the test. +- **Do not assert against snapshots** unless the output is a stable, intentional artifact (e.g. a token stream). Snapshots rot fast when used as a substitute for thinking about the contract. + +## Running + +```bash +npx vitest run test/unit # All unit tests +npx vitest run test/unit/lexer.test.ts # One file +npx vitest test/unit/lexer.test.ts -t "tokenization" # Filter by name +``` + +The `npm test` script runs both the unit and browser projects; for fast iteration on a pure-logic change, run the single file directly. diff --git a/web/test/unit/CLAUDE.md b/web/test/unit/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/web/test/unit/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md