# E2E Testing with Playwright

## Directory Structure

```bash
frontend/e2e/
  fixtures/
    user.ts                    # Actor union + useActor() sign-in helper — base for all spec fixtures
    item-lifecycle.ts          # fixtures specific to item-lifecycle.spec.ts
    user-management.ts         # fixtures specific to user-management.spec.ts
    ...                        # one fixture file per spec file (same basename)
  pages/                       # page objects — one per screen, grouped by domain
    auth/login.ts
    item/item-list.ts
    item/item-form.ts
    item/item-detail.ts
    user/user-list.ts
    ...
  tests/                       # spec files — one per business flow
    item-lifecycle.spec.ts
    user-management.spec.ts
    ...
  utils/
    user-factory.ts            # createUser helper + TestUser type
    role-factory.ts            # createRole helper
```

## Sync Check Rules

`erp-kit app sync-check` enforces:

- Each **screen doc** (`docs/screen/<name>.md`) must have a matching **page object** (`e2e/pages/**/<name>.ts`)
- Each **actor** in the `Actor` type union (`e2e/fixtures/user.ts`) must have a matching **actor doc** (`docs/actor/<name>.md`)
- Page objects **without** a screen doc are allowed (e.g., `login.ts` for platform-provided screens)
- Each **business flow** (`docs/business-flow/<flow>/README.md`) must have a matching spec file (`tests/<flow>.spec.ts`), and each spec file must match a business flow. Spec content is not checked — whether the journey covers the flow's `## Flow Diagram` main path is a review concern

## Login Reuse (one account per parallel worker)

**Never log in per test** — a full sign-in takes seconds. But don't share one
global account either: if the same account signs in from several parallel workers
at once, the logins contend and time out, and tests that mutate shared state
collide.

Use Playwright's **one account per parallel worker**: a worker-scoped fixture
creates a unique account per actor (keyed by `parallelIndex`) and signs in once,
then every test in that worker reuses it. Tests in a worker run serially, so the
account is never used concurrently; different workers use different accounts, so
no account logs in twice at once.

| What | Scope |
| --- | --- |
| One account + sign-in | per (worker, actor) — lazily, only for actors used |
| Reuse | every test in that worker |

A test that **mutates or asserts its own record** (e.g. own-profile) must create a
dedicated account in-test, so it doesn't disturb the worker's shared account.

Use `fullyParallel: true` so tests spread across all workers. Sign-in stays
reused: `signInAs` is worker-scoped, so each worker signs in once per actor no
matter how tests are distributed — test-level parallelism costs more workers, not
more sign-ins.

### Actor definitions — `e2e/fixtures/user.ts`

The `Actor` union is the source of truth for actor names (sync-check matches it to
the actor docs). Select the actor at file scope with `useActor(test, actor)`; read
the `user` fixture when a test needs the signed-in account's details (e.g. email).

