Playwright Frontend-Backend E2E Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a deterministic Playwright suite that drives the real frontend, hits the real backend, and verifies critical backend side effects for all 12 local-first user journeys.
Architecture: Add a dedicated Playwright layer under frontend/e2e/ and a guarded backend E2E support module that exists only in NODE_ENV=e2e. Use one serial Playwright project, regenerate storage state per persona in globalSetup, reset the dedicated E2E database before each spec file, and verify side effects through test-only backend assertion endpoints instead of brittle direct DB calls from the browser process.
Tech Stack: Bun workspaces, Playwright, Vite, React 19, NestJS 11, TypeORM, PostgreSQL/TimescaleDB, Biome.
Scope Notes
- This plan intentionally covers the frontend-to-backend Playwright lane only.
- The existing backend-only E2E plan in
docs/superpowers/plans/2026-05-10-e2e-critical-user-journeys.mdremains separate and should not be duplicated. - The current frontend has login/forgot/reset flows but does not expose a registration screen. Journey 1 in this spec therefore requires adding a real registration page and route before Playwright can cover
register -> login -> profile/me -> refresh/session continuity -> logout.
File Structure (planned changes)
Create
frontend/e2e/config/playwright.config.ts- single-project Playwright config, web servers, traces/videos on failure.frontend/e2e/setup/global-setup.ts- reset baseline, seed personas, UI login, save storage states.frontend/e2e/setup/global-teardown.ts- cleanup auth artifacts and optional final reset.frontend/e2e/fixtures/personas.ts- typed persona metadata and storage-state paths.frontend/e2e/utils/backend-admin.ts- typed helper for/api/v1/e2e/*reset/bootstrap/assertion endpoints.frontend/e2e/utils/polling.ts- bounded polling for telemetry/dashboard eventual consistency.frontend/e2e/pages/auth/LoginPage.ts- login page object.frontend/e2e/pages/auth/RegisterPage.ts- registration page object.frontend/e2e/pages/ProfilePage.ts- profile/session page object.frontend/e2e/pages/OrganizationPage.ts- org list/create/detail helper.frontend/e2e/pages/UserPage.ts- user create/list helper.frontend/e2e/pages/FarmPage.ts- area/pond create/list helper.frontend/e2e/pages/SystemDeviceInventoryPage.ts- system device create/assign helper.frontend/e2e/pages/DevicePage.ts- org device list/detail/telemetry helper.frontend/e2e/pages/AutomationPage.ts- automation rule helper.frontend/e2e/pages/DashboardPage.ts- dashboard assertions helper.frontend/e2e/journeys/01-auth.spec.tsfrontend/e2e/journeys/02-rbac.spec.tsfrontend/e2e/journeys/03-tenant-isolation.spec.tsfrontend/e2e/journeys/04-error-contract.spec.tsfrontend/e2e/journeys/05-organization-onboarding.spec.tsfrontend/e2e/journeys/06-farm-management.spec.tsfrontend/e2e/journeys/07-device-lifecycle.spec.tsfrontend/e2e/journeys/08-telemetry.spec.tsfrontend/e2e/journeys/09-dashboard.spec.tsfrontend/e2e/journeys/10-automation.spec.tsfrontend/e2e/journeys/11-release-smoke.spec.tsfrontend/e2e/.auth/.gitignore- ignore generated storage states.frontend/.env.e2e- Vite E2E URLs and feature flags.backend/.env.e2e- dedicated DB and guarded E2E support settings.backend/src/config/env-file-path.ts- chooses.envvs.env.e2e.backend/src/e2e-support/e2e-support.module.tsbackend/src/e2e-support/e2e-support.controller.tsbackend/src/e2e-support/e2e-support.service.tsbackend/src/e2e-support/dto/bootstrap-response.dto.tsbackend/src/e2e-support/dto/assertion-response.dto.tsbackend/scripts/ensure-e2e-database.ts- createshmpiot_e2e_localif missing.
Modify
frontend/package.json- adde2e:install,e2e:smoke,e2e:full,e2e:debug.frontend/src/App.tsx- add/registerroute and keep protected route coverage intact.frontend/src/pages/auth/Login.tsx- add stabledata-testidhooks.frontend/src/pages/profile/ProfilePage.tsx- add stable profile/session hooks.frontend/src/stores/auth.store.ts- addregisteraction and keep persisted auth consistent.frontend/src/pages/organizations/OrganizationList.tsxfrontend/src/pages/organizations/OrganizationCreate.tsxfrontend/src/pages/users/UsersPage.tsxfrontend/src/components/users/UserSlideOver.tsxfrontend/src/components/users/UserForm.tsxfrontend/src/pages/farm/AreaList.tsxfrontend/src/components/farm/AreaForm.tsxfrontend/src/pages/farm/PondList.tsxfrontend/src/components/farm/PondForm.tsxfrontend/src/pages/system-devices/SystemDeviceInventoryPage.tsxfrontend/src/pages/devices/DeviceList.tsxfrontend/src/pages/devices/DeviceDetail.tsxfrontend/src/pages/automation/AutomationRulesPage.tsxfrontend/src/pages/dashboard/DashboardRoute.tsxfrontend/src/pages/dashboard/admin/AdminDashboard.tsxfrontend/src/pages/dashboard/owner/OwnerDashboard.tsxbackend/package.json- addstart:e2e.backend/src/app.module.ts- load.env.e2e, conditionally import E2E support module.
Validate / reference while implementing
frontend/src/lib/api.tsfrontend/src/pages/auth/Login.tsxfrontend/src/pages/organizations/OrganizationList.tsxfrontend/src/pages/organizations/OrganizationCreate.tsxfrontend/src/pages/users/UsersPage.tsxfrontend/src/pages/farm/AreaList.tsxfrontend/src/pages/devices/DeviceList.tsxfrontend/src/pages/devices/DeviceDetail.tsxfrontend/src/pages/system-devices/SystemDeviceInventoryPage.tsxfrontend/src/pages/automation/AutomationRulesPage.tsxbackend/test/auth/auth.controller.e2e-spec.tsbackend/test/telemetry/telemetry.controller.e2e-spec.tsbackend/test/automation/automation-flow.e2e-spec.ts
Task 1: Scaffold the Playwright runtime and local command contract
Files:
Create:
frontend/e2e/config/playwright.config.tsCreate:
frontend/e2e/.auth/.gitignoreCreate:
frontend/.env.e2eModify:
frontend/package.jsonModify:
backend/package.jsonTest:
frontend/e2e/journeys/01-auth.spec.tsStep 1: Write the smallest failing browser smoke spec
// frontend/e2e/journeys/01-auth.spec.ts
import { expect, test } from '@playwright/test';
test('renders the login screen', async ({ page }) => {
await page.goto('/login');
await expect(page.getByRole('heading', { name: 'Đăng Nhập' })).toBeVisible();
});
- Step 2: Run the spec to confirm the repo has no E2E runtime yet
Run: cd frontend && bunx playwright test -c e2e/config/playwright.config.ts e2e/journeys/01-auth.spec.ts
Expected: FAIL with config file not found or Playwright not installed.
- Step 3: Add Playwright scripts and single-project config
// frontend/package.json
{
"scripts": {
"e2e:install": "bunx playwright install chromium",
"e2e:smoke": "playwright test -c e2e/config/playwright.config.ts --grep @smoke",
"e2e:full": "playwright test -c e2e/config/playwright.config.ts",
"e2e:debug": "playwright test -c e2e/config/playwright.config.ts --ui"
},
"devDependencies": {
"@playwright/test": "^1.56.1"
}
}
// frontend/e2e/config/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: '../journeys',
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 90_000,
expect: { timeout: 10_000 },
reporter: [['list'], ['html', { open: 'never', outputFolder: '../artifacts/html-report' }]],
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: [
{
command: 'cd ../.. && cd backend && bun run start:e2e',
url: 'http://127.0.0.1:3000/api/v1/health',
reuseExistingServer: true,
timeout: 120_000,
},
{
command: 'cd ../.. && cd frontend && bun run dev --mode e2e --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173/login',
reuseExistingServer: true,
timeout: 120_000,
},
],
});
// frontend/e2e/.auth/.gitignore
*
!.gitignore
- Step 4: Add backend start script and E2E env skeleton
// backend/package.json
{
"scripts": {
"start:e2e": "bun run scripts/ensure-e2e-database.ts && NODE_ENV=e2e nest start --watch"
}
}
# frontend/.env.e2e
VITE_API_URL=http://127.0.0.1:3000/api/v1
VITE_WS_URL=ws://127.0.0.1:3000
VITE_E2E_MODE=true
- Step 5: Run the first spec again and verify the UI boots
Run: cd frontend && bun run e2e:smoke --grep "renders the login screen"
Expected: PASS.
- Step 6: Commit
git add frontend/package.json frontend/.env.e2e frontend/e2e/config/playwright.config.ts frontend/e2e/.auth/.gitignore backend/package.json frontend/e2e/journeys/01-auth.spec.ts
git commit -m "test(e2e): scaffold playwright runtime and local commands"
Task 2: Add guarded backend E2E support and dedicated database mode
Files:
Create:
backend/src/config/env-file-path.tsCreate:
backend/src/e2e-support/e2e-support.module.tsCreate:
backend/src/e2e-support/e2e-support.controller.tsCreate:
backend/src/e2e-support/e2e-support.service.tsCreate:
backend/src/e2e-support/dto/bootstrap-response.dto.tsCreate:
backend/src/e2e-support/dto/assertion-response.dto.tsCreate:
backend/.env.e2eCreate:
backend/scripts/ensure-e2e-database.tsModify:
backend/src/app.module.tsTest:
frontend/e2e/setup/global-setup.tsStep 1: Write a failing backend-admin helper call from setup
// frontend/e2e/utils/backend-admin.ts
export async function resetScenario(request: APIRequestContext, scenario: string) {
const response = await request.post('/e2e/reset', { data: { scenario } });
if (!response.ok()) {
throw new Error(`reset failed: ${response.status()}`);
}
}
// frontend/e2e/setup/global-setup.ts
await resetScenario(request, 'baseline');
- Step 2: Run a setup-dependent spec and confirm
/e2e/resetis missing
Run: cd frontend && bun run e2e:smoke --grep "renders the login screen"
Expected: FAIL with reset failed: 404.
- Step 3: Make Nest load
.env.e2eand import the support module only in E2E mode
// backend/src/config/env-file-path.ts
export const getEnvFilePath = (): string => {
if (process.env.NODE_ENV === 'e2e') {
return '.env.e2e';
}
return '.env';
};
// backend/src/app.module.ts
import { getEnvFilePath } from '@/config/env-file-path';
import { E2eSupportModule } from '@/e2e-support/e2e-support.module';
const e2eImports = process.env.NODE_ENV === 'e2e' ? [E2eSupportModule] : [];
ConfigModule.forRoot({
isGlobal: true,
envFilePath: getEnvFilePath(),
});
@Module({
imports: [
// existing modules...
...e2eImports,
],
})
export class AppModule {}
- Step 4: Implement reset/bootstrap/assertion endpoints and dedicated DB config
// backend/src/e2e-support/e2e-support.controller.ts
@Controller('e2e')
export class E2eSupportController {
constructor(private readonly service: E2eSupportService) {}
@Post('reset')
reset(@Body() body: { scenario: string }) {
return this.service.resetScenario(body.scenario);
}
@Post('bootstrap')
bootstrap() {
return this.service.bootstrapPersonas();
}
@Get('assertions/telemetry')
telemetry(@Query('deviceId') deviceId: string) {
return this.service.getLatestTelemetryAssertion(deviceId);
}
@Get('assertions/automation-executions')
automation(@Query('ruleId') ruleId: string) {
return this.service.getAutomationExecutionAssertion(ruleId);
}
}
// backend/.env.e2e
NODE_ENV=e2e
DATABASE_HOST=127.0.0.1
DATABASE_PORT=5432
DATABASE_USERNAME=hmpiot
DATABASE_PASSWORD=hmpiot_dev
DATABASE_NAME=hmpiot_e2e_local
DATABASE_URL=postgresql://hmpiot:hmpiot_dev@127.0.0.1:5432/hmpiot_e2e_local
DATABASE_SYNCHRONIZE=true
JWT_SECRET=e2e-secret-key
REDIS_URL=redis://127.0.0.1:6379
MQTT_BROKER_URL=mqtt://127.0.0.1:1883
// backend/scripts/ensure-e2e-database.ts
import { Client } from 'pg';
const databaseName = 'hmpiot_e2e_local';
const client = new Client({
host: '127.0.0.1',
port: 5432,
user: 'hmpiot',
password: 'hmpiot_dev',
database: 'postgres',
});
await client.connect();
const exists = await client.query('SELECT 1 FROM pg_database WHERE datname = $1', [databaseName]);
if (exists.rowCount === 0) {
await client.query(`CREATE DATABASE ${databaseName}`);
}
await client.end();
- Step 5: Re-run the smoke spec and verify setup reaches the reset endpoint
Run: cd frontend && bun run e2e:smoke --grep "renders the login screen"
Expected: PASS with backend started against hmpiot_e2e_local.
- Step 6: Commit
git add backend/.env.e2e backend/package.json backend/scripts/ensure-e2e-database.ts backend/src/config/env-file-path.ts backend/src/e2e-support backend/src/app.module.ts frontend/e2e/utils/backend-admin.ts frontend/e2e/setup/global-setup.ts
git commit -m "test(e2e): add guarded backend support module and e2e database mode"
Task 3: Implement auth journey 1 with real registration UI, profile checks, and storage-state bootstrapping
Files:
Create:
frontend/src/pages/auth/Register.tsxCreate:
frontend/e2e/pages/auth/LoginPage.tsCreate:
frontend/e2e/pages/auth/RegisterPage.tsCreate:
frontend/e2e/pages/ProfilePage.tsModify:
frontend/src/App.tsxModify:
frontend/src/stores/auth.store.tsModify:
frontend/src/pages/auth/Login.tsxModify:
frontend/src/pages/profile/ProfilePage.tsxModify:
frontend/e2e/setup/global-setup.tsModify:
frontend/e2e/fixtures/personas.tsModify:
frontend/e2e/journeys/01-auth.spec.tsTest:
frontend/e2e/journeys/01-auth.spec.tsStep 1: Write failing happy and negative auth tests
// frontend/e2e/journeys/01-auth.spec.ts
import { expect, test } from '@playwright/test';
test('@smoke auth register -> login -> profile -> logout', async ({ page }) => {
await page.goto('/register');
await page.getByTestId('register-email').fill('playwright-owner@example.com');
await page.getByTestId('register-password').fill('Password123!');
await page.getByTestId('register-submit').click();
await expect(page).toHaveURL(/\/dashboard/);
await page.goto('/profile');
await expect(page.getByTestId('profile-email')).toContainText('playwright-owner@example.com');
await page.getByTestId('topnav-logout').click();
await expect(page).toHaveURL(/\/login/);
});
test('invalid login shows backend error copy', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('login-email').fill('missing@example.com');
await page.getByTestId('login-password').fill('wrong-password');
await page.getByTestId('login-submit').click();
await expect(page.getByText('Đăng nhập thất bại')).toBeVisible();
});
- Step 2: Run the auth spec and verify
/registeris missing
Run: cd frontend && bunx playwright test -c e2e/config/playwright.config.ts e2e/journeys/01-auth.spec.ts
Expected: FAIL with /register route not found and missing test IDs.
- Step 3: Add the registration screen, route, auth-store action, and stable selectors
// frontend/src/App.tsx
import { RegisterPage } from '@/pages/auth/Register';
<Route element={<PublicRoute />}>
<Route element={<AuthLayout />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
</Route>
</Route>
// frontend/src/stores/auth.store.ts
interface RegisterInput {
email: string;
password: string;
fullName: string;
organizationName?: string;
}
register: async (input) => {
set({ isLoading: true });
try {
await validatedPost('/auth/register', input, userSchema);
await get().login({ email: input.email, password: input.password });
} finally {
set({ isLoading: false });
}
},
// frontend/src/pages/auth/Login.tsx
<Form data-testid="login-form" ...>
<Form.Item name="email" rules={loginRules.email}>
<Input data-testid="login-email" ... />
</Form.Item>
<Form.Item name="password" rules={loginRules.password}>
<Input.Password data-testid="login-password" ... />
</Form.Item>
<Button data-testid="login-submit" ...>
Đăng nhập
</Button>
</Form>
// frontend/src/pages/profile/ProfilePage.tsx
<Title data-testid="profile-name" level={3}>
{user.firstName} {user.lastName}
</Title>
<Text data-testid="profile-email" type="secondary">
{user.email}
</Text>
- Step 4: Implement persona bootstrapping and saved auth states
// frontend/e2e/fixtures/personas.ts
export const personas = {
superAdmin: { email: 'super-admin.e2e@example.com', password: 'Password123!', storageState: 'frontend/e2e/.auth/super-admin.json' },
adminOrgA: { email: 'admin.org-a.e2e@example.com', password: 'Password123!', storageState: 'frontend/e2e/.auth/admin-org-a.json' },
viewerOrgA: { email: 'viewer.org-a.e2e@example.com', password: 'Password123!', storageState: 'frontend/e2e/.auth/viewer-org-a.json' },
adminOrgB: { email: 'admin.org-b.e2e@example.com', password: 'Password123!', storageState: 'frontend/e2e/.auth/admin-org-b.json' },
} as const;
// frontend/e2e/setup/global-setup.ts
const bootstrap = await bootstrapPersonas(request);
for (const persona of bootstrap.personas) {
const page = await browser.newPage();
await page.goto('/login');
await page.getByTestId('login-email').fill(persona.email);
await page.getByTestId('login-password').fill(persona.password);
await page.getByTestId('login-submit').click();
await page.context().storageState({ path: persona.storageStatePath });
await page.close();
}
- Step 5: Re-run auth and verify stored-session continuity
Run: cd frontend && bunx playwright test -c e2e/config/playwright.config.ts e2e/journeys/01-auth.spec.ts
Expected: PASS.
- Step 6: Commit
git add frontend/src/App.tsx frontend/src/stores/auth.store.ts frontend/src/pages/auth/Login.tsx frontend/src/pages/auth/Register.tsx frontend/src/pages/profile/ProfilePage.tsx frontend/e2e/fixtures/personas.ts frontend/e2e/pages/auth/LoginPage.ts frontend/e2e/pages/auth/RegisterPage.ts frontend/e2e/pages/ProfilePage.ts frontend/e2e/setup/global-setup.ts frontend/e2e/journeys/01-auth.spec.ts
git commit -m "test(e2e): cover auth journey with registration and saved sessions"
Task 4: Cover RBAC, multi-tenant isolation, and error-contract UX first
Files:
Create:
frontend/e2e/journeys/02-rbac.spec.tsCreate:
frontend/e2e/journeys/03-tenant-isolation.spec.tsCreate:
frontend/e2e/journeys/04-error-contract.spec.tsCreate:
frontend/e2e/pages/OrganizationPage.tsModify:
frontend/src/pages/organizations/OrganizationList.tsxModify:
frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsxModify:
frontend/src/pages/devices/DeviceDetail.tsxModify:
frontend/src/pages/dashboard/DashboardRoute.tsxTest:
frontend/e2e/journeys/02-rbac.spec.ts,frontend/e2e/journeys/03-tenant-isolation.spec.ts,frontend/e2e/journeys/04-error-contract.spec.tsStep 1: Write failing security-path specs
// frontend/e2e/journeys/02-rbac.spec.ts
import { expect, test } from '@playwright/test';
test.use({ storageState: 'frontend/e2e/.auth/viewer-org-a.json' });
test('@smoke viewer sees deny UX on system inventory', async ({ page }) => {
await page.goto('/system/devices');
await expect(page.getByText('Không có quyền')).toBeVisible();
});
// frontend/e2e/journeys/03-tenant-isolation.spec.ts
test('@smoke org A cannot open org B organization detail', async ({ page }) => {
await page.goto('/admin/organizations/org-b-public-id');
await expect(page.getByTestId('organization-not-found')).toBeVisible();
});
// frontend/e2e/journeys/04-error-contract.spec.ts
test('unknown device detail maps backend 404 to empty state', async ({ page }) => {
await page.goto('/devices/non-existent-device-id');
await expect(page.getByTestId('device-empty-state')).toBeVisible();
});
- Step 2: Run only the new security specs and confirm selector/UX gaps
Run: cd frontend && bunx playwright test -c e2e/config/playwright.config.ts e2e/journeys/02-rbac.spec.ts e2e/journeys/03-tenant-isolation.spec.ts e2e/journeys/04-error-contract.spec.ts
Expected: FAIL with missing test IDs and inconsistent deny/error states.
- Step 3: Add stable selectors and explicit deny/error affordances
// frontend/src/pages/organizations/OrganizationList.tsx
<PageHeader
title="Quản lý tổ chức"
subtitle="Quản lý tất cả tổ chức trong hệ thống"
extra={
<Button data-testid="organization-create-trigger" type="primary" icon={<PlusOutlined />}>
Tạo tổ chức
</Button>
}
/>
// frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx
<Button data-testid="system-device-create-trigger" type="primary" icon={<PlusOutlined />}>
Thêm thiết bị
</Button>
// frontend/src/pages/devices/DeviceDetail.tsx
<EmptyState
title="Không tìm thấy thiết bị"
data-testid="device-empty-state"
description="Thiết bị bạn đang tìm không tồn tại hoặc đã bị xóa."
/>
- Step 4: Add backend-aware assertions for hidden mutation
// frontend/e2e/utils/backend-admin.ts
export async function assertOrganizationStillAbsent(
request: APIRequestContext,
organizationName: string,
) {
const response = await request.get(`/e2e/assertions/organizations?name=${organizationName}`);
const payload = await response.json();
expect(payload.exists).toBe(false);
}
- Step 5: Re-run the security suites
Run: cd frontend && bunx playwright test -c e2e/config/playwright.config.ts e2e/journeys/02-rbac.spec.ts e2e/journeys/03-tenant-isolation.spec.ts e2e/journeys/04-error-contract.spec.ts
Expected: PASS.
- Step 6: Commit
git add frontend/e2e/journeys/02-rbac.spec.ts frontend/e2e/journeys/03-tenant-isolation.spec.ts frontend/e2e/journeys/04-error-contract.spec.ts frontend/e2e/pages/OrganizationPage.ts frontend/e2e/utils/backend-admin.ts frontend/src/pages/organizations/OrganizationList.tsx frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx frontend/src/pages/devices/DeviceDetail.tsx frontend/src/pages/dashboard/DashboardRoute.tsx
git commit -m "test(e2e): cover rbac tenant isolation and error contract flows"
Task 5: Implement organization onboarding and farm management journeys
Files:
Create:
frontend/e2e/pages/UserPage.tsCreate:
frontend/e2e/pages/FarmPage.tsCreate:
frontend/e2e/journeys/05-organization-onboarding.spec.tsCreate:
frontend/e2e/journeys/06-farm-management.spec.tsModify:
frontend/src/pages/organizations/OrganizationCreate.tsxModify:
frontend/src/pages/users/UsersPage.tsxModify:
frontend/src/components/users/UserSlideOver.tsxModify:
frontend/src/components/users/UserForm.tsxModify:
frontend/src/pages/farm/AreaList.tsxModify:
frontend/src/components/farm/AreaForm.tsxModify:
frontend/src/pages/farm/PondList.tsxModify:
frontend/src/components/farm/PondForm.tsxTest:
frontend/e2e/journeys/05-organization-onboarding.spec.ts,frontend/e2e/journeys/06-farm-management.spec.tsStep 1: Write failing onboarding and farm specs
// frontend/e2e/journeys/05-organization-onboarding.spec.ts
test('super admin creates organization and first user', async ({ page, request }) => {
await page.goto('/admin/organizations/create');
await page.getByTestId('organization-name').fill('Playwright Org A');
await page.getByTestId('organization-submit').click();
await expect(page.getByText('Playwright Org A')).toBeVisible();
await page.goto('/users');
await page.getByTestId('user-create-trigger').click();
await page.getByTestId('user-email').fill('first.user@playwright-org-a.example');
await page.getByTestId('user-submit').click();
await expect(page.getByText('first.user@playwright-org-a.example')).toBeVisible();
});
// frontend/e2e/journeys/06-farm-management.spec.ts
test('org admin creates area and pond and sees scoped list', async ({ page }) => {
await page.goto('/farms/areas/create');
await page.getByTestId('area-name').fill('Area A');
await page.getByTestId('area-code').fill('AREA-A');
await page.getByTestId('area-submit').click();
await page.goto('/farms/ponds/create');
await page.getByTestId('pond-name').fill('Pond A1');
await page.getByTestId('pond-code').fill('POND-A1');
await page.getByTestId('pond-submit').click();
await expect(page.getByText('Pond A1')).toBeVisible();
});
- Step 2: Run the onboarding/farm specs and confirm form hooks are missing
Run: cd frontend && bunx playwright test -c e2e/config/playwright.config.ts e2e/journeys/05-organization-onboarding.spec.ts e2e/journeys/06-farm-management.spec.ts
Expected: FAIL.
- Step 3: Add selector-ready form fields and list assertions
// frontend/src/pages/organizations/OrganizationCreate.tsx
<Form form={form} layout="vertical" onFinish={handleSubmit} data-testid="organization-form">
<Form.Item label="Tên tổ chức" name="name" rules={[{ required: true, message: 'Tên tổ chức là bắt buộc' }]}>
<Input data-testid="organization-name" placeholder="VD: Công ty TNHH Thủy Sản Bến Tre" size="large" />
</Form.Item>
<Button data-testid="organization-submit" type="primary" htmlType="submit">
Lưu
</Button>
</Form>
// frontend/src/components/users/UserForm.tsx
<Input data-testid="user-email" ... />
<Input.Password data-testid="user-password" ... />
<Button data-testid="user-submit" type="primary" htmlType="submit">
Lưu người dùng
</Button>
// frontend/src/components/farm/AreaForm.tsx
<Input data-testid="area-name" ... />
<Input data-testid="area-code" ... />
<Button data-testid="area-submit" htmlType="submit" type="primary">
Lưu khu vực
</Button>
// frontend/src/components/farm/PondForm.tsx
<Input data-testid="pond-name" ... />
<Input data-testid="pond-code" ... />
<Button data-testid="pond-submit" htmlType="submit" type="primary">
Lưu ao nuôi
</Button>
- Step 4: Add backend assertions for org-scoped pagination/filtering
// frontend/e2e/journeys/06-farm-management.spec.ts
const scoped = await request.get('/api/v1/e2e/assertions/ponds?organization=org-a');
await expect(scoped).toBeOK();
- Step 5: Run the onboarding/farm specs again
Run: cd frontend && bunx playwright test -c e2e/config/playwright.config.ts e2e/journeys/05-organization-onboarding.spec.ts e2e/journeys/06-farm-management.spec.ts
Expected: PASS.
- Step 6: Commit
git add frontend/e2e/pages/UserPage.ts frontend/e2e/pages/FarmPage.ts frontend/e2e/journeys/05-organization-onboarding.spec.ts frontend/e2e/journeys/06-farm-management.spec.ts frontend/src/pages/organizations/OrganizationCreate.tsx frontend/src/pages/users/UsersPage.tsx frontend/src/components/users/UserSlideOver.tsx frontend/src/components/users/UserForm.tsx frontend/src/pages/farm/AreaList.tsx frontend/src/components/farm/AreaForm.tsx frontend/src/pages/farm/PondList.tsx frontend/src/components/farm/PondForm.tsx
git commit -m "test(e2e): add organization onboarding and farm management journeys"
Task 6: Implement device lifecycle plus valid and invalid telemetry flows
Files:
Create:
frontend/e2e/pages/SystemDeviceInventoryPage.tsCreate:
frontend/e2e/pages/DevicePage.tsCreate:
frontend/e2e/journeys/07-device-lifecycle.spec.tsCreate:
frontend/e2e/journeys/08-telemetry.spec.tsModify:
frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsxModify:
frontend/src/pages/devices/DeviceList.tsxModify:
frontend/src/pages/devices/DeviceDetail.tsxModify:
frontend/e2e/utils/backend-admin.tsTest:
frontend/e2e/journeys/07-device-lifecycle.spec.ts,frontend/e2e/journeys/08-telemetry.spec.tsStep 1: Write failing device-lifecycle and telemetry specs
// frontend/e2e/journeys/07-device-lifecycle.spec.ts
test('super admin registers device and org admin assigns it to pond', async ({ page }) => {
await page.goto('/system/devices');
await page.getByTestId('system-device-create-trigger').click();
await page.getByTestId('system-device-type').selectOption('sensor');
await page.getByTestId('system-device-submit').click();
await expect(page.getByTestId('system-device-row')).toContainText('sensor');
});
// frontend/e2e/journeys/08-telemetry.spec.ts
test('@smoke valid telemetry appears in latest view', async ({ page, request }) => {
const seeded = await request.post('/api/v1/e2e/telemetry/emit', {
data: { deviceSerial: 'E2E-SENSOR-001', data: { temperature: 28.5, ph: 7.2 } },
});
await expect(seeded).toBeOK();
await page.goto('/devices/e2e-sensor-id');
await expect(page.getByTestId('telemetry-latest-temperature')).toContainText('28.5');
});
test('invalid telemetry is rejected and absent from history', async ({ page, request }) => {
const rejected = await request.post('/api/v1/e2e/telemetry/emit', {
data: { deviceSerial: 'E2E-SENSOR-001', data: { temperature: 'bad-value' } },
});
expect(rejected.status()).toBe(422);
await page.goto('/devices/e2e-sensor-id');
await expect(page.getByText('bad-value')).toHaveCount(0);
});
- Step 2: Run the device/telemetry specs and verify emit/assert helpers do not exist
Run: cd frontend && bunx playwright test -c e2e/config/playwright.config.ts e2e/journeys/07-device-lifecycle.spec.ts e2e/journeys/08-telemetry.spec.ts
Expected: FAIL.
- Step 3: Add selector-ready device inventory and device detail hooks
// frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx
<Select data-testid="system-device-type" ... />
<Input data-testid="system-device-name" ... />
<Input data-testid="system-device-serial" ... />
<Button data-testid="system-device-submit" type="primary" htmlType="submit">
{editingDevice ? 'Cập nhật thiết bị' : 'Thêm thiết bị'}
</Button>
// frontend/src/pages/devices/DeviceList.tsx
<Input data-testid="device-search" prefix={<SearchOutlined />} ... />
<Table rowKey="id" data-testid="device-table" ... />
// frontend/src/pages/devices/DeviceDetail.tsx
<Statistic data-testid="telemetry-latest-temperature" title="Temperature" value={latestTemperature} />
<Button data-testid="telemetry-export" icon={<DownloadOutlined />}>
Xuất dữ liệu
</Button>
- Step 4: Extend backend E2E support with telemetry emit/assert endpoints
// backend/src/e2e-support/e2e-support.controller.ts
@Post('telemetry/emit')
emitTelemetry(@Body() body: { deviceSerial: string; data: Record<string, number> }) {
return this.service.emitTelemetry(body);
}
@Get('assertions/telemetry-history')
getHistory(@Query('deviceSerial') deviceSerial: string) {
return this.service.getTelemetryHistoryAssertion(deviceSerial);
}
- Step 5: Re-run the device/telemetry specs
Run: cd frontend && bunx playwright test -c e2e/config/playwright.config.ts e2e/journeys/07-device-lifecycle.spec.ts e2e/journeys/08-telemetry.spec.ts
Expected: PASS.
- Step 6: Commit
git add frontend/e2e/pages/SystemDeviceInventoryPage.ts frontend/e2e/pages/DevicePage.ts frontend/e2e/journeys/07-device-lifecycle.spec.ts frontend/e2e/journeys/08-telemetry.spec.ts frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx frontend/src/pages/devices/DeviceList.tsx frontend/src/pages/devices/DeviceDetail.tsx frontend/e2e/utils/backend-admin.ts backend/src/e2e-support/e2e-support.controller.ts backend/src/e2e-support/e2e-support.service.ts
git commit -m "test(e2e): cover device lifecycle and telemetry journeys"
Task 7: Implement dashboard aggregation, automation, and release-smoke journeys
Files:
Create:
frontend/e2e/pages/DashboardPage.tsCreate:
frontend/e2e/pages/AutomationPage.tsCreate:
frontend/e2e/journeys/09-dashboard.spec.tsCreate:
frontend/e2e/journeys/10-automation.spec.tsCreate:
frontend/e2e/journeys/11-release-smoke.spec.tsCreate:
frontend/e2e/utils/polling.tsModify:
frontend/src/pages/dashboard/admin/AdminDashboard.tsxModify:
frontend/src/pages/dashboard/owner/OwnerDashboard.tsxModify:
frontend/src/pages/automation/AutomationRulesPage.tsxModify:
backend/src/e2e-support/e2e-support.service.tsTest:
frontend/e2e/journeys/09-dashboard.spec.ts,frontend/e2e/journeys/10-automation.spec.ts,frontend/e2e/journeys/11-release-smoke.spec.tsStep 1: Write failing dashboard and automation specs
// frontend/e2e/journeys/09-dashboard.spec.ts
test('@smoke dashboard cards reflect seeded telemetry', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByTestId('dashboard-online-devices')).toContainText('1');
await expect(page.getByTestId('dashboard-total-ponds')).toContainText('1');
});
// frontend/e2e/journeys/10-automation.spec.ts
test('rule trigger creates execution evidence', async ({ page, request }) => {
await page.goto('/automation/rules');
await page.getByTestId('automation-create-trigger').click();
await page.getByTestId('automation-rule-name').fill('DO thấp thì bật bơm');
await page.getByTestId('automation-submit').click();
await request.post('/api/v1/e2e/telemetry/emit', {
data: { deviceSerial: 'E2E-SENSOR-001', data: { do: 3.8 } },
});
await expect(page.getByTestId('automation-rule-row')).toContainText('DO thấp thì bật bơm');
await expect.poll(async () => {
const res = await request.get('/api/v1/e2e/assertions/automation-executions?ruleName=DO thấp thì bật bơm');
const payload = await res.json();
return payload.count;
}).toBeGreaterThan(0);
});
// frontend/e2e/journeys/11-release-smoke.spec.ts
test('@smoke release path: login -> dashboard -> create area -> verify telemetry card', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByTestId('dashboard-total-ponds')).toBeVisible();
await page.goto('/farms/areas/create');
await page.getByTestId('area-name').fill('Smoke Area');
await page.getByTestId('area-submit').click();
await expect(page.getByText('Smoke Area')).toBeVisible();
});
- Step 2: Run the final journey group and confirm missing automation/dashboard hooks
Run: cd frontend && bunx playwright test -c e2e/config/playwright.config.ts e2e/journeys/09-dashboard.spec.ts e2e/journeys/10-automation.spec.ts e2e/journeys/11-release-smoke.spec.ts
Expected: FAIL.
- Step 3: Add stable dashboard and automation selectors
// frontend/src/pages/automation/AutomationRulesPage.tsx
<PageHeader
title="Automation Rules"
subtitle="Tạo rule điều khiển giữa sensor, gateway và actuator trong cùng farm."
extra={
<Button data-testid="automation-create-trigger" type="primary">
Tạo rule
</Button>
}
/>
<Table rowKey="id" data-testid="automation-rule-table" ... />
// frontend/src/pages/dashboard/owner/OwnerDashboard.tsx
<Card data-testid="dashboard-total-ponds">...</Card>
<Card data-testid="dashboard-online-devices">...</Card>
- Step 4: Add bounded eventual-consistency helper instead of sleeps
// frontend/e2e/utils/polling.ts
export async function pollUntil<T>(
read: () => Promise<T>,
isDone: (value: T) => boolean,
timeoutMs = 15_000,
intervalMs = 500,
): Promise<T> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const value = await read();
if (isDone(value)) {
return value;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error('Timed out while polling semantic condition');
}
- Step 5: Re-run the final journey group
Run: cd frontend && bunx playwright test -c e2e/config/playwright.config.ts e2e/journeys/09-dashboard.spec.ts e2e/journeys/10-automation.spec.ts e2e/journeys/11-release-smoke.spec.ts
Expected: PASS.
- Step 6: Commit
git add frontend/e2e/pages/DashboardPage.ts frontend/e2e/pages/AutomationPage.ts frontend/e2e/utils/polling.ts frontend/e2e/journeys/09-dashboard.spec.ts frontend/e2e/journeys/10-automation.spec.ts frontend/e2e/journeys/11-release-smoke.spec.ts frontend/src/pages/dashboard/admin/AdminDashboard.tsx frontend/src/pages/dashboard/owner/OwnerDashboard.tsx frontend/src/pages/automation/AutomationRulesPage.tsx backend/src/e2e-support/e2e-support.service.ts
git commit -m "test(e2e): add dashboard automation and release smoke journeys"
Task 8: Stabilize reset semantics, finalize smoke/full runs, and prove repeatability
Files:
Modify:
frontend/e2e/setup/global-setup.tsModify:
frontend/e2e/setup/global-teardown.tsModify:
frontend/e2e/utils/backend-admin.tsModify:
frontend/e2e/config/playwright.config.tsModify:
frontend/package.jsonTest: full Playwright suite
Step 1: Add per-spec reset and failure diagnostics hooks
// frontend/e2e/setup/global-setup.ts
await resetScenario(request, 'baseline');
// frontend/e2e/journeys/08-telemetry.spec.ts
test.beforeEach(async ({ request }) => {
await resetScenario(request, 'telemetry');
});
- Step 2: Tighten smoke/full command boundaries
// frontend/package.json
{
"scripts": {
"e2e:smoke": "playwright test -c e2e/config/playwright.config.ts --grep @smoke",
"e2e:full": "playwright test -c e2e/config/playwright.config.ts",
"e2e:debug": "playwright test -c e2e/config/playwright.config.ts --ui --headed"
}
}
- Step 3: Add reporter output folders and keep traces on failure
// frontend/e2e/config/playwright.config.ts
reporter: [
['list'],
['html', { open: 'never', outputFolder: '../artifacts/html-report' }],
['junit', { outputFile: '../artifacts/junit.xml' }],
],
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
- Step 4: Run smoke three times and full once
Run: cd frontend && bun run e2e:smoke
Expected: PASS three consecutive times, runtime <= 7 minutes.
Run: cd frontend && bun run e2e:full
Expected: PASS once with all 12 journeys covered.
- Step 5: Commit
git add frontend/e2e/setup/global-setup.ts frontend/e2e/setup/global-teardown.ts frontend/e2e/utils/backend-admin.ts frontend/e2e/config/playwright.config.ts frontend/package.json
git commit -m "test(e2e): stabilize suite and finalize smoke full commands"
Spec Coverage Check
- Journey 1
Auth basic: Task 3. - Journey 2
RBAC enforcement: Task 4. - Journey 3
Multi-tenant isolation: Task 4. - Journey 4
Error contract UX: Task 4. - Journey 5
Organization onboarding: Task 5. - Journey 6
Farm management: Task 5. - Journey 7
Device lifecycle: Task 6. - Journey 8
Telemetry valid payload flow: Task 6. - Journey 9
Telemetry invalid payload flow: Task 6. - Journey 10
Dashboard aggregation: Task 7. - Journey 11
Automation rule flow: Task 7. - Journey 12
Release smoke journey: Task 7 plus Task 8 repeatability checks.
Placeholder Scan
- No
TBD,TODO, or “implement later” markers remain. - The only deliberate gap is CI integration, which the source spec explicitly marked out of scope for this phase.
Type / Interface Consistency Check
- The plan consistently uses
storageState,resetScenario,bootstrapPersonas, and/api/v1/e2e/*as the E2E support contract. - The environment naming is consistent:
NODE_ENV=e2e,backend/.env.e2e,frontend/.env.e2e, databasehmpiot_e2e_local. - The smoke tag is consistently
@smoke.