mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
web: Flesh out frontend, E2E, agent instructions. (#22388)
* Flesh out agent instructions. * Update heading, localization. * Add tooling ignores.
This commit is contained in:
@@ -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
|
||||
|
||||
+148
@@ -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., `<ak-command-palette>`)
|
||||
- **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 `<feature>.<subfeature>.<role>[.<modifier>]` 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.
|
||||
@@ -0,0 +1 @@
|
||||
@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`.
|
||||
@@ -0,0 +1 @@
|
||||
@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<string, string>();
|
||||
|
||||
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<testId, name>` 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.
|
||||
@@ -0,0 +1 @@
|
||||
@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.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
Reference in New Issue
Block a user