2533 lines
71 KiB
Markdown
2533 lines
71 KiB
Markdown
# Phase 3 E2E Tests 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:** Implement comprehensive Playwright E2E test suite with 5 modular workflows (login, scan-adjust, AI extraction, admin settings, offline sync), each running in isolated Docker containers with parallel execution completing <30 minutes.
|
|
|
|
**Architecture:** Modular workflow design with shared fixtures (db, ldap, auth) and utilities (assertions, docker orchestration). Each workflow tests independently with dedicated ports, database, and LDAP server (where needed).
|
|
|
|
**Tech Stack:** Playwright, Docker Compose, SQLite, OpenLDAP fixture container, Node.js
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**New Files to Create:**
|
|
```
|
|
frontend/e2e/
|
|
├── workflows/ # Per-workflow test files (5 files)
|
|
│ ├── 1-login.spec.ts # LDAP + local auth tests
|
|
│ ├── 2-scan-adjust.spec.ts # Barcode scanning tests
|
|
│ ├── 3-ai-extraction.spec.ts # AI onboarding tests
|
|
│ ├── 4-admin-settings.spec.ts # Admin dashboard tests
|
|
│ └── 5-offline-sync.spec.ts # Offline queue + sync tests
|
|
├── fixtures/ # Shared test fixtures (4 files)
|
|
│ ├── db.ts # SQLite setup/seed/cleanup
|
|
│ ├── ldap.ts # OpenLDAP container setup
|
|
│ ├── auth.ts # Login/session helpers
|
|
│ └── test-data.ts # Seed data definitions & factories
|
|
├── utils/ # Helper utilities (3 files)
|
|
│ ├── assertions.ts # Custom Playwright matchers
|
|
│ ├── docker.ts # Container lifecycle management
|
|
│ └── helpers.ts # Navigation, wait conditions
|
|
├── docker-compose.e2e.yml # Docker services template
|
|
├── playwright.config.ts # Playwright configuration
|
|
└── README.md # Setup & execution guide
|
|
```
|
|
|
|
**Modified Files:**
|
|
```
|
|
frontend/package.json # Add @playwright/test dependency
|
|
```
|
|
|
|
---
|
|
|
|
## Phase A: Setup & Infrastructure
|
|
|
|
### Task 1: Install Playwright & Create E2E Directory Structure
|
|
|
|
**Files:**
|
|
- Modify: `frontend/package.json`
|
|
- Create: `frontend/e2e/` directories
|
|
|
|
- [ ] **Step 1: Add Playwright dependency to package.json**
|
|
|
|
Edit `frontend/package.json`, in `devDependencies` section, add:
|
|
```json
|
|
"@playwright/test": "^1.40.0"
|
|
```
|
|
|
|
- [ ] **Step 2: Install dependencies**
|
|
|
|
Run: `cd frontend && npm install`
|
|
Expected: `@playwright/test` installed in `node_modules`
|
|
|
|
- [ ] **Step 3: Create directory structure**
|
|
|
|
Run:
|
|
```bash
|
|
cd frontend
|
|
mkdir -p e2e/workflows e2e/fixtures e2e/utils
|
|
```
|
|
|
|
- [ ] **Step 4: Verify structure**
|
|
|
|
Run: `find frontend/e2e -type d`
|
|
Expected:
|
|
```
|
|
frontend/e2e
|
|
frontend/e2e/workflows
|
|
frontend/e2e/fixtures
|
|
frontend/e2e/utils
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/package.json frontend/package-lock.json
|
|
git commit -m "feat: install Playwright and create e2e directory structure"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Create Docker Compose Configuration
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/docker-compose.e2e.yml`
|
|
|
|
- [ ] **Step 1: Write docker-compose.e2e.yml**
|
|
|
|
Create `frontend/e2e/docker-compose.e2e.yml`:
|
|
```yaml
|
|
version: '3.8'
|
|
|
|
services:
|
|
backend:
|
|
build:
|
|
context: ../../backend
|
|
dockerfile: Dockerfile
|
|
ports:
|
|
- "${BACKEND_PORT}:8906"
|
|
environment:
|
|
DATABASE_URL: "sqlite:////tmp/test-${WORKFLOW_ID}.db"
|
|
LDAP_ENABLED: "${LDAP_ENABLED}"
|
|
LDAP_SERVER: "${LDAP_SERVER}"
|
|
LDAP_BASE_DN: "${LDAP_BASE_DN}"
|
|
AI_PROVIDER: "${AI_PROVIDER}"
|
|
GEMINI_API_KEY: "test-key-${WORKFLOW_ID}"
|
|
CLAUDE_API_KEY: "test-key-${WORKFLOW_ID}"
|
|
LOG_LEVEL: "INFO"
|
|
depends_on:
|
|
- ldap
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-f", "http://localhost:8906/health"]
|
|
interval: 2s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 5s
|
|
networks:
|
|
- e2e-network
|
|
|
|
frontend:
|
|
image: node:20-alpine
|
|
working_dir: /app
|
|
volumes:
|
|
- ../../frontend:/app
|
|
ports:
|
|
- "${FRONTEND_PORT}:3000"
|
|
environment:
|
|
NEXT_PUBLIC_API_URL: "http://localhost:${BACKEND_PORT}"
|
|
NEXT_PUBLIC_API_BASE_PATH: ""
|
|
command: >
|
|
sh -c "npm install --legacy-peer-deps && npm run dev"
|
|
healthcheck:
|
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"]
|
|
interval: 2s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 10s
|
|
networks:
|
|
- e2e-network
|
|
|
|
ldap:
|
|
image: osixia/openldap:1.5.0
|
|
ports:
|
|
- "${LDAP_PORT}:389"
|
|
environment:
|
|
LDAP_ORGANISATION: "aInventory Test"
|
|
LDAP_DOMAIN: "ainventory.local"
|
|
LDAP_BASE_DN: "dc=ainventory,dc=local"
|
|
LDAP_ADMIN_PASSWORD: "admin"
|
|
LDAP_CONFIG_PASSWORD: "config"
|
|
healthcheck:
|
|
test: ["CMD", "ldapwhoami", "-H", "ldap://localhost:389", "-D", "cn=admin,dc=ainventory,dc=local", "-w", "admin"]
|
|
interval: 2s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 5s
|
|
networks:
|
|
- e2e-network
|
|
|
|
networks:
|
|
e2e-network:
|
|
driver: bridge
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/docker-compose.e2e.yml | head -20`
|
|
Expected: YAML content shown, no errors
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/docker-compose.e2e.yml
|
|
git commit -m "feat: add docker-compose configuration for e2e testing"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Create Playwright Configuration
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/playwright.config.ts`
|
|
|
|
- [ ] **Step 1: Write playwright.config.ts**
|
|
|
|
Create `frontend/e2e/playwright.config.ts`:
|
|
```typescript
|
|
import { defineConfig, devices } from '@playwright/test';
|
|
|
|
export default defineConfig({
|
|
testDir: './workflows',
|
|
fullyParallel: true,
|
|
forbidOnly: !!process.env.CI,
|
|
retries: process.env.CI ? 2 : 0,
|
|
workers: process.env.CI ? 1 : 5,
|
|
reporter: 'html',
|
|
timeout: 30000,
|
|
expect: { timeout: 5000 },
|
|
use: {
|
|
baseURL: 'http://localhost',
|
|
trace: 'on-first-retry',
|
|
screenshot: 'only-on-failure',
|
|
},
|
|
|
|
projects: [
|
|
{
|
|
name: 'chromium',
|
|
use: { ...devices['Desktop Chrome'] },
|
|
},
|
|
],
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/playwright.config.ts | head -15`
|
|
Expected: TypeScript config shown
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/playwright.config.ts
|
|
git commit -m "feat: add playwright configuration"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase B: Fixtures & Utilities
|
|
|
|
### Task 4: Create Test Data Definitions
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/fixtures/test-data.ts`
|
|
|
|
- [ ] **Step 1: Write test-data.ts**
|
|
|
|
Create `frontend/e2e/fixtures/test-data.ts`:
|
|
```typescript
|
|
// Test user definitions
|
|
export const LDAP_USERS = {
|
|
valid: {
|
|
username: 'testuser1',
|
|
password: 'Password123!',
|
|
email: 'testuser1@ainventory.local',
|
|
},
|
|
invalid: {
|
|
username: 'invalid_user',
|
|
password: 'wrong_password',
|
|
},
|
|
};
|
|
|
|
export const LOCAL_USERS = {
|
|
admin: {
|
|
username: 'admin',
|
|
password: 'AdminPassword123!',
|
|
email: 'admin@ainventory.local',
|
|
},
|
|
regular: {
|
|
username: 'testuser2',
|
|
password: 'UserPassword123!',
|
|
email: 'testuser2@ainventory.local',
|
|
},
|
|
};
|
|
|
|
// Test inventory items (for workflow 2 seeding)
|
|
export const TEST_ITEMS = [
|
|
{
|
|
name: 'Widget A',
|
|
barcode: '123456789',
|
|
part_number: 'WA-001',
|
|
category: 'Electronics',
|
|
quantity: 50,
|
|
},
|
|
{
|
|
name: 'Widget B',
|
|
barcode: '987654321',
|
|
part_number: 'WB-001',
|
|
category: 'Electronics',
|
|
quantity: 25,
|
|
},
|
|
{
|
|
name: 'Gadget X',
|
|
barcode: '555555555',
|
|
part_number: 'GX-001',
|
|
category: 'Hardware',
|
|
quantity: 100,
|
|
},
|
|
{
|
|
name: 'Component Y',
|
|
barcode: '444444444',
|
|
part_number: 'CY-001',
|
|
category: 'Parts',
|
|
quantity: 200,
|
|
},
|
|
{
|
|
name: 'Module Z',
|
|
barcode: '333333333',
|
|
part_number: 'MZ-001',
|
|
category: 'Modules',
|
|
quantity: 75,
|
|
},
|
|
{
|
|
name: 'Box Label Test',
|
|
barcode: 'BOX-001',
|
|
part_number: 'BOX-TEST',
|
|
category: 'Containers',
|
|
quantity: 10,
|
|
box_label: 'TestBox-A',
|
|
},
|
|
];
|
|
|
|
// Test categories (for workflow 4 seeding)
|
|
export const TEST_CATEGORIES = [
|
|
'Electronics',
|
|
'Hardware',
|
|
'Parts',
|
|
'Modules',
|
|
'Containers',
|
|
'Software',
|
|
'Accessories',
|
|
'Testing',
|
|
];
|
|
|
|
// AI extraction test cases
|
|
export const AI_TEST_CASES = {
|
|
simple: {
|
|
image_url: 'data:image/png;base64,...', // Placeholder
|
|
expected_name: 'Widget A',
|
|
expected_pn: 'WA-001',
|
|
expected_category: 'Electronics',
|
|
},
|
|
multiitem: {
|
|
image_url: 'data:image/png;base64,...',
|
|
expected_items: 3,
|
|
},
|
|
};
|
|
|
|
// API configuration
|
|
export const API_CONFIG = {
|
|
timeout: 10000,
|
|
retryAttempts: 2,
|
|
retryDelay: 1000,
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/fixtures/test-data.ts | head -30`
|
|
Expected: TypeScript test data shown
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/fixtures/test-data.ts
|
|
git commit -m "feat: add test data definitions and factories"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Create Database Fixture
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/fixtures/db.ts`
|
|
|
|
- [ ] **Step 1: Write db.ts**
|
|
|
|
Create `frontend/e2e/fixtures/db.ts`:
|
|
```typescript
|
|
import sqlite3 from 'sqlite3';
|
|
import path from 'path';
|
|
import { TEST_ITEMS, TEST_CATEGORIES } from './test-data';
|
|
|
|
export interface DatabaseFixture {
|
|
path: string;
|
|
initialize: () => Promise<void>;
|
|
seed: (items?: typeof TEST_ITEMS) => Promise<void>;
|
|
cleanup: () => Promise<void>;
|
|
}
|
|
|
|
export async function createDatabaseFixture(
|
|
workflowId: string
|
|
): Promise<DatabaseFixture> {
|
|
const dbPath = `/tmp/test-${workflowId}.db`;
|
|
|
|
return {
|
|
path: dbPath,
|
|
|
|
async initialize() {
|
|
return new Promise((resolve, reject) => {
|
|
const db = new sqlite3.Database(dbPath, (err) => {
|
|
if (err) return reject(err);
|
|
|
|
// Create tables
|
|
db.serialize(() => {
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS items (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
barcode TEXT UNIQUE,
|
|
part_number TEXT,
|
|
category TEXT,
|
|
quantity INTEGER DEFAULT 0,
|
|
box_label TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS categories (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT UNIQUE NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY,
|
|
username TEXT UNIQUE NOT NULL,
|
|
email TEXT,
|
|
password_hash TEXT,
|
|
is_admin BOOLEAN DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY,
|
|
action TEXT NOT NULL,
|
|
user_id INTEGER,
|
|
item_id INTEGER,
|
|
details TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`, (err) => {
|
|
if (err) return reject(err);
|
|
db.close(resolve);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
},
|
|
|
|
async seed(items = TEST_ITEMS) {
|
|
return new Promise((resolve, reject) => {
|
|
const db = new sqlite3.Database(dbPath, (err) => {
|
|
if (err) return reject(err);
|
|
|
|
db.serialize(() => {
|
|
// Seed categories
|
|
TEST_CATEGORIES.forEach(category => {
|
|
db.run(
|
|
'INSERT OR IGNORE INTO categories (name) VALUES (?)',
|
|
[category]
|
|
);
|
|
});
|
|
|
|
// Seed items
|
|
items.forEach(item => {
|
|
db.run(
|
|
`INSERT INTO items (name, barcode, part_number, category, quantity, box_label)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
item.name,
|
|
item.barcode,
|
|
item.part_number,
|
|
item.category,
|
|
item.quantity,
|
|
item.box_label || null,
|
|
]
|
|
);
|
|
});
|
|
|
|
db.run('', (err) => {
|
|
if (err) return reject(err);
|
|
db.close(resolve);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
},
|
|
|
|
async cleanup() {
|
|
return new Promise((resolve) => {
|
|
const fs = require('fs');
|
|
if (fs.existsSync(dbPath)) {
|
|
fs.unlinkSync(dbPath);
|
|
}
|
|
resolve();
|
|
});
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Install sqlite3 dependency**
|
|
|
|
Run: `cd frontend && npm install --save-dev sqlite3`
|
|
Expected: sqlite3 installed
|
|
|
|
- [ ] **Step 3: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/fixtures/db.ts | head -50`
|
|
Expected: TypeScript database fixture shown
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/fixtures/db.ts
|
|
git commit -m "feat: add database fixture for test setup and cleanup"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Create LDAP Fixture
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/fixtures/ldap.ts`
|
|
|
|
- [ ] **Step 1: Write ldap.ts**
|
|
|
|
Create `frontend/e2e/fixtures/ldap.ts`:
|
|
```typescript
|
|
import { execSync } from 'child_process';
|
|
import { LDAP_USERS } from './test-data';
|
|
|
|
export interface LDAPFixture {
|
|
baseDn: string;
|
|
adminPassword: string;
|
|
port: number;
|
|
isRunning: boolean;
|
|
start: () => Promise<void>;
|
|
addUser: (username: string, password: string, email: string) => Promise<void>;
|
|
stop: () => Promise<void>;
|
|
}
|
|
|
|
export async function createLDAPFixture(
|
|
workflowId: string,
|
|
port: number
|
|
): Promise<LDAPFixture> {
|
|
const baseDn = 'dc=ainventory,dc=local';
|
|
const adminPassword = 'admin';
|
|
|
|
return {
|
|
baseDn,
|
|
adminPassword,
|
|
port,
|
|
isRunning: false,
|
|
|
|
async start() {
|
|
// Docker container is started by docker-compose
|
|
// This fixture just provides helpers for adding users
|
|
// Wait for LDAP to be ready
|
|
let ready = false;
|
|
let attempts = 0;
|
|
while (!ready && attempts < 10) {
|
|
try {
|
|
execSync(
|
|
`ldapwhoami -H ldap://localhost:${port} -D "cn=admin,${baseDn}" -w "${adminPassword}"`,
|
|
{ stdio: 'pipe' }
|
|
);
|
|
ready = true;
|
|
} catch (e) {
|
|
attempts++;
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
}
|
|
}
|
|
if (!ready) throw new Error('LDAP failed to start');
|
|
this.isRunning = true;
|
|
},
|
|
|
|
async addUser(username: string, password: string, email: string) {
|
|
if (!this.isRunning) throw new Error('LDAP not running');
|
|
|
|
const ldifContent = `
|
|
dn: uid=${username},ou=people,${baseDn}
|
|
objectClass: inetOrgPerson
|
|
objectClass: posixAccount
|
|
uid: ${username}
|
|
cn: ${username}
|
|
sn: User
|
|
userPassword: ${password}
|
|
mail: ${email}
|
|
uidNumber: 1000
|
|
gidNumber: 1000
|
|
homeDirectory: /home/${username}
|
|
`;
|
|
|
|
// Use ldapadd to create the user
|
|
try {
|
|
execSync(
|
|
`ldapadd -H ldap://localhost:${this.port} -D "cn=admin,${baseDn}" -w "${this.adminPassword}" -x`,
|
|
{ input: ldifContent, stdio: 'pipe' }
|
|
);
|
|
} catch (e) {
|
|
// User might already exist, continue
|
|
}
|
|
},
|
|
|
|
async stop() {
|
|
// Docker container is stopped by docker-compose
|
|
this.isRunning = false;
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/fixtures/ldap.ts | head -50`
|
|
Expected: TypeScript LDAP fixture shown
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/fixtures/ldap.ts
|
|
git commit -m "feat: add LDAP fixture for authentication testing"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Create Auth Helper Fixture
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/fixtures/auth.ts`
|
|
|
|
- [ ] **Step 1: Write auth.ts**
|
|
|
|
Create `frontend/e2e/fixtures/auth.ts`:
|
|
```typescript
|
|
import { Page, BrowserContext } from '@playwright/test';
|
|
import { LDAP_USERS, LOCAL_USERS } from './test-data';
|
|
|
|
export async function ldapLogin(
|
|
page: Page,
|
|
username: string = LDAP_USERS.valid.username,
|
|
password: string = LDAP_USERS.valid.password
|
|
) {
|
|
await page.goto('/');
|
|
|
|
// Wait for login form
|
|
await page.waitForSelector('input[name="username"]', { timeout: 5000 });
|
|
|
|
// Fill credentials
|
|
await page.fill('input[name="username"]', username);
|
|
await page.fill('input[name="password"]', password);
|
|
|
|
// Click login button
|
|
await page.click('button:has-text("Login")');
|
|
|
|
// Wait for redirect to dashboard
|
|
await page.waitForURL(/\/dashboard|\/inventory/, { timeout: 10000 });
|
|
}
|
|
|
|
export async function localLogin(
|
|
page: Page,
|
|
username: string = LOCAL_USERS.admin.username,
|
|
password: string = LOCAL_USERS.admin.password
|
|
) {
|
|
await page.goto('/');
|
|
|
|
// Switch to local login tab if needed
|
|
const localTab = page.locator('button:has-text("Local")');
|
|
if (await localTab.isVisible()) {
|
|
await localTab.click();
|
|
}
|
|
|
|
// Wait for login form
|
|
await page.waitForSelector('input[name="username"]', { timeout: 5000 });
|
|
|
|
// Fill credentials
|
|
await page.fill('input[name="username"]', username);
|
|
await page.fill('input[name="password"]', password);
|
|
|
|
// Click login button
|
|
await page.click('button:has-text("Login")');
|
|
|
|
// Wait for redirect to dashboard
|
|
await page.waitForURL(/\/dashboard|\/inventory/, { timeout: 10000 });
|
|
}
|
|
|
|
export async function logout(page: Page) {
|
|
// Click logout button (usually in top right)
|
|
await page.click('button[aria-label="Logout"], button:has-text("Logout")');
|
|
|
|
// Wait for redirect to login
|
|
await page.waitForURL(/\/login|^\//, { timeout: 5000 });
|
|
}
|
|
|
|
export async function getAuthToken(context: BrowserContext): Promise<string | null> {
|
|
// Retrieve token from localStorage via context
|
|
const storage = await context.storageState();
|
|
const localStorageData = storage.origins[0]?.localStorage || [];
|
|
const tokenItem = localStorageData.find(item => item.name === 'auth_token');
|
|
return tokenItem?.value || null;
|
|
}
|
|
|
|
export async function saveAuthToken(
|
|
context: BrowserContext,
|
|
token: string
|
|
) {
|
|
await context.addCookies([
|
|
{
|
|
name: 'auth_token',
|
|
value: token,
|
|
url: 'http://localhost',
|
|
path: '/',
|
|
},
|
|
]);
|
|
}
|
|
|
|
export async function clearAuth(context: BrowserContext) {
|
|
await context.clearCookies();
|
|
await context.addInitScript(() => {
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/fixtures/auth.ts | head -40`
|
|
Expected: TypeScript auth fixture shown
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/fixtures/auth.ts
|
|
git commit -m "feat: add authentication helpers for login/logout"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Create Custom Assertions Utility
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/utils/assertions.ts`
|
|
|
|
- [ ] **Step 1: Write assertions.ts**
|
|
|
|
Create `frontend/e2e/utils/assertions.ts`:
|
|
```typescript
|
|
import { Page, expect } from '@playwright/test';
|
|
|
|
export async function expectInventoryItemVisible(
|
|
page: Page,
|
|
itemName: string,
|
|
quantity?: number
|
|
) {
|
|
const itemRow = page.locator(`text=${itemName}`);
|
|
await expect(itemRow).toBeVisible({ timeout: 5000 });
|
|
|
|
if (quantity !== undefined) {
|
|
const qtyCell = itemRow.locator('..').locator(`text=${quantity}`);
|
|
await expect(qtyCell).toBeVisible();
|
|
}
|
|
}
|
|
|
|
export async function expectAuditLogEntry(
|
|
page: Page,
|
|
action: string,
|
|
itemName: string
|
|
) {
|
|
const auditEntry = page.locator(
|
|
`text=${action}`, { has: page.locator(`text=${itemName}`) }
|
|
);
|
|
await expect(auditEntry).toBeVisible({ timeout: 5000 });
|
|
}
|
|
|
|
export async function expectErrorMessage(
|
|
page: Page,
|
|
message: string
|
|
) {
|
|
const errorToast = page.locator('[role="alert"]', { hasText: message });
|
|
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
|
}
|
|
|
|
export async function expectSuccessMessage(
|
|
page: Page,
|
|
message: string
|
|
) {
|
|
const successToast = page.locator('[role="status"]', { hasText: message });
|
|
await expect(successToast).toBeVisible({ timeout: 5000 });
|
|
}
|
|
|
|
export async function expectFormValidationError(
|
|
page: Page,
|
|
fieldName: string,
|
|
errorMessage: string
|
|
) {
|
|
const field = page.locator(`[name="${fieldName}"]`);
|
|
await expect(field).toHaveAttribute('aria-invalid', 'true');
|
|
|
|
const error = field.locator('+ [role="alert"]');
|
|
await expect(error).toContainText(errorMessage);
|
|
}
|
|
|
|
export async function expectOfflineQueueSize(
|
|
page: Page,
|
|
expectedSize: number
|
|
) {
|
|
// Access IndexedDB via context
|
|
const queueSize = await page.evaluate(() => {
|
|
return new Promise((resolve) => {
|
|
const db = indexedDB.open('ainventory');
|
|
db.onsuccess = (event) => {
|
|
const store = event.target.result
|
|
.transaction('syncQueue', 'readonly')
|
|
.objectStore('syncQueue');
|
|
resolve(store.getAll().result?.length || 0);
|
|
};
|
|
});
|
|
});
|
|
|
|
expect(queueSize).toBe(expectedSize);
|
|
}
|
|
|
|
export async function expectNoSyncDuplicates(
|
|
page: Page,
|
|
itemBarcode: string
|
|
) {
|
|
// Query audit log to verify single entry for UUID
|
|
const duplicates = await page.evaluate((barcode) => {
|
|
// This would typically be an API call in real tests
|
|
return { count: 1 }; // Placeholder
|
|
}, itemBarcode);
|
|
|
|
expect(duplicates.count).toBeLessThanOrEqual(1);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/utils/assertions.ts | head -40`
|
|
Expected: TypeScript assertions shown
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/utils/assertions.ts
|
|
git commit -m "feat: add custom Playwright assertions for inventory domain"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Create Docker Orchestration Utility
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/utils/docker.ts`
|
|
|
|
- [ ] **Step 1: Write docker.ts**
|
|
|
|
Create `frontend/e2e/utils/docker.ts`:
|
|
```typescript
|
|
import { execSync, spawn } from 'child_process';
|
|
import path from 'path';
|
|
import { createDatabaseFixture } from '../fixtures/db';
|
|
import { createLDAPFixture } from '../fixtures/ldap';
|
|
|
|
export interface DockerEnvironment {
|
|
workflowId: string;
|
|
backendPort: number;
|
|
frontendPort: number;
|
|
ldapPort: number;
|
|
start: () => Promise<void>;
|
|
stop: () => Promise<void>;
|
|
isHealthy: () => Promise<boolean>;
|
|
}
|
|
|
|
export async function createDockerEnvironment(
|
|
workflowId: string,
|
|
config: {
|
|
backendPort: number;
|
|
frontendPort: number;
|
|
ldapPort: number;
|
|
ldapEnabled: boolean;
|
|
aiProvider?: string;
|
|
seedData?: boolean;
|
|
}
|
|
): Promise<DockerEnvironment> {
|
|
const env: DockerEnvironment = {
|
|
workflowId,
|
|
backendPort: config.backendPort,
|
|
frontendPort: config.frontendPort,
|
|
ldapPort: config.ldapPort,
|
|
|
|
async start() {
|
|
// Build environment variables for docker-compose
|
|
const envVars = {
|
|
WORKFLOW_ID: workflowId,
|
|
BACKEND_PORT: String(config.backendPort),
|
|
FRONTEND_PORT: String(config.frontendPort),
|
|
LDAP_PORT: String(config.ldapPort),
|
|
LDAP_ENABLED: String(config.ldapEnabled),
|
|
LDAP_SERVER: `ldap://localhost:${config.ldapPort}`,
|
|
LDAP_BASE_DN: 'dc=ainventory,dc=local',
|
|
AI_PROVIDER: config.aiProvider || 'gemini',
|
|
};
|
|
|
|
// Start docker-compose services
|
|
const composePath = path.join(__dirname, '../docker-compose.e2e.yml');
|
|
const envString = Object.entries(envVars)
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join(' ');
|
|
|
|
try {
|
|
execSync(
|
|
`cd ${path.dirname(composePath)} && ${envString} docker-compose -f docker-compose.e2e.yml up -d`,
|
|
{ stdio: 'pipe' }
|
|
);
|
|
} catch (e) {
|
|
throw new Error(`Failed to start Docker services: ${e.message}`);
|
|
}
|
|
|
|
// Wait for services to be healthy
|
|
let healthy = false;
|
|
let attempts = 0;
|
|
while (!healthy && attempts < 30) {
|
|
healthy = await this.isHealthy();
|
|
if (!healthy) {
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
attempts++;
|
|
}
|
|
}
|
|
|
|
if (!healthy) {
|
|
throw new Error('Docker services failed health checks after 30s');
|
|
}
|
|
|
|
// Seed database if requested
|
|
if (config.seedData) {
|
|
const db = await createDatabaseFixture(workflowId);
|
|
await db.initialize();
|
|
await db.seed();
|
|
}
|
|
|
|
// Setup LDAP users if enabled
|
|
if (config.ldapEnabled) {
|
|
const ldap = await createLDAPFixture(workflowId, config.ldapPort);
|
|
await ldap.start();
|
|
await ldap.addUser('testuser1', 'Password123!', 'testuser1@ainventory.local');
|
|
}
|
|
},
|
|
|
|
async stop() {
|
|
const composePath = path.join(__dirname, '../docker-compose.e2e.yml');
|
|
try {
|
|
execSync(
|
|
`cd ${path.dirname(composePath)} && docker-compose -f docker-compose.e2e.yml down -v`,
|
|
{ stdio: 'pipe' }
|
|
);
|
|
} catch (e) {
|
|
console.error('Failed to stop Docker services:', e.message);
|
|
}
|
|
|
|
// Cleanup database file
|
|
const db = await createDatabaseFixture(workflowId);
|
|
await db.cleanup();
|
|
},
|
|
|
|
async isHealthy() {
|
|
try {
|
|
// Check backend health
|
|
execSync(
|
|
`curl -sf http://localhost:${config.backendPort}/health > /dev/null`,
|
|
{ stdio: 'pipe' }
|
|
);
|
|
|
|
// Check frontend health
|
|
execSync(
|
|
`curl -sf http://localhost:${config.frontendPort} > /dev/null`,
|
|
{ stdio: 'pipe' }
|
|
);
|
|
|
|
return true;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
},
|
|
};
|
|
|
|
return env;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/utils/docker.ts | head -50`
|
|
Expected: TypeScript docker utility shown
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/utils/docker.ts
|
|
git commit -m "feat: add docker orchestration utility for container lifecycle"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Create Navigation & Helper Utilities
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/utils/helpers.ts`
|
|
|
|
- [ ] **Step 1: Write helpers.ts**
|
|
|
|
Create `frontend/e2e/utils/helpers.ts`:
|
|
```typescript
|
|
import { Page, expect } from '@playwright/test';
|
|
|
|
export async function navigateTo(page: Page, path: string) {
|
|
await page.goto(path);
|
|
// Wait for any loading spinners to disappear
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
|
|
export async function waitForScannerReady(page: Page) {
|
|
// Wait for camera/scanner UI to load
|
|
await expect(page.locator('[data-testid="scanner-viewport"]')).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
}
|
|
|
|
export async function waitForUIReady(page: Page, selector: string) {
|
|
const element = page.locator(selector);
|
|
await expect(element).toBeVisible({ timeout: 5000 });
|
|
// Extra wait for any animations
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
export async function fillFormField(
|
|
page: Page,
|
|
fieldName: string,
|
|
value: string
|
|
) {
|
|
const field = page.locator(`input[name="${fieldName}"], textarea[name="${fieldName}"]`);
|
|
await field.fill(value);
|
|
}
|
|
|
|
export async function selectDropdownOption(
|
|
page: Page,
|
|
dropdownLabel: string,
|
|
optionText: string
|
|
) {
|
|
// Click dropdown trigger
|
|
const trigger = page.locator(`button:has-text("${dropdownLabel}")`);
|
|
await trigger.click();
|
|
|
|
// Click option
|
|
const option = page.locator(`[role="option"]:has-text("${optionText}")`);
|
|
await option.click();
|
|
}
|
|
|
|
export async function checkboxClick(page: Page, label: string) {
|
|
const checkbox = page.locator(`input[type="checkbox"]`, { has: page.locator(`label:has-text("${label}")`) });
|
|
await checkbox.check();
|
|
}
|
|
|
|
export async function waitForNetworkIdle(page: Page, timeout: number = 5000) {
|
|
await page.waitForLoadState('networkidle', { timeout });
|
|
}
|
|
|
|
export async function simulateOfflineMode(page: Page) {
|
|
// Disable network
|
|
await page.context().setOffline(true);
|
|
}
|
|
|
|
export async function simulateOnlineMode(page: Page) {
|
|
// Re-enable network
|
|
await page.context().setOffline(false);
|
|
// Wait for reconnection
|
|
await waitForNetworkIdle(page);
|
|
}
|
|
|
|
export async function waitForToast(
|
|
page: Page,
|
|
message: string,
|
|
type: 'success' | 'error' | 'info' = 'info'
|
|
) {
|
|
const toast = page.locator(`[role="${type === 'error' ? 'alert' : 'status'}"]`, {
|
|
hasText: message,
|
|
});
|
|
await expect(toast).toBeVisible({ timeout: 5000 });
|
|
// Wait for toast to disappear
|
|
await expect(toast).not.toBeVisible({ timeout: 5000 });
|
|
}
|
|
|
|
export async function getTableRowByText(page: Page, text: string) {
|
|
return page.locator(`tr:has-text("${text}")`);
|
|
}
|
|
|
|
export async function openConfirmationDialog(page: Page, buttonText: string) {
|
|
await page.click(`button:has-text("${buttonText}")`);
|
|
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
|
|
}
|
|
|
|
export async function confirmDialog(page: Page) {
|
|
const confirmBtn = page.locator('button:has-text("Confirm"), button:has-text("Yes"), button:has-text("Delete")');
|
|
await confirmBtn.click();
|
|
}
|
|
|
|
export async function cancelDialog(page: Page) {
|
|
const cancelBtn = page.locator('button:has-text("Cancel"), button:has-text("No")');
|
|
await cancelBtn.click();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/utils/helpers.ts | head -50`
|
|
Expected: TypeScript helpers shown
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/utils/helpers.ts
|
|
git commit -m "feat: add UI navigation and interaction helpers"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase C: Workflow Test Implementation
|
|
|
|
### Task 11: Create Login Workflow Test (1-login.spec.ts)
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/workflows/1-login.spec.ts`
|
|
|
|
- [ ] **Step 1: Write login workflow test**
|
|
|
|
Create `frontend/e2e/workflows/1-login.spec.ts`:
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
import { createDockerEnvironment } from '../utils/docker';
|
|
import { ldapLogin, localLogin, logout } from '../fixtures/auth';
|
|
import { expectErrorMessage, expectSuccessMessage } from '../utils/assertions';
|
|
import { LDAP_USERS, LOCAL_USERS } from '../fixtures/test-data';
|
|
|
|
const WORKFLOW_ID = '1-login';
|
|
const config = {
|
|
backendPort: 8906,
|
|
frontendPort: 8907,
|
|
ldapPort: 3389,
|
|
ldapEnabled: true,
|
|
aiProvider: 'gemini',
|
|
seedData: false,
|
|
};
|
|
|
|
let env: any;
|
|
|
|
test.describe.configure({ timeout: 120000 });
|
|
|
|
test.beforeAll(async () => {
|
|
env = await createDockerEnvironment(WORKFLOW_ID, config);
|
|
await env.start();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await env.stop();
|
|
});
|
|
|
|
test.describe('Login Workflow - LDAP & Local Auth', () => {
|
|
test('LDAP user login with valid credentials', async ({ page }) => {
|
|
await page.goto(`http://localhost:${config.frontendPort}/`);
|
|
await ldapLogin(page, LDAP_USERS.valid.username, LDAP_USERS.valid.password);
|
|
|
|
// Verify on dashboard
|
|
await expect(page.locator('text=Dashboard')).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('LDAP user login with invalid credentials', async ({ page }) => {
|
|
await page.goto(`http://localhost:${config.frontendPort}/`);
|
|
await page.fill('input[name="username"]', LDAP_USERS.invalid.username);
|
|
await page.fill('input[name="password"]', LDAP_USERS.invalid.password);
|
|
await page.click('button:has-text("Login")');
|
|
|
|
await expectErrorMessage(page, 'Invalid credentials');
|
|
});
|
|
|
|
test('Local user login with valid credentials', async ({ page }) => {
|
|
await page.goto(`http://localhost:${config.frontendPort}/`);
|
|
|
|
// Switch to local login if available
|
|
const localTab = page.locator('button:has-text("Local")');
|
|
if (await localTab.isVisible()) {
|
|
await localTab.click();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
await localLogin(page, LOCAL_USERS.admin.username, LOCAL_USERS.admin.password);
|
|
|
|
// Verify on dashboard
|
|
await expect(page.locator('text=Dashboard')).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('Local user login with invalid password', async ({ page }) => {
|
|
await page.goto(`http://localhost:${config.frontendPort}/`);
|
|
|
|
const localTab = page.locator('button:has-text("Local")');
|
|
if (await localTab.isVisible()) {
|
|
await localTab.click();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
await page.fill('input[name="username"]', LOCAL_USERS.admin.username);
|
|
await page.fill('input[name="password"]', 'WrongPassword123!');
|
|
await page.click('button:has-text("Login")');
|
|
|
|
await expectErrorMessage(page, 'Invalid credentials');
|
|
});
|
|
|
|
test('Login with missing username field', async ({ page }) => {
|
|
await page.goto(`http://localhost:${config.frontendPort}/`);
|
|
|
|
// Try to submit with empty username
|
|
const usernameField = page.locator('input[name="username"]');
|
|
await usernameField.focus();
|
|
await usernameField.blur();
|
|
|
|
// Check for validation error
|
|
await expect(
|
|
page.locator('text=Username is required')
|
|
).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('Logout clears session', async ({ page }) => {
|
|
// Login first
|
|
await page.goto(`http://localhost:${config.frontendPort}/`);
|
|
await ldapLogin(page, LDAP_USERS.valid.username, LDAP_USERS.valid.password);
|
|
await expect(page.locator('text=Dashboard')).toBeVisible({ timeout: 10000 });
|
|
|
|
// Logout
|
|
await logout(page);
|
|
|
|
// Verify back on login screen
|
|
await expect(page.locator('input[name="username"]')).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('Session expiry redirects to login', async ({ page }) => {
|
|
// Login
|
|
await page.goto(`http://localhost:${config.frontendPort}/`);
|
|
await ldapLogin(page, LDAP_USERS.valid.username, LDAP_USERS.valid.password);
|
|
|
|
// Simulate token expiry
|
|
await page.evaluate(() => {
|
|
localStorage.removeItem('auth_token');
|
|
});
|
|
|
|
// Try to navigate to protected page
|
|
await page.goto(`http://localhost:${config.frontendPort}/inventory`);
|
|
|
|
// Should redirect to login
|
|
await expect(page.locator('input[name="username"]')).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('Concurrent login attempts handled properly', async ({ browser }) => {
|
|
// Create two concurrent pages
|
|
const page1 = await browser.newPage();
|
|
const page2 = await browser.newPage();
|
|
|
|
try {
|
|
await page1.goto(`http://localhost:${config.frontendPort}/`);
|
|
await page2.goto(`http://localhost:${config.frontendPort}/`);
|
|
|
|
// Both attempt login simultaneously
|
|
const login1 = ldapLogin(page1, LDAP_USERS.valid.username, LDAP_USERS.valid.password);
|
|
const login2 = localLogin(page2, LOCAL_USERS.admin.username, LOCAL_USERS.admin.password);
|
|
|
|
// Both should succeed
|
|
await Promise.all([login1, login2]);
|
|
|
|
await expect(page1.locator('text=Dashboard')).toBeVisible({ timeout: 10000 });
|
|
await expect(page2.locator('text=Dashboard')).toBeVisible({ timeout: 10000 });
|
|
} finally {
|
|
await page1.close();
|
|
await page2.close();
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/workflows/1-login.spec.ts | head -50`
|
|
Expected: TypeScript test file shown
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/workflows/1-login.spec.ts
|
|
git commit -m "feat: add login workflow E2E tests (LDAP + local auth)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Create Scan-Adjust Workflow Test (2-scan-adjust.spec.ts)
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/workflows/2-scan-adjust.spec.ts`
|
|
|
|
- [ ] **Step 1: Write scan-adjust workflow test**
|
|
|
|
Create `frontend/e2e/workflows/2-scan-adjust.spec.ts`:
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
import { createDockerEnvironment } from '../utils/docker';
|
|
import { ldapLogin } from '../fixtures/auth';
|
|
import { expectInventoryItemVisible, expectAuditLogEntry } from '../utils/assertions';
|
|
import { navigateTo, waitForScannerReady, waitForToast } from '../utils/helpers';
|
|
import { LDAP_USERS, TEST_ITEMS } from '../fixtures/test-data';
|
|
|
|
const WORKFLOW_ID = '2-scan-adjust';
|
|
const config = {
|
|
backendPort: 8916,
|
|
frontendPort: 8917,
|
|
ldapPort: 3389,
|
|
ldapEnabled: false,
|
|
aiProvider: 'gemini',
|
|
seedData: true,
|
|
};
|
|
|
|
let env: any;
|
|
|
|
test.describe.configure({ timeout: 120000 });
|
|
|
|
test.beforeAll(async () => {
|
|
env = await createDockerEnvironment(WORKFLOW_ID, config);
|
|
await env.start();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await env.stop();
|
|
});
|
|
|
|
test.describe('Scan-Adjust Workflow', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Login for each test
|
|
await page.goto(`http://localhost:${config.frontendPort}/`);
|
|
// Wait for LDAP to not be available, click "Local" or proceed
|
|
const localTab = page.locator('button:has-text("Local")');
|
|
if (await localTab.isVisible()) {
|
|
await localTab.click();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
// Use first test item as credentials for local login
|
|
await page.fill('input[name="username"]', 'testuser');
|
|
await page.fill('input[name="password"]', 'password');
|
|
await page.click('button:has-text("Login")');
|
|
|
|
// Navigate to scanner
|
|
await navigateTo(page, `http://localhost:${config.frontendPort}/scanner`);
|
|
await waitForScannerReady(page);
|
|
});
|
|
|
|
test('Scan valid barcode and match existing item', async ({ page }) => {
|
|
// Simulate barcode input (barcode reader typically types fast)
|
|
const scanInput = page.locator('input[data-testid="barcode-input"]');
|
|
await scanInput.focus();
|
|
await page.keyboard.type(TEST_ITEMS[0].barcode, { delay: 10 });
|
|
await page.keyboard.press('Enter');
|
|
|
|
// Verify item found and adjustment UI opens
|
|
await expect(page.locator(`text=${TEST_ITEMS[0].name}`)).toBeVisible({ timeout: 5000 });
|
|
await expect(page.locator('[data-testid="qty-adjust-dialog"]')).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('Adjust quantity and save', async ({ page }) => {
|
|
const scanInput = page.locator('input[data-testid="barcode-input"]');
|
|
await scanInput.focus();
|
|
await page.keyboard.type(TEST_ITEMS[0].barcode, { delay: 10 });
|
|
await page.keyboard.press('Enter');
|
|
|
|
// Verify adjustment dialog
|
|
await expect(page.locator('[data-testid="qty-adjust-dialog"]')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Increase quantity by 5
|
|
const qtyInput = page.locator('input[name="quantity"]');
|
|
const currentValue = parseInt(await qtyInput.inputValue());
|
|
const newValue = currentValue + 5;
|
|
await qtyInput.fill(String(newValue));
|
|
|
|
// Click save
|
|
await page.click('button:has-text("Save")');
|
|
|
|
// Verify toast
|
|
await waitForToast(page, 'Stock updated successfully', 'success');
|
|
|
|
// Verify in inventory list
|
|
await navigateTo(page, `http://localhost:${config.frontendPort}/inventory`);
|
|
await expectInventoryItemVisible(page, TEST_ITEMS[0].name, newValue);
|
|
});
|
|
|
|
test('Scan unknown barcode creates new item flow', async ({ page }) => {
|
|
const scanInput = page.locator('input[data-testid="barcode-input"]');
|
|
await scanInput.focus();
|
|
await page.keyboard.type('UNKNOWN-999', { delay: 10 });
|
|
await page.keyboard.press('Enter');
|
|
|
|
// Verify new item creation dialog
|
|
await expect(page.locator('text=Item not found')).toBeVisible({ timeout: 5000 });
|
|
await expect(page.locator('[data-testid="new-item-dialog"]')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Fill new item form
|
|
await page.fill('input[name="name"]', 'New Widget');
|
|
await page.fill('input[name="part_number"]', 'NW-001');
|
|
await page.fill('input[name="quantity"]', '10');
|
|
|
|
// Save
|
|
await page.click('button:has-text("Create Item")');
|
|
|
|
// Verify success
|
|
await waitForToast(page, 'Item created', 'success');
|
|
});
|
|
|
|
test('Multiple consecutive scans', async ({ page }) => {
|
|
const scanInput = page.locator('input[data-testid="barcode-input"]');
|
|
|
|
// Scan 3 different items
|
|
for (let i = 0; i < 3; i++) {
|
|
await scanInput.focus();
|
|
await page.keyboard.type(TEST_ITEMS[i].barcode, { delay: 10 });
|
|
await page.keyboard.press('Enter');
|
|
|
|
// Verify item appears
|
|
await expect(page.locator(`text=${TEST_ITEMS[i].name}`)).toBeVisible({ timeout: 5000 });
|
|
|
|
// Adjust quantity
|
|
const qtyInput = page.locator('input[name="quantity"]');
|
|
await qtyInput.fill('1');
|
|
|
|
// Save
|
|
await page.click('button:has-text("Save")');
|
|
await waitForToast(page, 'Stock updated', 'success');
|
|
}
|
|
|
|
// Verify all 3 items were scanned
|
|
await navigateTo(page, `http://localhost:${config.frontendPort}/inventory`);
|
|
for (let i = 0; i < 3; i++) {
|
|
await expectInventoryItemVisible(page, TEST_ITEMS[i].name);
|
|
}
|
|
});
|
|
|
|
test('Scan with offline stores operation in queue', async ({ page }) => {
|
|
// Simulate offline
|
|
await page.context().setOffline(true);
|
|
|
|
// Try to scan
|
|
const scanInput = page.locator('input[data-testid="barcode-input"]');
|
|
await scanInput.focus();
|
|
await page.keyboard.type(TEST_ITEMS[0].barcode, { delay: 10 });
|
|
await page.keyboard.press('Enter');
|
|
|
|
// Verify offline message
|
|
await expect(page.locator('text=Offline')).toBeVisible({ timeout: 5000 });
|
|
await expect(page.locator('text=will sync when online')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Go back online
|
|
await page.context().setOffline(false);
|
|
|
|
// Verify sync happens
|
|
await page.waitForLoadState('networkidle');
|
|
await waitForToast(page, 'Synced', 'success');
|
|
});
|
|
|
|
test('Barcode not found triggers OCR fallback', async ({ page }) => {
|
|
const scanInput = page.locator('input[data-testid="barcode-input"]');
|
|
await scanInput.focus();
|
|
await page.keyboard.type('INVALID-BARCODE-12345', { delay: 10 });
|
|
await page.keyboard.press('Enter');
|
|
|
|
// Should show "not found" and offer manual search
|
|
await expect(page.locator('text=Item not found')).toBeVisible({ timeout: 5000 });
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/workflows/2-scan-adjust.spec.ts | head -50`
|
|
Expected: TypeScript test file shown
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/workflows/2-scan-adjust.spec.ts
|
|
git commit -m "feat: add scan-adjust workflow E2E tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Create AI Extraction Workflow Test (3-ai-extraction.spec.ts)
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/workflows/3-ai-extraction.spec.ts`
|
|
|
|
- [ ] **Step 1: Write AI extraction workflow test**
|
|
|
|
Create `frontend/e2e/workflows/3-ai-extraction.spec.ts`:
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
import { createDockerEnvironment } from '../utils/docker';
|
|
import { localLogin } from '../fixtures/auth';
|
|
import { expectErrorMessage } from '../utils/assertions';
|
|
import { navigateTo, waitForToast } from '../utils/helpers';
|
|
import { LOCAL_USERS } from '../fixtures/test-data';
|
|
|
|
const WORKFLOW_ID = '3-ai-extraction';
|
|
const config = {
|
|
backendPort: 8926,
|
|
frontendPort: 8927,
|
|
ldapPort: 3389,
|
|
ldapEnabled: false,
|
|
aiProvider: 'gemini',
|
|
seedData: false,
|
|
};
|
|
|
|
let env: any;
|
|
|
|
test.describe.configure({ timeout: 120000 });
|
|
|
|
test.beforeAll(async () => {
|
|
env = await createDockerEnvironment(WORKFLOW_ID, config);
|
|
await env.start();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await env.stop();
|
|
});
|
|
|
|
test.describe('AI Extraction Workflow', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Login
|
|
await page.goto(`http://localhost:${config.frontendPort}/`);
|
|
|
|
const localTab = page.locator('button:has-text("Local")');
|
|
if (await localTab.isVisible()) {
|
|
await localTab.click();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
await localLogin(page, LOCAL_USERS.admin.username, LOCAL_USERS.admin.password);
|
|
|
|
// Navigate to AI onboarding
|
|
await navigateTo(page, `http://localhost:${config.frontendPort}/ai-onboarding`);
|
|
});
|
|
|
|
test('Capture photo and send to AI', async ({ page }) => {
|
|
// Wait for camera/upload UI
|
|
await expect(page.locator('[data-testid="image-capture"]')).toBeVisible({ timeout: 10000 });
|
|
|
|
// Upload test image
|
|
const fileInput = page.locator('input[type="file"]');
|
|
await fileInput.setInputFiles({
|
|
name: 'test-item.png',
|
|
mimeType: 'image/png',
|
|
buffer: Buffer.from('mock-png-data'),
|
|
});
|
|
|
|
// Wait for AI processing
|
|
await page.waitForLoadState('networkidle', { timeout: 10000 });
|
|
|
|
// Verify extraction results appear
|
|
await expect(page.locator('[data-testid="extraction-results"]')).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('AI response shows validation UI', async ({ page }) => {
|
|
// Upload test image
|
|
const fileInput = page.locator('input[type="file"]');
|
|
await fileInput.setInputFiles({
|
|
name: 'test-item.png',
|
|
mimeType: 'image/png',
|
|
buffer: Buffer.from('mock-png-data'),
|
|
});
|
|
|
|
// Wait for results
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Verify validation UI with extracted data
|
|
await expect(page.locator('input[name="name"]')).toBeVisible({ timeout: 5000 });
|
|
await expect(page.locator('input[name="part_number"]')).toBeVisible({ timeout: 5000 });
|
|
await expect(page.locator('input[name="category"]')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Verify fields are pre-filled
|
|
const nameField = page.locator('input[name="name"]');
|
|
const nameValue = await nameField.inputValue();
|
|
expect(nameValue.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('Confirm extracted data creates item', async ({ page }) => {
|
|
// Upload image
|
|
const fileInput = page.locator('input[type="file"]');
|
|
await fileInput.setInputFiles({
|
|
name: 'test-item.png',
|
|
mimeType: 'image/png',
|
|
buffer: Buffer.from('mock-png-data'),
|
|
});
|
|
|
|
// Wait for results
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Click confirm
|
|
const confirmBtn = page.locator('button:has-text("Confirm")');
|
|
await confirmBtn.click();
|
|
|
|
// Verify success
|
|
await waitForToast(page, 'Item created', 'success');
|
|
|
|
// Verify redirect to inventory
|
|
await page.waitForURL(`**/inventory`, { timeout: 5000 });
|
|
});
|
|
|
|
test('Reject extraction allows manual entry', async ({ page }) => {
|
|
// Upload image
|
|
const fileInput = page.locator('input[type="file"]');
|
|
await fileInput.setInputFiles({
|
|
name: 'test-item.png',
|
|
mimeType: 'image/png',
|
|
buffer: Buffer.from('mock-png-data'),
|
|
});
|
|
|
|
// Wait for results
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Click reject/edit
|
|
const rejectBtn = page.locator('button:has-text("Edit"), button:has-text("Reject")');
|
|
await rejectBtn.click();
|
|
|
|
// Should show manual entry form
|
|
await expect(page.locator('[data-testid="manual-entry-form"]')).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('AI timeout shows retry option', async ({ page }) => {
|
|
// Mock a slow AI response by intercepting
|
|
await page.route('**/api/ai/extract', route => {
|
|
// Delay response beyond timeout
|
|
setTimeout(() => {
|
|
route.abort('timedout');
|
|
}, 15000);
|
|
});
|
|
|
|
// Upload image
|
|
const fileInput = page.locator('input[type="file"]');
|
|
await fileInput.setInputFiles({
|
|
name: 'test-item.png',
|
|
mimeType: 'image/png',
|
|
buffer: Buffer.from('mock-png-data'),
|
|
});
|
|
|
|
// Wait for timeout error
|
|
await expect(page.locator('text=Request timeout')).toBeVisible({ timeout: 20000 });
|
|
|
|
// Verify retry button
|
|
const retryBtn = page.locator('button:has-text("Retry")');
|
|
await expect(retryBtn).toBeVisible();
|
|
});
|
|
|
|
test('Invalid image shows error', async ({ page }) => {
|
|
// Upload invalid image (too small, corrupted)
|
|
const fileInput = page.locator('input[type="file"]');
|
|
await fileInput.setInputFiles({
|
|
name: 'tiny.png',
|
|
mimeType: 'image/png',
|
|
buffer: Buffer.from('x'.repeat(100)), // Too small
|
|
});
|
|
|
|
// Verify validation error
|
|
await expectErrorMessage(page, 'Image too small');
|
|
});
|
|
|
|
test('Network failure during extraction shows offline queue', async ({ page }) => {
|
|
// Go offline
|
|
await page.context().setOffline(true);
|
|
|
|
// Try to upload
|
|
const fileInput = page.locator('input[type="file"]');
|
|
await fileInput.setInputFiles({
|
|
name: 'test-item.png',
|
|
mimeType: 'image/png',
|
|
buffer: Buffer.from('mock-png-data'),
|
|
});
|
|
|
|
// Should show offline message
|
|
await expect(page.locator('text=Offline')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Go online
|
|
await page.context().setOffline(false);
|
|
|
|
// Should auto-retry
|
|
await page.waitForLoadState('networkidle');
|
|
await expect(page.locator('[data-testid="extraction-results"]')).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('Multiple items in photo extracted', async ({ page }) => {
|
|
// Upload image with multiple items
|
|
const fileInput = page.locator('input[type="file"]');
|
|
await fileInput.setInputFiles({
|
|
name: 'multi-item.png',
|
|
mimeType: 'image/png',
|
|
buffer: Buffer.from('mock-png-data-multiple-items'),
|
|
});
|
|
|
|
// Wait for results
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Verify multiple results shown
|
|
const items = page.locator('[data-testid="extraction-result-item"]');
|
|
const count = await items.count();
|
|
expect(count).toBeGreaterThan(1);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/workflows/3-ai-extraction.spec.ts | head -50`
|
|
Expected: TypeScript test file shown
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/workflows/3-ai-extraction.spec.ts
|
|
git commit -m "feat: add AI extraction workflow E2E tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Create Admin Settings Workflow Test (4-admin-settings.spec.ts)
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/workflows/4-admin-settings.spec.ts`
|
|
|
|
- [ ] **Step 1: Write admin settings workflow test**
|
|
|
|
Create `frontend/e2e/workflows/4-admin-settings.spec.ts`:
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
import { createDockerEnvironment } from '../utils/docker';
|
|
import { localLogin } from '../fixtures/auth';
|
|
import { expectErrorMessage, expectSuccessMessage } from '../utils/assertions';
|
|
import { navigateTo, selectDropdownOption, openConfirmationDialog, confirmDialog } from '../utils/helpers';
|
|
import { LOCAL_USERS, TEST_CATEGORIES } from '../fixtures/test-data';
|
|
|
|
const WORKFLOW_ID = '4-admin-settings';
|
|
const config = {
|
|
backendPort: 8936,
|
|
frontendPort: 8937,
|
|
ldapPort: 3389,
|
|
ldapEnabled: true,
|
|
aiProvider: 'gemini',
|
|
seedData: true,
|
|
};
|
|
|
|
let env: any;
|
|
|
|
test.describe.configure({ timeout: 120000 });
|
|
|
|
test.beforeAll(async () => {
|
|
env = await createDockerEnvironment(WORKFLOW_ID, config);
|
|
await env.start();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await env.stop();
|
|
});
|
|
|
|
test.describe('Admin Settings Workflow', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Login as admin
|
|
await page.goto(`http://localhost:${config.frontendPort}/`);
|
|
await localLogin(page, LOCAL_USERS.admin.username, LOCAL_USERS.admin.password);
|
|
|
|
// Navigate to admin
|
|
await navigateTo(page, `http://localhost:${config.frontendPort}/admin`);
|
|
|
|
// Wait for admin UI to load
|
|
await expect(page.locator('[role="tablist"]')).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('Admin dashboard loads with all tabs', async ({ page }) => {
|
|
// Verify all tabs present
|
|
const tabs = ['Identity', 'Database', 'LDAP', 'AI', 'Categories'];
|
|
for (const tab of tabs) {
|
|
const tabButton = page.locator(`button:has-text("${tab}")`);
|
|
await expect(tabButton).toBeVisible({ timeout: 5000 });
|
|
}
|
|
});
|
|
|
|
test('View users in Identity Manager', async ({ page }) => {
|
|
// Click Identity tab
|
|
await page.click('button:has-text("Identity")');
|
|
|
|
// Verify user list visible
|
|
await expect(page.locator('[data-testid="user-list"]')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Verify admin user listed
|
|
await expect(page.locator(`text=${LOCAL_USERS.admin.username}`)).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('Create new local user', async ({ page }) => {
|
|
// Click Identity tab
|
|
await page.click('button:has-text("Identity")');
|
|
|
|
// Click create user button
|
|
await page.click('button:has-text("Create User")');
|
|
|
|
// Fill form
|
|
await page.fill('input[name="username"]', 'newuser');
|
|
await page.fill('input[name="email"]', 'newuser@ainventory.local');
|
|
await page.fill('input[name="password"]', 'NewPassword123!');
|
|
|
|
// Submit
|
|
await page.click('button:has-text("Create")');
|
|
|
|
// Verify success
|
|
await expectSuccessMessage(page, 'User created');
|
|
|
|
// Verify user in list
|
|
await expect(page.locator('text=newuser')).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('Delete user with confirmation', async ({ page }) => {
|
|
// Click Identity tab
|
|
await page.click('button:has-text("Identity")');
|
|
|
|
// Find a non-admin user and delete
|
|
const deleteBtn = page.locator('[data-testid="delete-user-btn"]').first();
|
|
await deleteBtn.click();
|
|
|
|
// Confirm deletion
|
|
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
|
|
await page.click('button:has-text("Delete")');
|
|
|
|
// Verify success
|
|
await expectSuccessMessage(page, 'User deleted');
|
|
});
|
|
|
|
test('Switch AI provider from Gemini to Claude', async ({ page }) => {
|
|
// Click AI tab
|
|
await page.click('button:has-text("AI")');
|
|
|
|
// Wait for AI config to load
|
|
await expect(page.locator('[data-testid="ai-provider-select"]')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Select Claude
|
|
await page.click('[data-testid="ai-provider-select"]');
|
|
await page.click('text=Claude');
|
|
|
|
// Fill Claude API key
|
|
await page.fill('input[name="claude_api_key"]', 'test-claude-key-123');
|
|
|
|
// Save
|
|
await page.click('button:has-text("Save")');
|
|
|
|
// Verify success
|
|
await expectSuccessMessage(page, 'Configuration updated');
|
|
});
|
|
|
|
test('Test AI connection', async ({ page }) => {
|
|
// Click AI tab
|
|
await page.click('button:has-text("AI")');
|
|
|
|
// Fill API key
|
|
await page.fill('input[name="gemini_api_key"]', 'test-key');
|
|
|
|
// Click test button
|
|
await page.click('button:has-text("Test Connection")');
|
|
|
|
// Verify result
|
|
await expect(
|
|
page.locator('[data-testid="connection-status"]')
|
|
).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('Update LDAP settings', async ({ page }) => {
|
|
// Click LDAP tab
|
|
await page.click('button:has-text("LDAP")');
|
|
|
|
// Verify LDAP form
|
|
await expect(page.locator('input[name="ldap_server"]')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Fill form
|
|
await page.fill('input[name="ldap_server"]', 'ldap://localhost:389');
|
|
await page.fill('input[name="ldap_base_dn"]', 'dc=ainventory,dc=local');
|
|
|
|
// Test connection
|
|
await page.click('button:has-text("Test")');
|
|
|
|
// Verify success
|
|
await expect(page.locator('text=Connection successful')).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('View and trigger database backup', async ({ page }) => {
|
|
// Click Database tab
|
|
await page.click('button:has-text("Database")');
|
|
|
|
// Verify backup button
|
|
const backupBtn = page.locator('button:has-text("Backup")');
|
|
await expect(backupBtn).toBeVisible({ timeout: 5000 });
|
|
|
|
// Click backup
|
|
await backupBtn.click();
|
|
|
|
// Verify backup in progress message
|
|
await expect(
|
|
page.locator('text=Backup in progress')
|
|
).toBeVisible({ timeout: 5000 });
|
|
|
|
// Wait for completion
|
|
await expect(
|
|
page.locator('text=Backup completed')
|
|
).toBeVisible({ timeout: 30000 });
|
|
});
|
|
|
|
test('Manage categories', async ({ page }) => {
|
|
// Click Categories tab
|
|
await page.click('button:has-text("Categories")');
|
|
|
|
// Verify categories listed
|
|
const categoryList = page.locator('[data-testid="category-list"]');
|
|
await expect(categoryList).toBeVisible({ timeout: 5000 });
|
|
|
|
// Add new category
|
|
await page.fill('input[name="category_name"]', 'New Category');
|
|
await page.click('button:has-text("Add Category")');
|
|
|
|
// Verify added
|
|
await expectSuccessMessage(page, 'Category added');
|
|
await expect(page.locator('text=New Category')).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('Invalid API key shows error', async ({ page }) => {
|
|
// Click AI tab
|
|
await page.click('button:has-text("AI")');
|
|
|
|
// Fill invalid key
|
|
await page.fill('input[name="gemini_api_key"]', '');
|
|
|
|
// Try to save
|
|
await page.click('button:has-text("Save")');
|
|
|
|
// Verify validation error
|
|
await expectErrorMessage(page, 'API key is required');
|
|
});
|
|
|
|
test('Configuration persists after refresh', async ({ page }) => {
|
|
// Click AI tab
|
|
await page.click('button:has-text("AI")');
|
|
|
|
// Select Claude
|
|
await page.click('[data-testid="ai-provider-select"]');
|
|
await page.click('text=Claude');
|
|
|
|
// Save
|
|
await page.click('button:has-text("Save")');
|
|
await expectSuccessMessage(page, 'Configuration updated');
|
|
|
|
// Refresh page
|
|
await page.reload();
|
|
|
|
// Wait for admin to load
|
|
await expect(page.locator('[role="tablist"]')).toBeVisible({ timeout: 10000 });
|
|
|
|
// Click AI tab
|
|
await page.click('button:has-text("AI")');
|
|
|
|
// Verify Claude is still selected
|
|
const providerValue = page.locator('[data-testid="ai-provider-select"]');
|
|
await expect(providerValue).toContainText('Claude');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/workflows/4-admin-settings.spec.ts | head -50`
|
|
Expected: TypeScript test file shown
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/workflows/4-admin-settings.spec.ts
|
|
git commit -m "feat: add admin settings workflow E2E tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Create Offline Sync Workflow Test (5-offline-sync.spec.ts)
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/workflows/5-offline-sync.spec.ts`
|
|
|
|
- [ ] **Step 1: Write offline sync workflow test**
|
|
|
|
Create `frontend/e2e/workflows/5-offline-sync.spec.ts`:
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
import { createDockerEnvironment } from '../utils/docker';
|
|
import { localLogin } from '../fixtures/auth';
|
|
import { expectOfflineQueueSize, expectNoSyncDuplicates } from '../utils/assertions';
|
|
import { navigateTo, simulateOfflineMode, simulateOnlineMode, waitForNetworkIdle, waitForToast } from '../utils/helpers';
|
|
import { LOCAL_USERS, TEST_ITEMS } from '../fixtures/test-data';
|
|
|
|
const WORKFLOW_ID = '5-offline-sync';
|
|
const config = {
|
|
backendPort: 8946,
|
|
frontendPort: 8947,
|
|
ldapPort: 3389,
|
|
ldapEnabled: false,
|
|
aiProvider: 'gemini',
|
|
seedData: true,
|
|
};
|
|
|
|
let env: any;
|
|
|
|
test.describe.configure({ timeout: 120000 });
|
|
|
|
test.beforeAll(async () => {
|
|
env = await createDockerEnvironment(WORKFLOW_ID, config);
|
|
await env.start();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await env.stop();
|
|
});
|
|
|
|
test.describe('Offline Sync Workflow', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Login
|
|
await page.goto(`http://localhost:${config.frontendPort}/`);
|
|
|
|
const localTab = page.locator('button:has-text("Local")');
|
|
if (await localTab.isVisible()) {
|
|
await localTab.click();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
await localLogin(page, LOCAL_USERS.admin.username, LOCAL_USERS.admin.password);
|
|
|
|
// Navigate to inventory
|
|
await navigateTo(page, `http://localhost:${config.frontendPort}/inventory`);
|
|
});
|
|
|
|
test('Perform operation while offline', async ({ page }) => {
|
|
// Go offline
|
|
await simulateOfflineMode(page);
|
|
|
|
// Try to scan/adjust (will be queued)
|
|
const scanButton = page.locator('button[data-testid="open-scanner"]');
|
|
if (await scanButton.isVisible()) {
|
|
await scanButton.click();
|
|
}
|
|
|
|
// Verify offline indicator
|
|
await expect(page.locator('[data-testid="offline-indicator"]')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Go online
|
|
await simulateOnlineMode(page);
|
|
|
|
// Verify sync message appears
|
|
await expect(
|
|
page.locator('text=Syncing')
|
|
).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('Queue 5+ operations while offline', async ({ page }) => {
|
|
// Go offline
|
|
await simulateOfflineMode(page);
|
|
|
|
// Perform 5 operations
|
|
for (let i = 0; i < 5; i++) {
|
|
// Simulate scanning (simplified for test)
|
|
const qtyInput = page.locator(`input[data-testid="qty-${TEST_ITEMS[i].barcode}"]`);
|
|
if (await qtyInput.isVisible()) {
|
|
const currentValue = parseInt(await qtyInput.inputValue());
|
|
await qtyInput.fill(String(currentValue + 1));
|
|
await page.click(`button[data-testid="save-qty-${i}"]`);
|
|
}
|
|
}
|
|
|
|
// Verify queue size
|
|
await expectOfflineQueueSize(page, 5);
|
|
|
|
// Go online
|
|
await simulateOnlineMode(page);
|
|
|
|
// Wait for sync
|
|
await waitForNetworkIdle(page);
|
|
|
|
// Verify queue cleared
|
|
await expectOfflineQueueSize(page, 0);
|
|
});
|
|
|
|
test('Sync batch to server', async ({ page }) => {
|
|
// Go offline
|
|
await simulateOfflineMode(page);
|
|
|
|
// Perform operation
|
|
const qtyInput = page.locator(`input[data-testid="qty-${TEST_ITEMS[0].barcode}"]`);
|
|
if (await qtyInput.isVisible()) {
|
|
const currentValue = parseInt(await qtyInput.inputValue());
|
|
await qtyInput.fill(String(currentValue + 3));
|
|
await page.click(`button[data-testid="save-qty-0"]`);
|
|
}
|
|
|
|
// Go online
|
|
await simulateOnlineMode(page);
|
|
|
|
// Verify sync happens
|
|
await waitForToast(page, 'Synced', 'success');
|
|
await waitForNetworkIdle(page);
|
|
});
|
|
|
|
test('UUID idempotency - no duplicates on resync', async ({ page }) => {
|
|
// Go offline
|
|
await simulateOfflineMode(page);
|
|
|
|
// Perform operation
|
|
const qtyInput = page.locator(`input[data-testid="qty-${TEST_ITEMS[0].barcode}"]`);
|
|
if (await qtyInput.isVisible()) {
|
|
const currentValue = parseInt(await qtyInput.inputValue());
|
|
await qtyInput.fill(String(currentValue + 2));
|
|
await page.click(`button[data-testid="save-qty-0"]`);
|
|
}
|
|
|
|
// Simulate slow sync by going offline mid-sync
|
|
await simulateOnlineMode(page);
|
|
await page.waitForTimeout(500);
|
|
await simulateOfflineMode(page);
|
|
|
|
// Then go online again
|
|
await simulateOnlineMode(page);
|
|
|
|
// Wait for sync
|
|
await waitForNetworkIdle(page);
|
|
|
|
// Verify only one duplicate was created (UUID prevents duplicates)
|
|
await expectNoSyncDuplicates(page, TEST_ITEMS[0].barcode);
|
|
});
|
|
|
|
test('Partial sync failure with retry', async ({ page }) => {
|
|
// Go offline
|
|
await simulateOfflineMode(page);
|
|
|
|
// Perform 3 operations
|
|
for (let i = 0; i < 3; i++) {
|
|
const qtyInput = page.locator(`input[data-testid="qty-${TEST_ITEMS[i].barcode}"]`);
|
|
if (await qtyInput.isVisible()) {
|
|
const currentValue = parseInt(await qtyInput.inputValue());
|
|
await qtyInput.fill(String(currentValue + 1));
|
|
await page.click(`button[data-testid="save-qty-${i}"]`);
|
|
}
|
|
}
|
|
|
|
// Simulate server error during sync
|
|
await page.route('**/api/sync', route => {
|
|
route.abort('failed');
|
|
});
|
|
|
|
// Go online
|
|
await simulateOnlineMode(page);
|
|
|
|
// Verify error shown
|
|
await expect(page.locator('text=Sync failed')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Stop intercepting
|
|
await page.unroute('**/api/sync');
|
|
|
|
// Retry should happen automatically
|
|
await page.click('button:has-text("Retry")');
|
|
await waitForNetworkIdle(page);
|
|
|
|
// Verify sync succeeded
|
|
await waitForToast(page, 'Synced', 'success');
|
|
});
|
|
|
|
test('Page reload preserves offline queue', async ({ page }) => {
|
|
// Go offline
|
|
await simulateOfflineMode(page);
|
|
|
|
// Perform operation
|
|
const qtyInput = page.locator(`input[data-testid="qty-${TEST_ITEMS[0].barcode}"]`);
|
|
if (await qtyInput.isVisible()) {
|
|
const currentValue = parseInt(await qtyInput.inputValue());
|
|
await qtyInput.fill(String(currentValue + 1));
|
|
await page.click(`button[data-testid="save-qty-0"]`);
|
|
}
|
|
|
|
// Verify queue
|
|
await expectOfflineQueueSize(page, 1);
|
|
|
|
// Reload page
|
|
await page.reload();
|
|
|
|
// Wait for page to load
|
|
await expect(page.locator('[data-testid="inventory-list"]')).toBeVisible({ timeout: 10000 });
|
|
|
|
// Verify queue still exists
|
|
await expectOfflineQueueSize(page, 1);
|
|
|
|
// Go online and sync
|
|
await simulateOnlineMode(page);
|
|
await waitForNetworkIdle(page);
|
|
|
|
// Verify queue cleared after sync
|
|
await expectOfflineQueueSize(page, 0);
|
|
});
|
|
|
|
test('Concurrent updates handling', async ({ browser }) => {
|
|
// Create two browser pages to simulate concurrent users
|
|
const page2 = await browser.newPage();
|
|
|
|
try {
|
|
// Both pages login
|
|
await page2.goto(`http://localhost:${config.frontendPort}/`);
|
|
await localLogin(page2, LOCAL_USERS.admin.username, LOCAL_USERS.admin.password);
|
|
await navigateTo(page2, `http://localhost:${config.frontendPort}/inventory`);
|
|
|
|
// Page 1 goes offline and makes change
|
|
await simulateOfflineMode(this.page);
|
|
let qtyInput = page.locator(`input[data-testid="qty-${TEST_ITEMS[0].barcode}"]`);
|
|
const initialValue = parseInt(await qtyInput.inputValue());
|
|
await qtyInput.fill(String(initialValue + 5));
|
|
await page.click(`button[data-testid="save-qty-0"]`);
|
|
|
|
// Page 2 makes change online
|
|
qtyInput = page2.locator(`input[data-testid="qty-${TEST_ITEMS[0].barcode}"]`);
|
|
await qtyInput.fill(String(initialValue + 3));
|
|
await page2.click(`button[data-testid="save-qty-0"]`);
|
|
|
|
// Page 1 goes online
|
|
await simulateOnlineMode(page);
|
|
|
|
// Verify conflict handling (server value or merge)
|
|
await waitForNetworkIdle(page);
|
|
const finalValue = parseInt(
|
|
await page.locator(`input[data-testid="qty-${TEST_ITEMS[0].barcode}"]`).inputValue()
|
|
);
|
|
|
|
// Should be at least the higher value
|
|
expect(finalValue).toBeGreaterThanOrEqual(Math.max(initialValue + 5, initialValue + 3));
|
|
} finally {
|
|
await page2.close();
|
|
}
|
|
});
|
|
|
|
test('Offline sync with network timeout', async ({ page }) => {
|
|
// Go offline
|
|
await simulateOfflineMode(page);
|
|
|
|
// Perform operation
|
|
const qtyInput = page.locator(`input[data-testid="qty-${TEST_ITEMS[0].barcode}"]`);
|
|
if (await qtyInput.isVisible()) {
|
|
const currentValue = parseInt(await qtyInput.inputValue());
|
|
await qtyInput.fill(String(currentValue + 1));
|
|
await page.click(`button[data-testid="save-qty-0"]`);
|
|
}
|
|
|
|
// Simulate timeout on sync
|
|
await page.route('**/api/sync', route => {
|
|
setTimeout(() => route.abort('timedout'), 2000);
|
|
});
|
|
|
|
// Go online
|
|
await simulateOnlineMode(page);
|
|
|
|
// Verify timeout handling
|
|
await page.waitForTimeout(3000);
|
|
|
|
// Verify retry mechanism triggered
|
|
const retryIndicator = page.locator('[data-testid="retry-indicator"]');
|
|
await expect(retryIndicator).toBeVisible({ timeout: 5000 });
|
|
|
|
// Stop intercepting
|
|
await page.unroute('**/api/sync');
|
|
|
|
// Retry should succeed
|
|
await waitForNetworkIdle(page);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file exists**
|
|
|
|
Run: `cat frontend/e2e/workflows/5-offline-sync.spec.ts | head -50`
|
|
Expected: TypeScript test file shown
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/e2e/workflows/5-offline-sync.spec.ts
|
|
git commit -m "feat: add offline sync workflow E2E tests"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase D: Verification & Documentation
|
|
|
|
### Task 16: Create E2E README & Verify Execution
|
|
|
|
**Files:**
|
|
- Create: `frontend/e2e/README.md`
|
|
- Modify: `frontend/package.json` (add E2E scripts)
|
|
|
|
- [ ] **Step 1: Write E2E README**
|
|
|
|
Create `frontend/e2e/README.md`:
|
|
```markdown
|
|
# Playwright E2E Tests
|
|
|
|
Comprehensive end-to-end test suite for aInventory using Playwright with Docker containerization.
|
|
|
|
## Architecture
|
|
|
|
**5 Modular Workflows:**
|
|
1. **Login** (LDAP + local auth)
|
|
2. **Scan-Adjust** (barcode scanning, stock adjustment)
|
|
3. **AI Extraction** (photo upload, AI label extraction)
|
|
4. **Admin Settings** (configuration management)
|
|
5. **Offline Sync** (offline queue, sync idempotency)
|
|
|
|
Each workflow runs in isolated Docker containers with:
|
|
- Dedicated SQLite database
|
|
- Optional OpenLDAP server
|
|
- Separate frontend/backend ports
|
|
- Parallel execution support
|
|
|
|
## Prerequisites
|
|
|
|
- Docker & Docker Compose
|
|
- Node.js 20+
|
|
- Playwright (`npm install` in frontend/)
|
|
|
|
## Setup
|
|
|
|
```bash
|
|
cd frontend
|
|
npm install
|
|
```
|
|
|
|
## Running Tests
|
|
|
|
### All Workflows (Parallel)
|
|
```bash
|
|
npm run e2e
|
|
```
|
|
|
|
Expected runtime: ~8-10 minutes
|
|
|
|
### Specific Workflow
|
|
```bash
|
|
npm run e2e -- workflows/1-login.spec.ts
|
|
```
|
|
|
|
### Debug Mode (Headed Browser)
|
|
```bash
|
|
npm run e2e:debug
|
|
```
|
|
|
|
### View HTML Report
|
|
```bash
|
|
npm run e2e:report
|
|
```
|
|
|
|
## Configuration
|
|
|
|
Each workflow uses environment variables in `.env.e2e.workflow-N`:
|
|
- `BACKEND_PORT`: FastAPI server port
|
|
- `FRONTEND_PORT`: Next.js dev server port
|
|
- `LDAP_PORT`: OpenLDAP port (when enabled)
|
|
- `LDAP_ENABLED`: true/false
|
|
- `AI_PROVIDER`: gemini or claude
|
|
|
|
## Troubleshooting
|
|
|
|
### Docker Port Conflicts
|
|
```bash
|
|
docker ps # Check running containers
|
|
docker-compose -f docker-compose.e2e.yml down # Stop all
|
|
```
|
|
|
|
### LDAP Connection Issues
|
|
```bash
|
|
docker logs <ldap-container-id>
|
|
ldapwhoami -H ldap://localhost:3389 -D "cn=admin,dc=ainventory,dc=local" -w admin
|
|
```
|
|
|
|
### Backend Health Check Failing
|
|
```bash
|
|
curl http://localhost:8906/health
|
|
docker logs <backend-container-id>
|
|
```
|
|
|
|
### Tests Timing Out
|
|
- Increase `timeout` in `playwright.config.ts`
|
|
- Check Docker container resources
|
|
- Verify network connectivity
|
|
|
|
## Test Structure
|
|
|
|
```
|
|
e2e/
|
|
├── workflows/ # Per-workflow test files
|
|
├── fixtures/ # Shared test setup
|
|
├── utils/ # Helper utilities
|
|
├── docker-compose.e2e.yml
|
|
└── playwright.config.ts
|
|
```
|
|
|
|
## CI/CD Integration
|
|
|
|
```bash
|
|
# In CI environment
|
|
npm run e2e -- --reporter=github
|
|
```
|
|
|
|
Reports: `playwright-report/index.html`
|
|
```
|
|
|
|
- [ ] **Step 2: Add E2E scripts to package.json**
|
|
|
|
Edit `frontend/package.json`, add to `scripts` section:
|
|
```json
|
|
"e2e": "playwright test",
|
|
"e2e:debug": "playwright test --debug --headed",
|
|
"e2e:report": "playwright show-report"
|
|
```
|
|
|
|
- [ ] **Step 3: Verify scripts added**
|
|
|
|
Run: `cat frontend/package.json | grep -A 5 '"e2e"'`
|
|
Expected: E2E scripts shown
|
|
|
|
- [ ] **Step 4: Run smoke test (one workflow)**
|
|
|
|
Run: `cd frontend && npm run e2e -- workflows/1-login.spec.ts --workers=1`
|
|
|
|
Expected output:
|
|
```
|
|
✓ 8 passed (45s)
|
|
```
|
|
|
|
(Note: This will take ~2-3 minutes per workflow due to Docker startup)
|
|
|
|
- [ ] **Step 5: Commit README and scripts**
|
|
|
|
```bash
|
|
git add frontend/e2e/README.md frontend/package.json
|
|
git commit -m "docs: add E2E testing guide and npm scripts"
|
|
```
|
|
|
|
- [ ] **Step 6: Final verification - check all test files exist**
|
|
|
|
Run:
|
|
```bash
|
|
find frontend/e2e -name "*.spec.ts" -o -name "*.ts" | wc -l
|
|
```
|
|
|
|
Expected: 12+ files (5 workflows + 4 fixtures + 3 utils)
|
|
|
|
- [ ] **Step 7: Final commit - Phase 3 complete**
|
|
|
|
```bash
|
|
git add -A && git commit -m "feat: phase 3 E2E tests complete - 5 workflows, Docker isolation, <30min parallel"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review Checklist
|
|
|
|
✅ **Spec Coverage:**
|
|
- [x] Playwright installation & config (Task 1-3)
|
|
- [x] Database fixture with seeding/cleanup (Task 5)
|
|
- [x] LDAP fixture for auth testing (Task 6)
|
|
- [x] Auth helpers (login/logout) (Task 7)
|
|
- [x] Custom assertions (Task 8)
|
|
- [x] Docker orchestration (Task 9)
|
|
- [x] Navigation helpers (Task 10)
|
|
- [x] 5 workflow test files (Tasks 11-15)
|
|
- [x] Login (LDAP + local)
|
|
- [x] Scan-Adjust
|
|
- [x] AI Extraction
|
|
- [x] Admin Settings
|
|
- [x] Offline Sync
|
|
- [x] README & npm scripts (Task 16)
|
|
- [x] Per-workflow Docker isolation with dedicated ports
|
|
- [x] Error handling scenarios per workflow
|
|
- [x] Offline queue & sync testing
|
|
|
|
✅ **No Placeholders:**
|
|
- [x] All docker-compose configuration complete
|
|
- [x] All fixture code written (no TODOs)
|
|
- [x] All test scenarios implemented with actual selectors
|
|
- [x] All assertions use real Playwright matchers
|
|
- [x] No vague steps ("add appropriate error handling" etc.)
|
|
|
|
✅ **Type Consistency:**
|
|
- [x] `createDockerEnvironment()` returns consistent interface
|
|
- [x] `createDatabaseFixture()` returns consistent interface
|
|
- [x] `createLDAPFixture()` returns consistent interface
|
|
- [x] Port offsets consistent (8906, 8916, 8926, 8936, 8946)
|
|
- [x] Workflow IDs match file names
|
|
|
|
---
|
|
|
|
## Plan saved to `docs/superpowers/plans/2026-04-19-phase-3-e2e-tests.md`
|
|
|
|
**Two execution options:**
|
|
|
|
**1. Subagent-Driven (Recommended)** — I dispatch a fresh subagent per task, review between tasks, ensures quality
|
|
- **REQUIRED SUB-SKILL:** superpowers:subagent-driven-development
|
|
- Fresh subagent per task + two-stage review
|
|
|
|
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints
|
|
- **REQUIRED SUB-SKILL:** superpowers:executing-plans
|
|
- Batch execution with checkpoints for review
|
|
|
|
**Which approach?**
|