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.md remains 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.ts
  • 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/journeys/05-organization-onboarding.spec.ts
  • frontend/e2e/journeys/06-farm-management.spec.ts
  • frontend/e2e/journeys/07-device-lifecycle.spec.ts
  • frontend/e2e/journeys/08-telemetry.spec.ts
  • frontend/e2e/journeys/09-dashboard.spec.ts
  • frontend/e2e/journeys/10-automation.spec.ts
  • frontend/e2e/journeys/11-release-smoke.spec.ts
  • frontend/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 .env vs .env.e2e.
  • backend/src/e2e-support/e2e-support.module.ts
  • backend/src/e2e-support/e2e-support.controller.ts
  • backend/src/e2e-support/e2e-support.service.ts
  • backend/src/e2e-support/dto/bootstrap-response.dto.ts
  • backend/src/e2e-support/dto/assertion-response.dto.ts
  • backend/scripts/ensure-e2e-database.ts - creates hmpiot_e2e_local if missing.

Modify

  • frontend/package.json - add e2e:install, e2e:smoke, e2e:full, e2e:debug.
  • frontend/src/App.tsx - add /register route and keep protected route coverage intact.
  • frontend/src/pages/auth/Login.tsx - add stable data-testid hooks.
  • frontend/src/pages/profile/ProfilePage.tsx - add stable profile/session hooks.
  • frontend/src/stores/auth.store.ts - add register action and keep persisted auth consistent.
  • frontend/src/pages/organizations/OrganizationList.tsx
  • 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
  • frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx
  • frontend/src/pages/devices/DeviceList.tsx
  • frontend/src/pages/devices/DeviceDetail.tsx
  • frontend/src/pages/automation/AutomationRulesPage.tsx
  • frontend/src/pages/dashboard/DashboardRoute.tsx
  • frontend/src/pages/dashboard/admin/AdminDashboard.tsx
  • frontend/src/pages/dashboard/owner/OwnerDashboard.tsx
  • backend/package.json - add start:e2e.
  • backend/src/app.module.ts - load .env.e2e, conditionally import E2E support module.

Validate / reference while implementing

  • frontend/src/lib/api.ts
  • frontend/src/pages/auth/Login.tsx
  • frontend/src/pages/organizations/OrganizationList.tsx
  • frontend/src/pages/organizations/OrganizationCreate.tsx
  • frontend/src/pages/users/UsersPage.tsx
  • frontend/src/pages/farm/AreaList.tsx
  • frontend/src/pages/devices/DeviceList.tsx
  • frontend/src/pages/devices/DeviceDetail.tsx
  • frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx
  • frontend/src/pages/automation/AutomationRulesPage.tsx
  • backend/test/auth/auth.controller.e2e-spec.ts
  • backend/test/telemetry/telemetry.controller.e2e-spec.ts
  • backend/test/automation/automation-flow.e2e-spec.ts

Task 1: Scaffold the Playwright runtime and local command contract

