HazCom Plan Builder — Flow / Automation Tests (Playwright)
Why Playwright
This codebase is vibe-coded. The real bugs are: a button that stopped appearing, a status transition that broke, a form save that silently drops answers, a modal that doesn't open. Playwright tests the actual browser UI — if it passes, the user can do the thing. If it fails, something broke.
API-level pytest tests (covered at the bottom) are a fast secondary layer for backend logic. But Playwright is the primary regression suite.
Setup
Install
cd tellus-ehs-hazcom-ui
# Install Playwright
npm install -D @playwright/test
# Install browsers
npx playwright install chromium
Config
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 60_000,
retries: 1,
use: {
baseURL: 'http://localhost:5174',
headless: true,
screenshot: 'only-on-failure',
trace: 'on-first-retry',
},
webServer: [
{
command: 'npm run dev:local',
port: 5174,
reuseExistingServer: true,
},
],
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
});
Directory Structure
e2e/
├── fixtures/
│ ├── auth.ts # Login helper + authenticated page
│ └── plan-helpers.ts # API shortcuts for test setup
├── pages/
│ ├── plan-editor.page.ts # PlanEditorPage selectors + actions
│ └── approvals.page.ts # ApprovalsPage selectors + actions
├── flows/
│ ├── basic-plan-lifecycle.spec.ts
│ ├── rejection-flow.spec.ts
│ ├── editing-permissions.spec.ts
│ ├── versioning.spec.ts
│ ├── split-view-editing.spec.ts
│ ├── questionnaire.spec.ts
│ └── smoke.spec.ts
└── global-setup.ts # Seed test data if needed
Auth Fixture
Supabase auth — login once, reuse the session across tests.
// e2e/fixtures/auth.ts
import { test as base, Page } from '@playwright/test';
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'test@example.com';
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'testpassword123';
type AuthFixtures = {
authedPage: Page;
};
export const test = base.extend<AuthFixtures>({
authedPage: async ({ page }, use) => {
// Login via Supabase
await page.goto('/login');
await page.getByPlaceholder('Email').fill(TEST_EMAIL);
await page.getByPlaceholder('Password').fill(TEST_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
// Wait for redirect to dashboard
await page.waitForURL('**/dashboard**', { timeout: 10_000 });
await use(page);
},
});
export { expect } from '@playwright/test';
API Helpers
Use the API directly to set up test state fast — don't click through the UI for setup.
// e2e/fixtures/plan-helpers.ts
import { Page } from '@playwright/test';
const API_BASE = 'http://localhost:8000/api/v1/hazcom/plans';
export async function getAuthHeaders(page: Page) {
// Extract token from the app's storage
const token = await page.evaluate(() => localStorage.getItem('access_token'));
const userId = await page.evaluate(() => {
const user = localStorage.getItem('user');
return user ? JSON.parse(user).id : null;
});
const companyId = await page.evaluate(() => localStorage.getItem('company_id'));
return {
'Authorization': `Bearer ${token}`,
'X-User-ID': userId,
'X-Company-ID': companyId,
'Content-Type': 'application/json',
};
}
export async function createPlanViaAPI(
page: Page,
siteId: string,
planType: 'basic' | 'premium' = 'basic',
planName = 'Test Plan',
) {
const headers = await getAuthHeaders(page);
const resp = await page.request.post(API_BASE, {
headers,
data: { site_id: siteId, plan_type: planType, plan_name: planName },
});
return resp.json();
}
export async function fillAllSectionsViaAPI(page: Page, planId: string) {
const headers = await getAuthHeaders(page);
const sections = [
'company_info', 'inventory', 'labeling',
'sds', 'training', 'non_routine', 'contractors',
];
for (const code of sections) {
await page.request.put(`${API_BASE}/${planId}/sections/${code}`, {
headers,
data: { answers: { [`${code}_q1`]: `Test answer for ${code}` } },
});
}
}
export async function generatePlanViaAPI(page: Page, planId: string) {
const headers = await getAuthHeaders(page);
await page.request.post(`${API_BASE}/${planId}/generate`, { headers });
}
export async function submitPlanViaAPI(page: Page, planId: string) {
const headers = await getAuthHeaders(page);
await page.request.post(`${API_BASE}/${planId}/submit-for-approval`, { headers });
}
export async function approvePlanViaAPI(page: Page, planId: string) {
const headers = await getAuthHeaders(page);
await page.request.post(`${API_BASE}/${planId}/approve`, {
headers,
data: { approval_notes: 'Auto-approved for test' },
});
}
export async function publishPlanViaAPI(page: Page, planId: string) {
const headers = await getAuthHeaders(page);
await page.request.post(`${API_BASE}/${planId}/publish`, { headers });
}
Page Objects
PlanEditorPage
// e2e/pages/plan-editor.page.ts
import { Page, Locator, expect } from '@playwright/test';
export class PlanEditorPage {
readonly page: Page;
// Navigation
readonly editTab: Locator;
readonly previewTab: Locator;
readonly splitViewTab: Locator;
readonly reviewTab: Locator;
// Questionnaire
readonly overallProgress: Locator;
readonly sectionButtons: Locator;
readonly saveNextButton: Locator;
readonly saveFinishButton: Locator;
readonly previousButton: Locator;
// Actions
readonly generatePlanButton: Locator;
readonly submitButton: Locator;
readonly approveButton: Locator;
readonly rejectButton: Locator;
readonly publishButton: Locator;
readonly createNewVersionButton: Locator;
// Split view
readonly markdownEditor: Locator;
readonly livePreview: Locator;
readonly printButton: Locator;
readonly pdfButton: Locator;
// Rejection modal
readonly rejectionModal: Locator;
readonly rejectionTextarea: Locator;
readonly confirmRejectButton: Locator;
readonly cancelRejectButton: Locator;
// Status
readonly statusBadge: Locator;
constructor(page: Page) {
this.page = page;
// View mode tabs
this.editTab = page.getByRole('button', { name: /edit/i }).first();
this.previewTab = page.getByRole('button', { name: /preview/i }).first();
this.splitViewTab = page.getByRole('button', { name: /split view/i });
this.reviewTab = page.getByRole('button', { name: /review/i });
// Questionnaire navigation
this.overallProgress = page.locator('text=Overall Progress');
this.sectionButtons = page.locator('[class*="section"]');
this.saveNextButton = page.getByRole('button', { name: /save & next/i });
this.saveFinishButton = page.getByRole('button', { name: /save & finish/i });
this.previousButton = page.getByRole('button', { name: /previous/i });
// Action buttons
this.generatePlanButton = page.getByRole('button', { name: /generate plan/i });
this.submitButton = page.getByRole('button', { name: /submit for approval/i });
this.approveButton = page.getByRole('button', { name: /approve/i }).last();
this.rejectButton = page.getByRole('button', { name: /reject/i }).first();
this.publishButton = page.getByRole('button', { name: /publish plan/i });
this.createNewVersionButton = page.getByRole('button', { name: /create new version/i });
// Split view
this.markdownEditor = page.locator('textarea.font-mono');
this.livePreview = page.locator('text=Live Preview').locator('..');
this.printButton = page.getByRole('button', { name: /print/i });
this.pdfButton = page.getByRole('button', { name: /pdf/i });
// Rejection modal
this.rejectionModal = page.locator('text=Reject Plan').locator('..').locator('..');
this.rejectionTextarea = page.getByPlaceholder(/rejection notes/i);
this.confirmRejectButton = page.locator('button:has-text("Reject Plan")').last();
this.cancelRejectButton = page.getByRole('button', { name: /cancel/i });
// Status badge
this.statusBadge = page.locator('.inline-flex.items-center').first();
}
async goto(planId: string) {
await this.page.goto(`/plan/builder/hazcom/${planId}`);
await this.page.waitForLoadState('networkidle');
}
async clickSection(sectionTitle: string) {
await this.page.getByRole('button', { name: new RegExp(sectionTitle, 'i') }).click();
}
async fillTextQuestion(placeholder: string, answer: string) {
await this.page.getByPlaceholder(placeholder).fill(answer);
}
async fillTextareaQuestion(label: string, answer: string) {
// Find the question label, then the textarea below it
const questionBlock = this.page.locator(`text=${label}`).locator('..').locator('..');
await questionBlock.locator('textarea').fill(answer);
}
async selectOption(label: string, optionText: string) {
const questionBlock = this.page.locator(`text=${label}`).locator('..').locator('..');
await questionBlock.locator('select').selectOption({ label: optionText });
}
async selectYesNo(label: string, value: 'Yes' | 'No') {
const questionBlock = this.page.locator(`text=${label}`).locator('..').locator('..');
await questionBlock.getByRole('button', { name: value }).click();
}
async getCompletionPercentage(): Promise<number> {
const text = await this.page.locator('text=/\\d+%/').first().textContent();
return parseInt(text?.replace('%', '') || '0');
}
async getStatusText(): Promise<string> {
return (await this.statusBadge.textContent()) || '';
}
async waitForToast(text: string) {
await this.page.locator(`text=${text}`).waitFor({ timeout: 5_000 });
}
async waitForGenerationComplete() {
// Wait for "Generating your plan" overlay to disappear
await this.page.locator('text=Generating your plan').waitFor({ state: 'hidden', timeout: 120_000 });
}
}
Flow Tests
Flow 1: Basic Plan — Full Lifecycle (Smoke)
The single most important test. If this passes, the product works.
// e2e/flows/basic-plan-lifecycle.spec.ts
import { test, expect } from '../fixtures/auth';
import { PlanEditorPage } from '../pages/plan-editor.page';
import {
createPlanViaAPI,
fillAllSectionsViaAPI,
} from '../fixtures/plan-helpers';
const SITE_ID = process.env.TEST_SITE_ID!;
test.describe('Basic Plan — Full Lifecycle', () => {
test('create → fill → generate → submit → approve → publish', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
// 1. CREATE plan via API (fast setup)
const plan = await createPlanViaAPI(page, SITE_ID, 'basic', 'E2E Test Plan');
const planId = plan.plan_id;
// 2. NAVIGATE to editor
await editor.goto(planId);
await expect(page.locator('text=E2E Test Plan')).toBeVisible();
// 3. VERIFY 7 sections visible
await expect(page.locator('text=Company & Site Information')).toBeVisible();
await expect(page.locator('text=Chemical Inventory')).toBeVisible();
await expect(page.locator('text=Container Labeling')).toBeVisible();
await expect(page.locator('text=Safety Data Sheets')).toBeVisible();
await expect(page.locator('text=Employee Training')).toBeVisible();
await expect(page.locator('text=Non-Routine Tasks')).toBeVisible();
await expect(page.locator('text=Contractor Coordination')).toBeVisible();
// 4. FILL all sections via API (fast)
await fillAllSectionsViaAPI(page, planId);
await page.reload();
// 5. VERIFY 100% completion
await expect(page.locator('text=100%')).toBeVisible();
// 6. GENERATE plan
await editor.generatePlanButton.click();
await editor.waitForGenerationComplete();
await editor.waitForToast('Plan generated');
// 7. VERIFY split view has content
await editor.splitViewTab.click();
await expect(editor.markdownEditor).not.toBeEmpty();
await expect(page.locator('text=Live Preview')).toBeVisible();
// 8. SUBMIT for approval
await editor.submitButton.click();
await editor.waitForToast('submitted');
await expect(page.locator('text=/pending/i')).toBeVisible();
// 9. APPROVE
await editor.approveButton.click();
await editor.waitForToast('approved');
// 10. PUBLISH
await editor.publishButton.click();
await editor.waitForToast('published');
await expect(page.locator('text=/active/i')).toBeVisible();
// 11. CREATE NEW VERSION
await editor.createNewVersionButton.click();
await page.waitForURL(/\/plan\/builder\/hazcom\//);
await expect(page.locator('text=2.0')).toBeVisible();
await expect(page.locator('text=/draft/i')).toBeVisible();
});
});
Flow 2: Questionnaire Interaction
Tests the actual form-filling experience.
// e2e/flows/questionnaire.spec.ts
import { test, expect } from '../fixtures/auth';
import { PlanEditorPage } from '../pages/plan-editor.page';
import { createPlanViaAPI } from '../fixtures/plan-helpers';
const SITE_ID = process.env.TEST_SITE_ID!;
test.describe('Questionnaire', () => {
test('fill sections, verify completion updates', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
const plan = await createPlanViaAPI(page, SITE_ID);
await editor.goto(plan.plan_id);
// Start at 0%
await expect(page.locator('text=0%')).toBeVisible();
// Click first section
await editor.clickSection('Company & Site Information');
await expect(page.locator('text=/company/i')).toBeVisible();
// Fill a text question
const firstInput = page.locator('input[type="text"], textarea').first();
await firstInput.fill('Acme Safety Corp');
// Save & Next
await editor.saveNextButton.click();
await editor.waitForToast('saved');
// Completion should be > 0%
const completion = await editor.getCompletionPercentage();
expect(completion).toBeGreaterThan(0);
});
test('navigate between sections with Previous/Next', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
const plan = await createPlanViaAPI(page, SITE_ID);
await editor.goto(plan.plan_id);
// Click first section
await editor.clickSection('Company & Site Information');
// Fill and go next
const firstInput = page.locator('input[type="text"], textarea').first();
await firstInput.fill('Test answer');
await editor.saveNextButton.click();
await editor.waitForToast('saved');
// Should be on section 2
await expect(page.locator('text=Chemical Inventory')).toBeVisible();
// Go back
await editor.previousButton.click();
// Should be on section 1 again
await expect(page.locator('text=Company & Site Information')).toBeVisible();
});
});
Flow 3: Editing Permissions — Draft-Only
The #1 vibe-code regression: forgetting to lock down edits on non-draft plans.
// e2e/flows/editing-permissions.spec.ts
import { test, expect } from '../fixtures/auth';
import { PlanEditorPage } from '../pages/plan-editor.page';
import {
createPlanViaAPI,
fillAllSectionsViaAPI,
generatePlanViaAPI,
submitPlanViaAPI,
} from '../fixtures/plan-helpers';
const SITE_ID = process.env.TEST_SITE_ID!;
test.describe('Editing Permissions', () => {
test('submitted plan is read-only', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
// Setup: create and submit a plan
const plan = await createPlanViaAPI(page, SITE_ID);
await fillAllSectionsViaAPI(page, plan.plan_id);
await generatePlanViaAPI(page, plan.plan_id);
await submitPlanViaAPI(page, plan.plan_id);
await editor.goto(plan.plan_id);
// Status should show pending
await expect(page.locator('text=/pending/i')).toBeVisible();
// Generate Plan button should NOT be visible
await expect(editor.generatePlanButton).not.toBeVisible();
// Split view textarea should be read-only
await editor.splitViewTab.click();
const isReadonly = await editor.markdownEditor.getAttribute('readonly');
expect(isReadonly).not.toBeNull();
});
test('active plan shows Create New Version instead of edit', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
// Setup: create, fill, generate, submit, approve, publish
const plan = await createPlanViaAPI(page, SITE_ID);
await fillAllSectionsViaAPI(page, plan.plan_id);
await generatePlanViaAPI(page, plan.plan_id);
await submitPlanViaAPI(page, plan.plan_id);
await approvePlanViaAPI(page, plan.plan_id);
await publishPlanViaAPI(page, plan.plan_id);
await editor.goto(plan.plan_id);
// Status should show Active
await expect(page.locator('text=/active/i')).toBeVisible();
// Create New Version button should be visible
await expect(editor.createNewVersionButton).toBeVisible();
// Submit button should NOT be visible
await expect(editor.submitButton).not.toBeVisible();
});
});
Flow 4: Rejection — Edit — Resubmit
// e2e/flows/rejection-flow.spec.ts
import { test, expect } from '../fixtures/auth';
import { PlanEditorPage } from '../pages/plan-editor.page';
import {
createPlanViaAPI,
fillAllSectionsViaAPI,
generatePlanViaAPI,
submitPlanViaAPI,
} from '../fixtures/plan-helpers';
const SITE_ID = process.env.TEST_SITE_ID!;
test.describe('Rejection Flow', () => {
test('reject → edit → resubmit → approve', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
// Setup: submitted plan
const plan = await createPlanViaAPI(page, SITE_ID, 'basic', 'Rejection Test');
await fillAllSectionsViaAPI(page, plan.plan_id);
await generatePlanViaAPI(page, plan.plan_id);
await submitPlanViaAPI(page, plan.plan_id);
await editor.goto(plan.plan_id);
// 1. REJECT with reason
await editor.rejectButton.click();
// Modal should appear
await expect(editor.rejectionTextarea).toBeVisible();
await editor.rejectionTextarea.fill('Training section needs more detail');
await editor.confirmRejectButton.click();
// Status should return to Draft
await editor.waitForToast('draft');
await expect(page.locator('text=/draft/i')).toBeVisible();
// 2. EDIT the plan (should be editable again)
await editor.clickSection('Employee Training');
const textarea = page.locator('textarea').first();
await textarea.fill('Updated training schedule: monthly safety meetings');
await editor.saveNextButton.click();
await editor.waitForToast('saved');
// 3. RESUBMIT
await editor.submitButton.click();
await editor.waitForToast('submitted');
// 4. APPROVE
await editor.approveButton.click();
await editor.waitForToast('approved');
});
});
Flow 5: Split View Editing
// e2e/flows/split-view-editing.spec.ts
import { test, expect } from '../fixtures/auth';
import { PlanEditorPage } from '../pages/plan-editor.page';
import {
createPlanViaAPI,
fillAllSectionsViaAPI,
generatePlanViaAPI,
} from '../fixtures/plan-helpers';
const SITE_ID = process.env.TEST_SITE_ID!;
test.describe('Split View Editing', () => {
test('edit markdown → live preview updates', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
// Setup: plan with generated content
const plan = await createPlanViaAPI(page, SITE_ID);
await fillAllSectionsViaAPI(page, plan.plan_id);
await generatePlanViaAPI(page, plan.plan_id);
await editor.goto(plan.plan_id);
await editor.splitViewTab.click();
// Editor should have content
const editorValue = await editor.markdownEditor.inputValue();
expect(editorValue.length).toBeGreaterThan(0);
// Live Preview should be visible
await expect(page.locator('text=Live Preview')).toBeVisible();
// Type something new
await editor.markdownEditor.fill('## Custom Section\n\nThis is custom content.');
// Preview should update (debounced, wait a moment)
await page.waitForTimeout(1500);
await expect(page.locator('text=Custom Section')).toBeVisible();
await expect(page.locator('text=This is custom content')).toBeVisible();
});
test('PDF export button works', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
const plan = await createPlanViaAPI(page, SITE_ID);
await fillAllSectionsViaAPI(page, plan.plan_id);
await generatePlanViaAPI(page, plan.plan_id);
await editor.goto(plan.plan_id);
await editor.splitViewTab.click();
// PDF button should be visible
await expect(editor.pdfButton).toBeVisible();
// Click and wait for download
const downloadPromise = page.waitForEvent('download', { timeout: 30_000 });
await editor.pdfButton.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('.pdf');
});
});
Flow 6: Version Management
// e2e/flows/versioning.spec.ts
import { test, expect } from '../fixtures/auth';
import { PlanEditorPage } from '../pages/plan-editor.page';
import {
createPlanViaAPI,
fillAllSectionsViaAPI,
generatePlanViaAPI,
submitPlanViaAPI,
approvePlanViaAPI,
publishPlanViaAPI,
} from '../fixtures/plan-helpers';
const SITE_ID = process.env.TEST_SITE_ID!;
test.describe('Version Management', () => {
test('published plan → Create New Version → new draft', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
// Setup: active plan
const plan = await createPlanViaAPI(page, SITE_ID, 'basic', 'Version Test');
await fillAllSectionsViaAPI(page, plan.plan_id);
await generatePlanViaAPI(page, plan.plan_id);
await submitPlanViaAPI(page, plan.plan_id);
await approvePlanViaAPI(page, plan.plan_id);
await publishPlanViaAPI(page, plan.plan_id);
await editor.goto(plan.plan_id);
// Should show Active + Create New Version
await expect(page.locator('text=/active/i')).toBeVisible();
await expect(editor.createNewVersionButton).toBeVisible();
// Click Create New Version
await editor.createNewVersionButton.click();
// Should navigate to new plan
await page.waitForURL(/\/plan\/builder\/hazcom\//);
// New plan should be version 2.0 in draft
await expect(page.locator('text=2.0')).toBeVisible();
await expect(page.locator('text=/draft/i')).toBeVisible();
// Sections should be pre-filled (copied from v1)
const completion = await editor.getCompletionPercentage();
expect(completion).toBe(100);
});
});
Smoke Suite
Run before every deploy. Target: < 2 minutes.
// e2e/flows/smoke.spec.ts
import { test, expect } from '../fixtures/auth';
import { PlanEditorPage } from '../pages/plan-editor.page';
import {
createPlanViaAPI,
fillAllSectionsViaAPI,
generatePlanViaAPI,
} from '../fixtures/plan-helpers';
const SITE_ID = process.env.TEST_SITE_ID!;
test.describe('Smoke Tests', () => {
test('plan editor loads with 7 sections', async ({ authedPage: page }) => {
const plan = await createPlanViaAPI(page, SITE_ID);
await page.goto(`/plan/builder/hazcom/${plan.plan_id}`);
await page.waitForLoadState('networkidle');
// 7 sections visible in sidebar
for (const section of [
'Company & Site', 'Chemical Inventory', 'Container Labeling',
'Safety Data Sheets', 'Employee Training', 'Non-Routine', 'Contractor',
]) {
await expect(page.locator(`text=/${section}/i`)).toBeVisible();
}
});
test('questionnaire saves answers', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
const plan = await createPlanViaAPI(page, SITE_ID);
await editor.goto(plan.plan_id);
// Fill first question
await editor.clickSection('Company & Site');
const input = page.locator('input[type="text"], textarea').first();
await input.fill('Smoke test answer');
await editor.saveNextButton.click();
await editor.waitForToast('saved');
// Reload and verify answer persisted
await page.reload();
await editor.clickSection('Company & Site');
await expect(page.locator('text=Smoke test answer')).toBeVisible();
});
test('generate plan produces content', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
const plan = await createPlanViaAPI(page, SITE_ID);
await fillAllSectionsViaAPI(page, plan.plan_id);
await editor.goto(plan.plan_id);
await editor.generatePlanButton.click();
await editor.waitForGenerationComplete();
// Switch to split view — content should exist
await editor.splitViewTab.click();
const content = await editor.markdownEditor.inputValue();
expect(content.length).toBeGreaterThan(50);
});
test('approval flow completes', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
const plan = await createPlanViaAPI(page, SITE_ID);
await fillAllSectionsViaAPI(page, plan.plan_id);
await generatePlanViaAPI(page, plan.plan_id);
await editor.goto(plan.plan_id);
await editor.submitButton.click();
await editor.waitForToast('submitted');
await editor.approveButton.click();
await editor.waitForToast('approved');
await editor.publishButton.click();
await editor.waitForToast('published');
await expect(page.locator('text=/active/i')).toBeVisible();
});
test('view mode tabs switch correctly', async ({ authedPage: page }) => {
const editor = new PlanEditorPage(page);
const plan = await createPlanViaAPI(page, SITE_ID);
await fillAllSectionsViaAPI(page, plan.plan_id);
await generatePlanViaAPI(page, plan.plan_id);
await editor.goto(plan.plan_id);
// Preview tab
await editor.previewTab.click();
await expect(page.locator('text=/preview/i')).toBeVisible();
// Split view tab
await editor.splitViewTab.click();
await expect(editor.markdownEditor).toBeVisible();
await expect(page.locator('text=Live Preview')).toBeVisible();
// Back to edit
await editor.editTab.click();
await expect(page.locator('text=Overall Progress')).toBeVisible();
});
});
Running
# Run all E2E tests
npx playwright test
# Run smoke tests only (fast)
npx playwright test e2e/flows/smoke.spec.ts
# Run specific flow
npx playwright test e2e/flows/basic-plan-lifecycle.spec.ts
# Run headed (see the browser)
npx playwright test --headed
# Run with UI mode (interactive debugging)
npx playwright test --ui
# Run and show report on failure
npx playwright test && npx playwright show-report
# Debug a specific test
npx playwright test --debug e2e/flows/rejection-flow.spec.ts
Add to package.json:
{
"scripts": {
"e2e": "playwright test",
"e2e:smoke": "playwright test e2e/flows/smoke.spec.ts",
"e2e:headed": "playwright test --headed",
"e2e:ui": "playwright test --ui",
"e2e:report": "playwright show-report"
}
}
What These Tests Catch
| Regression | Which Flow Catches It |
|---|---|
| Button stopped appearing | All flows (explicit visibility checks) |
| Form save silently drops answers | Smoke: questionnaire saves |
| Status transition broke | Lifecycle, rejection, versioning |
| Generate plan returns empty | Smoke: generate produces content |
| Split view editor not rendering | Split view editing, smoke: view modes |
| Approval buttons visible to wrong role | Editing permissions |
| Can edit non-draft plan | Editing permissions (textarea readonly check) |
| Rejection modal doesn't open | Rejection flow |
| New version doesn't copy data | Versioning (completion = 100%) |
| PDF export broken | Split view editing (download event) |
| Page crashes on load | Smoke: editor loads |
| Section navigation broken | Questionnaire: Previous/Next |
| Live preview not updating | Split view: markdown → preview |
| Toast notifications missing | Every flow (waitForToast) |
Recommended Implementation Order
- Install Playwright + config — 10 minutes
- Auth fixture + API helpers — 30 minutes
smoke.spec.ts— 5 tests, catches catastrophic breakagebasic-plan-lifecycle.spec.ts— the full happy pathediting-permissions.spec.ts— the #1 vibe-code bugrejection-flow.spec.ts— approval edge casesplit-view-editing.spec.ts— content editingversioning.spec.ts— version managementquestionnaire.spec.ts— form interactions
Backend API Tests (Secondary Layer)
For fast backend-only regression checking without a browser, see the pytest API flow tests in the appendix below. These call endpoints directly via TestClient and verify DB state — useful for CI where Playwright is slower.
The pytest approach is documented in detail in the previous version of this doc. Key difference: pytest tests verify API contracts and DB state, Playwright tests verify what the user sees.
Run both:
# Fast backend check (< 30s)
cd tellus-ehs-hazcom-service && pytest tests/flows/ -x -v
# Full UI check (< 2 min)
cd tellus-ehs-hazcom-ui && npx playwright test e2e/flows/smoke.spec.ts
Related Documentation
- Testing Strategy — Overall testing strategy and priorities
- Implementation Plan — What was built
- API Endpoints — All 27 endpoints