Estimated time: 30–40 min
| File | What changes |
|---|---|
apps/web-e2e/src/support/env.ts |
New — base URLs, role constants, storageStatePath helper |
apps/web-e2e/src/support/seed/index.ts |
New — typed seed functions (user, project, task, projectWithTasks, reset) |
apps/web-e2e/src/support/global-setup.ts |
New — logs in once per role, writes .auth/<role>.json |
apps/web-e2e/src/support/global-teardown.ts |
New — calls /test/reset after the suite |
apps/web-e2e/playwright.config.ts |
Add testDir, globalSetup, globalTeardown |
.env / .env.example |
Pin JWT_SECRET |
This module has no @module-05 tagged scenarios. Its deliverable is the three .auth/*.json files that Module 06 consumes.
- What
storageStateis: a serialized snapshot of a browser context (cookies + localStorage) - Why logging in once per role is faster than logging in per scenario
- How
globalSetupandglobalTeardownbracket the entire test suite - How to write a typed seed library that talks to env-guarded test endpoints
- Why pinning
JWT_SECRETis not optional
Every scenario that starts with "log in via the form" pays 500ms–1s for the network round trip, DOM rendering, and navigation. With 30 scenarios that's up to 30 seconds of noise. More importantly, it makes every failing test harder to read: the broken step is buried under four login-preamble steps.
storageState solves this cleanly. Playwright can serialize a browser context — cookies, localStorage, sessionStorage — to a JSON file. Load that file into a fresh context and the app sees an already-authenticated user. No form. No redirect. No preamble.
globalSetup gives you a single place to run that login once, before any test worker starts. globalTeardown gives you a guaranteed cleanup slot after every worker finishes. Together they bracket the entire run.
- Module 04 complete
git checkout 05-start
The first thing to understand: stored auth tokens are JWTs. If the server's signing key changes between runs, every cached token becomes invalid and your stored state is stale. The fix is to pin the key to a known value in dev.
.env (actual local values — gitignored):
DATABASE_URL="file:./dev.db"
JWT_SECRET="dev-not-secret-pin-for-local-do-not-use-in-production"
JWT_EXPIRES_IN="1d"
PORT=3000
NODE_ENV=development
VITE_API_BASE_URL="http://localhost:3000"
E2E_BASE_URL="http://localhost:4200"
E2E_API_BASE_URL="http://localhost:3000"
.env.example (committed, documents intent):
# API
DATABASE_URL="file:./dev.db"
JWT_SECRET="dev-not-secret-pin-for-local-do-not-use-in-production"
JWT_EXPIRES_IN="1d"
PORT=3000
NODE_ENV=development
# Web
VITE_API_BASE_URL="http://localhost:3000"
# E2E
E2E_BASE_URL="http://localhost:4200"
E2E_API_BASE_URL="http://localhost:3000"
The playwright.config.ts hard-codes the same JWT_SECRET value in the webServer.env block (covered in step 5). This means the dev server started by Playwright uses the pinned key even when the .env file isn't loaded by that process.
The NestJS AuthModule reads process.env.JWT_SECRET. With both the server and the stored tokens using the same key, sessions survive between re-runs.
This file is the single source of truth for every environment-level constant the E2E suite uses. Nothing else hardcodes URLs or role lists.
export const E2E_WEB_BASE_URL = process.env['E2E_BASE_URL'] ?? 'http://localhost:4200';
export const E2E_API_BASE_URL = process.env['E2E_API_BASE_URL'] ?? 'http://localhost:3000';
export const E2E_PASSWORD = 'E2EPass1!';
export const E2E_PREFIX = 'E2E_';
export const ROLES = ['admin', 'alice', 'bob'] as const;
export type E2ERole = (typeof ROLES)[number];
export function storageStatePath(role: E2ERole): string {
return `${__dirname}/../../.auth/${role}.json`;
}Key design decisions:
ROLES as const— the array is a tuple literal, soE2ERoleis the union'admin' | 'alice' | 'bob'and TypeScript will reject typos like'Admin'at compile time.storageStatePath— one function, one place.globalSetupwrites to it,session.tsreads from it. If you change the directory, you change it here.E2E_PASSWORD— all E2E-created users share this password. The seed endpoint sets it when creating a user. Centralizing it here means tests never embed a magic string.E2E_PREFIX— reminder constant for the naming convention. The seed endpoints enforceE2E_prefixes on emails and names so the belt-and-suspenders/test/resetsweep can identify and delete them.
The seed library is a typed client over the four test endpoints the API exposes:
import { apiRequest } from '../api-client';
export type SeededUser = {
id: string;
email: string;
password: string;
token: string;
role: string;
name: string;
};
export type SeededProject = {
id: string;
name: string;
ownerId: string;
};
export type SeededTask = {
id: string;
};
type SeedUserInput = {
role?: 'admin' | 'member';
emailPrefix?: string;
};
type SeedProjectInput = {
ownerId: string;
name?: string;
description?: string;
};
type SeedTaskInput = {
projectId: string;
title?: string;
description?: string;
status?: 'todo' | 'doing' | 'done';
priority?: 'low' | 'medium' | 'high';
dueDate?: string;
assigneeId?: string;
};
export const seed = {
user(input: SeedUserInput = {}): Promise<SeededUser> {
return apiRequest<SeededUser>('/test/seed/user', { method: 'POST', body: input });
},
project(input: SeedProjectInput): Promise<SeededProject> {
return apiRequest<SeededProject>('/test/seed/project', { method: 'POST', body: input });
},
task(input: SeedTaskInput): Promise<SeededTask> {
return apiRequest<SeededTask>('/test/seed/task', { method: 'POST', body: input });
},
async projectWithTasks(input: {
owner: SeededUser;
name?: string;
tasks?: Array<Omit<SeedTaskInput, 'projectId'>>;
}): Promise<{ project: SeededProject; tasks: SeededTask[] }> {
const project = await seed.project({ ownerId: input.owner.id, name: input.name });
const tasks: SeededTask[] = [];
for (const t of input.tasks ?? []) {
tasks.push(await seed.task({ projectId: project.id, ...t }));
}
return { project, tasks };
},
reset(): Promise<void> {
return apiRequest<void>('/test/reset', { method: 'POST' });
},
};Why the full file belongs in this module even though some methods are only used later:
globalSetupcallsseed.reset()andseed.user()— the module can't run without them.globalTeardowncallsseed.reset().- Splitting the file across modules would require teaching partial files, which is harder to reason about than a complete, consistent object.
The SeededUser type deserves attention: it includes password in the response. The API seed endpoint creates the user with E2E_PASSWORD and echoes it back, so callers don't need to remember the constant. The token field is a pre-generated JWT for that user — useful in later modules when you need to make authenticated API calls without going through the browser at all.
projectWithTasks is a convenience composer. It calls seed.project() then iterates seed.task() for each task spec, wiring the projectId automatically. The return value bundles both project and tasks so callers get a single object with everything they need.
The apiRequest function comes from apps/web-e2e/src/support/api-client.ts (introduced in Module 03). It reads E2E_API_BASE_URL from env.ts, adds appropriate headers, and throws an ApiClientError on non-2xx responses. The seed library sits on top of it rather than calling fetch directly, so error messages stay consistent.
import { chromium, type FullConfig } from '@playwright/test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { seed } from './seed';
import { E2E_WEB_BASE_URL, ROLES, storageStatePath } from './env';
export default async function globalSetup(_config: FullConfig) {
console.log('[globalSetup] starting');
const authDir = path.resolve(__dirname, '../../.auth');
fs.mkdirSync(authDir, { recursive: true });
await seed.reset();
console.log('[globalSetup] reset complete, seeding baseline users...');
const browser = await chromium.launch();
try {
for (const role of ROLES) {
const user = await seed.user({
role: role === 'admin' ? 'admin' : 'member',
emailPrefix: `baseline_${role}`,
});
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${E2E_WEB_BASE_URL}/login`);
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/projects');
const state = await context.storageState();
const stateWithMeta = {
...state,
_meta: { role, userId: user.id, email: user.email, name: user.name },
};
fs.writeFileSync(storageStatePath(role), JSON.stringify(stateWithMeta, null, 2));
await context.close();
}
} finally {
await browser.close();
}
}Walk through what happens in order:
-
mkdirSync(authDir, { recursive: true })— createsapps/web-e2e/.auth/if it doesn't exist. Therecursive: truemeans it's safe to call on every run even if the directory is already there. -
seed.reset()— callsPOST /test/resetwhich deletes every row with anE2E_prefix from the database. This ensuresglobalSetupalways starts from a clean state, even if a previous run crashed beforeglobalTeardowncould clean up. -
chromium.launch()— a raw browser instance, not a Playwrighttestfixture.globalSetupruns outside the test runner context, so it can't usetest-scoped fixtures. It drives the browser directly. -
for (const role of ROLES)— iterates['admin', 'alice', 'bob']. For each role it:- Seeds a user via
POST /test/seed/user. TheemailPrefixbecomes part of the email:E2E_baseline_admin@example.com. TheE2E_prefix ensures the belt-and-suspenders reset sweep picks it up. - Creates a fresh context per role. Contexts are isolated: cookies and localStorage in one context don't bleed into another.
- Navigates to
/login, fills the form, and clicks Sign in. - Waits for the URL to become
**/projects— proof that authentication succeeded. - Calls
context.storageState()which serializes cookies and localStorage into a plain object.
- Seeds a user via
-
stateWithMeta— the state object is extended with a_metafield containing the role, user ID, email, and name. This is purely for human debugging: openadmin.jsonand you can immediately tell which user it represents without decoding the JWT. -
fs.writeFileSync(storageStatePath(role), ...)— writes the serialized state to.auth/admin.json,.auth/alice.json, or.auth/bob.json. JSON.stringify with indent 2 makes the files human-readable. -
finally { browser.close() }— thetry/finallyguarantees the browser closes even if one of the logins throws. Without this, a failed seed call would leave a Chromium process running.
import type { FullConfig } from '@playwright/test';
import { seed } from './seed';
export default async function globalTeardown(_config: FullConfig) {
try {
await seed.reset();
} catch (err) {
console.warn('[globalTeardown] /test/reset failed (api may be down):', err);
}
}This is intentionally small. It calls seed.reset() to sweep any E2E data that scenario After hooks missed — for example, if a worker process crashed mid-scenario. The try/catch is deliberate: if the API is already down by the time teardown runs, the warning is informational and teardown still exits cleanly. The runner should not fail because of a teardown warning.
Three additions to the config:
import * as path from 'node:path';
// at the top, after defineBddConfig():
const testDir = defineBddConfig({ ... });
export default defineConfig({
testDir, // ← was missing; tells the BDD project where generated specs live
globalSetup: path.resolve(__dirname, 'src/support/global-setup.ts'),
globalTeardown: path.resolve(__dirname, 'src/support/global-teardown.ts'),
// ... rest of config
});Why path.resolve instead of a relative string? Playwright resolves relative paths relative to the config file's directory, but path.resolve(__dirname, ...) is unambiguous regardless of where you invoke npx playwright from.
The webServer block (already present) hard-codes JWT_SECRET in the environment it passes to npx nx run api:serve:
webServer: [
{
command: 'npx nx run api:serve',
url: `${apiBaseURL}/auth/me`,
reuseExistingServer: !process.env['CI'],
// ...
env: {
NODE_ENV: 'development',
PORT: '3000',
DATABASE_URL: 'file:./dev.db',
JWT_SECRET: 'dev-not-secret-pin-for-local-do-not-use-in-production',
},
},
// ...
],This is why you need the value to match .env. When Playwright starts the API, it injects this env block, overriding whatever the process inherits. The value must be the same as what's in .env — if they diverge, the API server used during tests signs JWTs with a different key than the one that was running when you generated your stored state.
The stored state files have a _meta field you added in globalSetup. Write a small Node script (scripts/inspect-auth.mjs) that:
- Reads all three
.auth/*.jsonfiles - Prints the
_meta.email,_meta.role, and the expiry date decoded from thelocalStorageJWT for each
Then answer: what happens to the stored state if you change JWT_EXPIRES_IN from "1d" to "5m" and re-run the suite immediately? At what point does the stored state become invalid?
There are no @module-05 tagged scenarios. Verify the module by running the full suite and inspecting the output:
npm run e2e:bdd:gen && npx nx e2e web-e2eThen open the generated files:
apps/web-e2e/.auth/admin.json
apps/web-e2e/.auth/alice.json
apps/web-e2e/.auth/bob.json
Each file should contain:
cookies— an array (likely empty for JWT-localStorage auth)origins— an array with one entry forhttp://localhost:4200, whoselocalStoragearray has one item with keye2e-quickstart-authand a JSON value containing atokenanduserobject_meta— the debugging annotation added byglobalSetup
If any file is missing, check the [globalSetup] console output for the error.
git diff 05-complete -- apps/web-e2eglobalSetup / globalTeardown lifecycle:
Playwright starts
→ globalSetup runs (once, in the main process)
→ seed.reset() # clean slate
→ for each role: login via UI, write .auth/<role>.json
→ worker processes start; each runs its assigned scenarios
→ scenario After hooks delete tracked IDs
→ all workers finish
→ globalTeardown runs (once, in the main process)
→ seed.reset() # belt-and-suspenders sweep
Playwright exits
storageState anatomy:
{
"cookies": [],
"origins": [
{
"origin": "http://localhost:4200",
"localStorage": [
{
"name": "e2e-quickstart-auth",
"value": "{\"token\":\"eyJ...\",\"user\":{...}}"
}
]
}
],
"_meta": { "role": "admin", "userId": "...", "email": "...", "name": "..." }
}When stored state goes stale:
| Cause | Symptom | Fix |
|---|---|---|
JWT_SECRET changed |
401 on all protected routes | Restore the pinned secret, rerun |
JWT_EXPIRES_IN elapsed |
401 after the expiry window | Rerun globalSetup (full suite run) |
| App's localStorage key changed | App treats user as anonymous | Update STORAGE_KEY in session.ts to match |