web: Flesh out frontend, E2E, agent instructions. (#22388)

* Flesh out agent instructions.

* Update heading, localization.

* Add tooling ignores.
This commit is contained in:
Teffen Ellis
2026-06-03 17:18:10 +02:00
committed by GitHub
parent d639c0372e
commit 5a3b447452
9 changed files with 468 additions and 0 deletions
+21
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+50
View File
@@ -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`.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+150
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+95
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md