Skip to main content

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

RegressionWhich Flow Catches It
Button stopped appearingAll flows (explicit visibility checks)
Form save silently drops answersSmoke: questionnaire saves
Status transition brokeLifecycle, rejection, versioning
Generate plan returns emptySmoke: generate produces content
Split view editor not renderingSplit view editing, smoke: view modes
Approval buttons visible to wrong roleEditing permissions
Can edit non-draft planEditing permissions (textarea readonly check)
Rejection modal doesn't openRejection flow
New version doesn't copy dataVersioning (completion = 100%)
PDF export brokenSplit view editing (download event)
Page crashes on loadSmoke: editor loads
Section navigation brokenQuestionnaire: Previous/Next
Live preview not updatingSplit view: markdown → preview
Toast notifications missingEvery flow (waitForToast)

  1. Install Playwright + config — 10 minutes
  2. Auth fixture + API helpers — 30 minutes
  3. smoke.spec.ts — 5 tests, catches catastrophic breakage
  4. basic-plan-lifecycle.spec.ts — the full happy path
  5. editing-permissions.spec.ts — the #1 vibe-code bug
  6. rejection-flow.spec.ts — approval edge case
  7. split-view-editing.spec.ts — content editing
  8. versioning.spec.ts — version management
  9. questionnaire.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