Files:

  • Create: frontend/e2e/config/playwright.config.ts

  • Create: frontend/e2e/.auth/.gitignore

  • Create: frontend/.env.e2e

  • Modify: frontend/package.json

  • Modify: backend/package.json

  • Test: frontend/e2e/journeys/01-auth.spec.ts

  • Step 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.ts

  • Create: backend/src/e2e-support/e2e-support.module.ts

  • Create: backend/src/e2e-support/e2e-support.controller.ts

  • Create: backend/src/e2e-support/e2e-support.service.ts

  • Create: backend/src/e2e-support/dto/bootstrap-response.dto.ts

  • Create: backend/src/e2e-support/dto/assertion-response.dto.ts

  • Create: backend/.env.e2e

  • Create: backend/scripts/ensure-e2e-database.ts

  • Modify: backend/src/app.module.ts

  • Test: frontend/e2e/setup/global-setup.ts

  • Step 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/reset is 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.e2e and 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.tsx

  • Create: frontend/e2e/pages/auth/LoginPage.ts

  • Create: frontend/e2e/pages/auth/RegisterPage.ts

  • Create: frontend/e2e/pages/ProfilePage.ts

  • Modify: frontend/src/App.tsx

  • Modify: frontend/src/stores/auth.store.ts

  • Modify: frontend/src/pages/auth/Login.tsx

  • Modify: frontend/src/pages/profile/ProfilePage.tsx

  • Modify: frontend/e2e/setup/global-setup.ts

  • Modify: frontend/e2e/fixtures/personas.ts

  • Modify: frontend/e2e/journeys/01-auth.spec.ts

  • Test: frontend/e2e/journeys/01-auth.spec.ts

  • Step 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 /register is 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.ts

  • Create: frontend/e2e/journeys/03-tenant-isolation.spec.ts

  • Create: frontend/e2e/journeys/04-error-contract.spec.ts

  • Create: frontend/e2e/pages/OrganizationPage.ts

  • Modify: frontend/src/pages/organizations/OrganizationList.tsx

  • Modify: frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx

  • Modify: frontend/src/pages/devices/DeviceDetail.tsx

  • Modify: frontend/src/pages/dashboard/DashboardRoute.tsx

  • Test: frontend/e2e/journeys/02-rbac.spec.ts, frontend/e2e/journeys/03-tenant-isolation.spec.ts, frontend/e2e/journeys/04-error-contract.spec.ts

  • Step 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.ts

  • Create: frontend/e2e/pages/FarmPage.ts

  • Create: frontend/e2e/journeys/05-organization-onboarding.spec.ts

  • Create: frontend/e2e/journeys/06-farm-management.spec.ts

  • Modify: frontend/src/pages/organizations/OrganizationCreate.tsx

  • Modify: frontend/src/pages/users/UsersPage.tsx

  • Modify: frontend/src/components/users/UserSlideOver.tsx

  • Modify: frontend/src/components/users/UserForm.tsx

  • Modify: frontend/src/pages/farm/AreaList.tsx

  • Modify: frontend/src/components/farm/AreaForm.tsx

  • Modify: frontend/src/pages/farm/PondList.tsx

  • Modify: frontend/src/components/farm/PondForm.tsx

  • Test: frontend/e2e/journeys/05-organization-onboarding.spec.ts, frontend/e2e/journeys/06-farm-management.spec.ts

  • Step 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.ts

  • Create: frontend/e2e/pages/DevicePage.ts

  • Create: frontend/e2e/journeys/07-device-lifecycle.spec.ts

  • Create: frontend/e2e/journeys/08-telemetry.spec.ts

  • Modify: frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx

  • Modify: frontend/src/pages/devices/DeviceList.tsx

  • Modify: frontend/src/pages/devices/DeviceDetail.tsx

  • Modify: frontend/e2e/utils/backend-admin.ts

  • Test: frontend/e2e/journeys/07-device-lifecycle.spec.ts, frontend/e2e/journeys/08-telemetry.spec.ts

  • Step 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.ts

  • Create: frontend/e2e/pages/AutomationPage.ts

  • Create: frontend/e2e/journeys/09-dashboard.spec.ts

  • Create: frontend/e2e/journeys/10-automation.spec.ts

  • Create: frontend/e2e/journeys/11-release-smoke.spec.ts

  • Create: frontend/e2e/utils/polling.ts

  • Modify: frontend/src/pages/dashboard/admin/AdminDashboard.tsx

  • Modify: frontend/src/pages/dashboard/owner/OwnerDashboard.tsx

  • Modify: frontend/src/pages/automation/AutomationRulesPage.tsx

  • Modify: backend/src/e2e-support/e2e-support.service.ts

  • Test: frontend/e2e/journeys/09-dashboard.spec.ts, frontend/e2e/journeys/10-automation.spec.ts, frontend/e2e/journeys/11-release-smoke.spec.ts

  • Step 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.ts

  • Modify: frontend/e2e/setup/global-teardown.ts

  • Modify: frontend/e2e/utils/backend-admin.ts

  • Modify: frontend/e2e/config/playwright.config.ts

  • Modify: frontend/package.json

  • Test: 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, database hmpiot_e2e_local.
  • The smoke tag is consistently @smoke.