```ts
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { test as base, expect } from "@playwright/test";
import { LoginPage } from "../pages/auth/login";
import { createUser, type TestUser } from "../utils/user-factory";

export type Actor = "admin" | "general-user";

const ACTOR_PERMISSIONS: Record<Actor, string[]> = {
  admin: ["item-management:item", "user-management:user" /* broad */],
  "general-user": ["item-management:item:createItem", "user-management:profile" /* narrow */],
};
export { ACTOR_PERMISSIONS };

// `baseURL` is test-scoped, so the worker fixture below reads it from env instead.
function resolveBaseURL(): string {
  return process.env.PLAYWRIGHT_BASE_URL ?? process.env.E2E_BASE_URL ?? "http://localhost:5173";
}

interface TestFixtures {
  actor: Actor;
  user: TestUser;
}
interface WorkerFixtures {
  signInAs: (actor: Actor) => Promise<{ user: TestUser; storageState: string }>;
}

export const test = base.extend<TestFixtures, WorkerFixtures>({
  // One account + sign-in per (worker, actor), created lazily and reused across
  // the worker's tests. parallelIndex keeps the account unique per worker, so the
  // same account never logs in from two workers at once. Tests in a worker run
  // serially, so the shared account is never used concurrently.
  signInAs: [
    async ({ browser }, use, workerInfo) => {
      const cache = new Map<Actor, { user: TestUser; storageState: string }>();
      const signIn = async (actor: Actor) => {
        const hit = cache.get(actor);
        if (hit) return hit;
        const user = await createUser({ permissions: ACTOR_PERMISSIONS[actor] });
        const storageState = path.join(os.tmpdir(), `e2e-auth-w${workerInfo.parallelIndex}-${actor}.json`);
        // newContext inherits the project storageState option; start clean and
        // let LoginPage sign in, then persist the session for this worker.
        const context = await browser.newContext({
          baseURL: resolveBaseURL(),
          storageState: { cookies: [], origins: [] },
        });
        const page = await context.newPage();
        await new LoginPage(page).login(user.email, user.password);
        await context.storageState({ path: storageState });
        await context.close();
        const entry = { user, storageState };
        cache.set(actor, entry);
        return entry;
      };
      await use(signIn);
      for (const { storageState } of cache.values()) {
        fs.rmSync(storageState, { force: true });
      }
    },
    { scope: "worker" },
  ],

  // Default actor; override per spec file with `useActor(test, actor)`.
  actor: ["general-user", { option: true }],

  // Restore this worker's session for the selected actor.
  storageState: async ({ actor, signInAs }, use) => {
    const { storageState } = await signInAs(actor);
    await use(storageState);
  },

  // The signed-in user — e.g. to assert one's own email.
  user: async ({ actor, signInAs }, use) => {
    const { user } = await signInAs(actor);
    await use(user);
  },

  // Start each test signed in; re-login (and refresh the saved state) if the
  // restored session was rejected.
  page: async ({ page, actor, signInAs }, use) => {
    await page.goto("/");
    const loggedIn = page.getByRole("link", { name: /items/i }); // app's signed-in nav link
    const signInButton = page.getByRole("button", { name: "Sign in" });
    let needsLogin: boolean;
    try {
      await expect(loggedIn.or(signInButton)).toBeVisible({ timeout: 30_000 });
      needsLogin = await signInButton.isVisible();
    } catch {
      needsLogin = true;
    }
    if (needsLogin) {
      const { user, storageState } = await signInAs(actor);
      await new LoginPage(page).login(user.email, user.password);
      await page.context().storageState({ path: storageState });
    }
    await use(page);
  },
});

export { expect };

/** The base test or a per-spec extension of it. */
export type AppTest = typeof test;

/** Run this spec file's tests signed in as `actor`. */
export function useActor(test: AppTest, actor: Actor): void {
  test.use({ actor });
}
```

Pass the file's own `test` (some spec fixtures extend it with extra fixtures) so
its hooks and `use` attach to that object.

### playwright.config.ts — projects, workers, base URL

```ts
fullyParallel: true,         // spread tests across workers; signInAs stays worker-scoped
projects: [
  { name: "chromium", testMatch: /tests\/.*\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
],
workers: process.env.CI ? 16 : undefined,   // network-bound on a remote preview → more workers than cores
use: { baseURL: resolveBaseURL(), trace: "on-first-retry" },
```

- **Workers** — the suite runs as one job; `fullyParallel` spreads every test
  across `workers`, and the dynamic scheduler balances them better than a static
  split by file.
- **Base URL** — `resolveBaseURL()` reads `PLAYWRIGHT_BASE_URL` / `E2E_BASE_URL`
  (set by global-setup), else the local dev server.
- **trace `on-first-retry`** — capture a debug trace only for retried (failed) tests; no video, to keep artifacts light.

## Spec-Specific Fixtures

Each spec file has a corresponding fixture file with the same basename under `e2e/fixtures/`. The fixture file extends `user.ts` and defines test-scoped fixtures needed by that spec.

```ts
// e2e/fixtures/item-lifecycle.ts
import { createItem } from "../utils/item-factory";
import { test as base } from "./user";

type ItemFixture = Awaited<ReturnType<typeof createItem>>;

interface TestFixtures {
  DraftItem: ItemFixture;
  ActiveItem: ItemFixture;
}

export const test = base.extend<TestFixtures>({
  // oxlint-disable-next-line no-empty-pattern
  DraftItem: async ({}, use) => {
    const item = await createItem({ status: "draft" });
    await use(item);
  },
  // oxlint-disable-next-line no-empty-pattern
  ActiveItem: async ({}, use) => {
    const item = await createItem({ status: "active" });
    await use(item);
  },
});

export const expect = test.expect;
```

Spec files import from their own fixture file, not from a shared index:

```ts
// e2e/tests/item-lifecycle.spec.ts
import { test, expect } from "../fixtures/item-lifecycle";
```

If a spec only needs actor fixtures (no spec-specific data), import directly from `user.ts`:

```ts
import { test, expect } from "../fixtures/user";
```

