# E2E Testing with Playwright

## Directory Structure

```bash
frontend/e2e/
  fixtures/
    user.ts                    # actor fixtures (Admin, GeneralUser, etc.) — 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 / cleanUpUser helpers
    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/actors/<name>.md`)
- Page objects **without** a screen doc are allowed (e.g., `login.ts` for platform-provided screens)
- Each **story doc** scenario pattern must have a matching `test()` in the spec file

## Actor Fixtures

Define actors in `e2e/fixtures/user.ts`. The `Actor` type union is the source of truth for actor names:

```ts
import { test as base } from "@playwright/test";
import { createUser, cleanUpUser } from "../utils/user-factory";

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

const ACTOR_PERMISSIONS: Record<Actor, string[]> = {
  admin: [
    "item-management:item",
    "user-management:user",
    // ... broad module-level permissions
  ],
  "general-user": [
    "item-management:item:createItem",
    "user-management:profile",
    // ... specific operation-level permissions
  ],
};

type UserFixture = Awaited<ReturnType<typeof createUser>>;

interface Fixtures {
  Admin: UserFixture;
  GeneralUser: UserFixture;
}

function defineActorFixture(actor: Actor) {
  // oxlint-disable-next-line no-empty-pattern
  return async ({}, use: (user: UserFixture) => Promise<void>) => {
    const user = await createUser({ permissions: ACTOR_PERMISSIONS[actor] });
    await use(user);
    try {
      await cleanUpUser(user);
    } catch (error) {
      console.error(`Failed to clean up ${actor} user:`, error);
    }
  };
}

export const test = base.extend<Fixtures>({
  Admin: defineActorFixture("admin"),
  GeneralUser: defineActorFixture("general-user"),
});
```

## 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. Each `test.describe` maps to a story doc (`<actor>--<action>`). Each `test()` maps to a scenario pattern from the story doc.

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

test.describe("admin--create-item", () => {
  test.beforeEach(async ({ page, Admin }) => {
    const loginPage = new LoginPage(page);
    await loginPage.login(Admin.email, Admin.password);
  });

  test("Admin creates a new item with basic details and it is saved in DRAFT status", async ({
    page,
    DraftItem,
  }) => {
    const formPage = new ItemFormPage(page);
    await formPage.gotoCreate();
    await expect(page.getByRole("heading", { name: "Create Item" })).toBeVisible();
    await expect(formPage.nameInput).toBeVisible();
    await expect(formPage.skuInput).toBeVisible();
    await expect(formPage.submitButton).toBeVisible();
  });
});
```

### 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)
- Login in `beforeEach` using the appropriate actor fixture
- Use page objects for navigation and locators — avoid raw `page.goto()` and `page.getByLabel()` in specs
- Placeholder tests use `expect(true).toBe(true)` with a `// TODO:` comment
- `test.describe` name must match story doc filename (e.g., `admin--create-item`)
- `test()` description must match scenario pattern from story doc

## 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.
