name: 'create-run-e2e-tests'
description: 'Guide for writing and running new Playwright end-to-end tests in the apps/e2e-tests/ directory of the Packmind monorepo. Use this skill whenever you add or modify a spec that drives the real frontend and API — for example testing a user flow, a new page/route, a feature behind a flag, or a UI behavior end-to-end. Triggers on "write an e2e test", "add a Playwright test", "test this flow end-to-end", "cover this page with an e2e", "e2e for the frontend", or any work that lands a *.spec.ts under apps/e2e-tests/src/. Prefer this over hand-rolling raw Playwright test() calls — the codebase has mandatory fixtures and a Page Object Model you must follow.'
Authoring Packmind E2E Tests
Overview
apps/e2e-tests/ runs Playwright against the real frontend (http://localhost:4200) and API. Tests drive the browser through a Page Object Model and seed irrelevant setup data through the API, not the UI. The dev stack must be running first (see michel-run-local-dev-stack).
Two rules dominate everything here, and both come from the project's .packmind standards:
- Never use Playwright's raw
test. Always use one of the project fixtures. They handle user creation, sign-up, and API-key setup so each test starts from a clean, authenticated state. - Drive the UI through Page Objects, never raw selectors in specs. A spec should read like a user story; selectors live inside page objects so a markup change breaks one file, not twenty tests.
Where files go
| File type | Location | Naming |
|---|---|---|
| Spec | src/features/<area>/ |
<Feature>.spec.ts — one spec per feature |
| Page object interface | src/domain/pages/index.ts |
IXxxPage |
| Page object impl | src/infra/pages/ |
XxxPage.ts |
| API gateway type | src/domain/api/IPackmindApi.ts |
Gateway<IXxxUseCase> |
| API gateway impl | src/infra/api/PackmindApi.ts |
— |
| API data factory | src/domain/apiDataFactories/ |
apiXxxFactory.ts |
This mirrors the hexagonal split used across the repo: domain/ holds interfaces, infra/ holds implementations.
Choosing a fixture
The three fixtures form a chain — each extends the previous and adds one capability. Pick the lowest one that gives you what the test needs, so you don't pay for setup you won't use.
| Fixture | Provides | Use when |
|---|---|---|
testWithUserData |
userData (email/password), page |
Testing sign-up / activation / trial itself — i.e. flows that run before a session exists. You drive the PageFactory yourself. |
testWithUserSignedUp |
everything above + dashboardPage (already signed in) |
Testing in-app UI where you don't need to seed API data. |
testWithApi |
everything above + packmindApi |
You must seed standards/packages/skills/etc. before exercising the UI. |
All three live in src/fixtures/packmindTest.ts. Import the one you need:
import { testWithApi } from '../../fixtures/packmindTest';
Example — UI-only test
import { testWithUserSignedUp } from '../../fixtures/packmindTest';
import { expect } from '@playwright/test';
testWithUserSignedUp('user sees an empty standards list', async ({ dashboardPage }) => {
const standardsPage = await dashboardPage.openStandards();
// eslint-disable-next-line playwright/no-standalone-expect
expect(await standardsPage.hasNoStandards()).toBe(true);
});
Example — seed via API, assert via UI
Seed everything not under test through packmindApi (it's faster and less brittle than clicking through setup), then exercise the actual feature in the browser:
import { testWithApi } from '../../fixtures/packmindTest';
import { apiStandardFactory } from '../../domain/apiDataFactories/apiStandardFactory';
import { expect } from '@playwright/test';
testWithApi.describe('packages page', () => {
testWithApi('lists a standard added to a package', async ({ packmindApi, dashboardPage }) => {
const standard = await apiStandardFactory(packmindApi);
// ...seed package referencing standard.id...
const packagesPage = await dashboardPage.openPackages();
const packagePage = await packagesPage.openPackage('My package');
const standards = await packagePage.listStandardsInPackage();
// eslint-disable-next-line playwright/no-standalone-expect
expect(standards).toEqual([{ name: standard.name }]);
});
});
The
eslint-disable playwright/no-standalone-expectline is required: the lint rule can't tell that a fixture-extendedtestWithApi(...)callback is a real test body. Add it on anyexpectthat ESLint flags.
Writing a Page Object
A page object is the typed API a spec uses to talk to one route. Adding one is four mechanical steps — keep them in sync or TypeScript will complain.
1. Declare the interface in src/domain/pages/index.ts. In-app pages extend IPackmindAppPage (gives openStandards, openSettings, etc. for free); pre-login pages extend IPackmindPage.
export interface IBillingPage extends IPackmindAppPage {
listInvoices(): Promise<{ date: string; amount: string }[]>;
}
2. Implement it in src/infra/pages/BillingPage.ts, extending the matching abstract base, and define expectedUrl() as a RegExp (the project standard — Playwright's glob matching is too loose):
import { IBillingPage } from '../../domain/pages';
import { AbstractPackmindAppPage } from './AbstractPackmindAppPage';
export class BillingPage extends AbstractPackmindAppPage implements IBillingPage {
async listInvoices(): Promise<{ date: string; amount: string }[]> {
await this.page.locator('table tbody tr').first().waitFor();
// ...read rows...
}
expectedUrl(): RegExp {
return /.*\/billing$/;
}
}
3. Register it in the factory — add a getter to IPageFactory and PageFactory. Navigation methods that land on this page return the page object, so specs chain naturally (dashboardPage.openBilling() → IBillingPage).
4. Prefer data-testid over text/role selectors for app chrome that's likely to be reworded. The codebase exports test-id enums from @packmind/frontend (e.g. SidebarNavigationDataTestId) — reuse them.
Why navigation returns a page object
After any navigation, the factory calls waitForLoaded() (which awaits expectedUrl) before handing back the typed page. That's the project's this.pageFactory()-after-navigation rule: it guarantees the URL actually changed before the next interaction runs, killing a whole class of race conditions. Never page.goto + interact directly in a spec — go through the factory.
Seeding data through the API
When a test needs a resource that already exists in the product, create it via the API rather than clicking through the UI. Add the capability bottom-up:
- Add
myThing: Gateway<ICreateMyThingUseCase>;toIPackmindApi(src/domain/api/IPackmindApi.ts). All gateway methods are typed withGateway<IXxxUseCase>from@packmind/types. - Implement it in
PackmindApi(src/infra/api/PackmindApi.ts) using the privatepost/gethelpers — they inject the auth header and assert the status code. - Wrap it in an
apiXxxFactoryundersrc/domain/apiDataFactories/, reusing the shared@packmind/<domain>/testfactory for default field values (seeapiStandardFactory.ts/apiPackageFactory.tsfor the shape).
This keeps specs declarative: const standard = await apiStandardFactory(packmindApi) instead of a paragraph of POST plumbing.
Feature flags
Before writing, ask: is the feature under test gated by a feature flag?
If yes, the test user must have a @packmind.com email so the flag resolves to on. Flip the fixture option at the top of the file, before any describe/test:
testWithApi.use({ underFeatureFlag: true });
Without it the fixture creates an @example.com user, the flag stays off, and the feature is invisible — the test fails for the wrong reason. (Implementation: see the underFeatureFlag option in packmindTest.ts.)
Assertion style
- Split distinct expectations into separate tests or
describeblocks rather than one mega-test — a failure name then tells you exactly what broke. - Read state through a page-object method (
listInvoices(),listStandards()) and assert on the returned plain object. Don't reach into the DOM from the spec. - For multi-step scenarios (sign up → create → verify), nest
describeblocks with their ownbeforeEach, each building on the parent's state. SeeCliInstallDistribution.spec.tsfor the pattern.
Running the tests
There are two ways to run, and they share the same entry point — npm run e2e (i.e. npx playwright test). Never run the suite through Nx; it isn't wired for it.
Local iteration — fast feedback while writing a spec
Bring the stack up first (michel-run-local-dev-stack) so localhost:4200 serves the frontend. Then, from apps/e2e-tests/:
npm run e2e # all specs (BASE_URL defaults to http://localhost:4200)
npx playwright test <Feature>.spec.ts # one file
npx playwright test --headed # watch it run
npx playwright test --debug # step through with the inspector
npx playwright show-report # open the last HTML report
Full / CI run — containerized, the canonical way
CI runs the suite inside the run-e2e-tests Docker Compose service (Playwright image), whose entrypoint is the same npm run e2e but with BASE_URL=http://frontend:4200. It lives behind the e2e profile, so it only starts when you ask for it. Launch it, then block on its exit code with the helper script — this is what CI does and what keeps the two consistent:
# From the repo root, with PACKMIND_EDITION=oss already exported
docker compose --profile=e2e up -d run-e2e-tests
./scripts/wait-for-e2e-tests.sh # waits for the container, returns its exit code
wait-for-e2e-tests.sh matches the container by the run-e2e-tests name pattern, waits for it to finish, prints its logs, and exits with the container's code — so a non-zero exit means a failing suite. Reports land in apps/e2e-tests/playwright-report/ and apps/e2e-tests/test-results/ via the volume mount, exactly as in local runs.
Use local iteration while authoring a spec; use the containerized path to reproduce a CI result or run the whole suite the way the pipeline does.
Checklist before you call it done
- Spec uses a
testWith*fixture, not rawtest. - No raw selectors in the spec — all interaction goes through page objects.
- New page object: interface in
domain/pages, impl ininfra/pages, registered inPageFactory+IPageFactory,expectedUrl()is a RegExp. - Setup data not under test is seeded via
packmindApi/ anapiXxxFactory. -
testWithApi.use({ underFeatureFlag: true })added iff the feature is flagged. -
npx playwright test <file>passes locally against a running stack. -
./node_modules/.bin/nx lint e2e-testsis clean.