## Page Objects

Each page object is a class that wraps Playwright locators for a single screen. Group by domain under `e2e/pages/`.

**Important:** Do not use TypeScript parameter properties (`constructor(private page: Page)`). Use explicit field declaration — `erasableSyntaxOnly` is enabled.

### ListView Page Object

```ts
import type { Page } from "@playwright/test";

export class ItemListPage {
  private page: Page;
  constructor(page: Page) {
    this.page = page;
  }

  async goto() {
    await this.page.goto("/item-management/item");
  }

  get heading() {
    return this.page.getByRole("heading", { name: "Items" });
  }

  get createButton() {
    return this.page.getByRole("link", { name: /create/i });
  }

  get table() {
    return this.page.getByRole("table");
  }

  getRow(name: string) {
    return this.page.getByRole("row").filter({ hasText: name });
  }

  getViewLink(name: string) {
    return this.getRow(name).getByRole("link", { name: /view/i });
  }
}
```

### Form Page Object

```ts
import type { Page } from "@playwright/test";

export class ItemFormPage {
  private page: Page;
  constructor(page: Page) {
    this.page = page;
  }

  async gotoCreate() {
    await this.page.goto("/item-management/item/create");
  }

  async gotoEdit(id: string) {
    await this.page.goto(`/item-management/item/${id}/edit`);
  }

  get nameInput() {
    return this.page.getByLabel("Name");
  }

  get skuInput() {
    return this.page.getByLabel("SKU");
  }

  get submitButton() {
    return this.page.getByRole("button", { name: /create|save/i });
  }

  get cancelButton() {
    return this.page.getByRole("button", { name: /cancel/i });
  }

  async fill(fields: { name?: string; sku?: string }) {
    if (fields.name) await this.nameInput.fill(fields.name);
    if (fields.sku) await this.skuInput.fill(fields.sku);
  }
}
```

### DetailView Page Object

```ts
import type { Page } from "@playwright/test";

export class ItemDetailPage {
  private page: Page;
  constructor(page: Page) {
    this.page = page;
  }

  async goto(id: string) {
    await this.page.goto(`/item-management/item/${id}`);
  }

  get heading() {
    return this.page.getByRole("heading").first();
  }

  get statusBadge() {
    return this.page.getByText(/draft|active|inactive/i).first();
  }

  get editButton() {
    return this.page.getByRole("link", { name: /edit/i });
  }

  get activateButton() {
    return this.page.getByRole("button", { name: /activate/i });
  }

  get deactivateButton() {
    return this.page.getByRole("button", { name: /deactivate/i });
  }
}
```

### Naming Conventions

| Screen doc         | Page object file                   | Class name              |
| ------------------ | ---------------------------------- | ----------------------- |
| `item-list.md`     | `pages/item/item-list.ts`          | `ItemListPage`          |
| `item-form.md`     | `pages/item/item-form.ts`          | `ItemFormPage`          |
| `item-detail.md`   | `pages/item/item-detail.ts`        | `ItemDetailPage`        |
| `user-profile.md`  | `pages/user/user-profile.ts`       | `UserProfilePage`       |
| (no doc)           | `pages/auth/login.ts`              | `LoginPage`             |

- **File basename** must match the screen doc name (without `.md` / `.ts`)
- **Directory** groups by domain (item, user, taxonomy, unit, uom, auth)
- Platform-provided screens (login) have page objects but no screen doc

## Spec Files

One spec file per business flow (`tests/<flow>.spec.ts`) holding a **single top-level `test()`** — one end-to-end journey following the flow's `## Flow Diagram` main path, across its actors and stories. The diagram is the journey's script: title the test after the thread it walks, and keep the steps in the diagram's order. No wrapping `describe` — the file is the flow.

Assert only that the journey runs end to end and reaches its **main outcomes**. Not business rules (integration) or UI behavior like dialogs (component tests).

Call `useActor(test, actor)` at file scope — the worker signs that actor in
once and starts the test already signed in, so your spec writes **no login
code**.

```ts
import { test, expect } from "../fixtures/item-lifecycle";
import { useActor } from "../fixtures/user";
import { ItemFormPage } from "../pages/item/item-form";
import { ItemListPage } from "../pages/item/item-list";

useActor(test, "admin");

test("create an item, publish it, and see it active in the list", async ({ page }) => {
  const code = `ITEM-${Date.now()}`;
  const formPage = new ItemFormPage(page);
  await formPage.gotoCreate();
  await formPage.fill({ name: code });
  await formPage.submitButton.click();

  const listPage = new ItemListPage(page);
  await expect(listPage.getRow(code)).toBeVisible();
  // open detail, run the flow's key transition, assert the main outcome…
});
```

To act as a *second* actor within one test (e.g. an admin seeds data a viewer then reads),
open a separate browser context and sign in there — do not change the test's `actor`:

```ts
const manager = await createUser({ permissions: ACTOR_PERMISSIONS["admin"] });
const managerCtx = await browser.newContext({ baseURL, storageState: { cookies: [], origins: [] } }); // fresh: don't inherit the block's session
const managerPage = await managerCtx.newPage();
await new LoginPage(managerPage).login(manager.email, manager.password);
// ... seed data via managerPage, then managerCtx.close()
```

### Spec File Patterns

- Import `test` and `expect` from the spec's own fixture file (`../fixtures/<spec-basename>`) or from `../fixtures/user` if no spec-specific fixtures are needed (never from `@playwright/test` directly)
- Call `useActor(test, actor)` at file scope (see [Login Reuse](#login-reuse-one-account-per-parallel-worker))
- **Create mutable data inside the test with a unique suffix** (`` `ITEM-${Date.now()}` ``, `crypto.randomUUID()`) so parallel runs don't collide. Reference seed data only for master/reference rows, never for mutable fixtures
- **A test that mutates or asserts its OWN record** (e.g. own-profile) must create a dedicated account in-test (`createUser` + a fresh `browser.newContext({ baseURL, storageState: { cookies: [], origins: [] } })`) — the worker's shared account is reused by its other tests
- To act as a **second actor** inside one test (an admin seeds data a viewer then reads), open a separate `browser.newContext({ baseURL, storageState: { cookies: [], origins: [] } })` (fresh, no inherited session) and sign in there — don't change the block's actor
- Use page objects for navigation and locators — avoid raw `page.goto()` and `page.getByLabel()` in specs
- **One top-level `test()` per flow** (no wrapping `describe`)
- **Assert operability + main outcomes only** — business rules belong to integration, UI behavior to component tests
- **No placeholder tests.** `expect(true).toBe(true)` is never acceptable
- The spec basename must match the business-flow directory name (`tests/<flow>.spec.ts`)

## Locator Strategy

Prefer accessible locators in this order:

1. `getByRole("heading", { name })` — headings
2. `getByRole("button", { name })` — buttons
3. `getByRole("link", { name })` — links
4. `getByLabel("Name")` — form inputs (matches `<label>`)
5. `getByRole("table")` — tables
6. `getByRole("row").filter({ hasText })` — table rows
7. `getByText(/pattern/i)` — status badges, text content

Use regex patterns (`/create|save/i`) when button text varies between create and edit modes.

## Anti-flake Patterns

Playwright's auto-waiting covers most races. These patterns address the ones it doesn't.

### Wait for URL after mutation → navigation

A heading assertion alone can race the destination page's mount + refetch after a mutation. Add `waitForURL` as a strong signal first:

```ts
await detailPage.deleteButton.click();
await page.waitForURL(/\/product\/category\/?$/);
await expect(listPage.heading).toBeVisible();
```

Skip for pure route changes (no data dependency) — `toBeVisible` auto-retry suffices.

### Select combobox options by keyboard, not by clicking

A long option list can render the target outside the popover's viewport, so
`option.click()` times out (`element is outside of the viewport`) even after
`scrollIntoViewIfNeeded()` — auto-scroll can't pull a portal element into the
viewport. Select by keyboard, which doesn't depend on scroll position:

```ts
// By name — Radix Select typeahead (use a unique name so one option matches)
await combobox.click();
await page.getByRole("option", { name }).waitFor({ state: "visible" });
await page.keyboard.type(name);
await page.keyboard.press("Enter");

// First option
await combobox.click();
await page.getByRole("option").first().waitFor({ state: "visible" });
await page.keyboard.press("Home");
await page.keyboard.press("Enter");
```

### Never `waitForTimeout` for state; poll instead

Fixed sleeps amplify flakes under variable CI load. For DOM state use web-first assertions (`toBeVisible` etc.); for non-DOM state (workspace health, deploy status) use `expect.poll`:

```ts
await expect
  .poll(async () => (await getAppHealth({ workspaceId })).status.toLowerCase(), {
    intervals: [500, 1_000, 2_000, 3_000],
    timeout: 60_000,
  })
  .toBe("ok");
```
