feat(e2e): add SuperSync E2E test infrastructure for multi-client sync

- Add test mode to super-sync-server (TEST_MODE=true env var)
- Add /api/test/create-user endpoint for auto-verified test users
- Add /api/test/cleanup endpoint for wiping test data
- Create SuperSyncPage page object for sync configuration
- Create supersync-helpers.ts with test utilities
- Add 6 E2E sync scenarios:
  - 2.1: Client A creates task, Client B downloads
  - 2.2: Both clients create different tasks
  - 1.3: Update propagates between clients
  - 1.4: Delete propagates between clients
  - 3.1: Concurrent edits handled gracefully
  - 2.3: Parent/subtask relationships sync correctly
This commit is contained in:
Johannes Millan 2025-12-05 14:32:20 +01:00
parent 24dc503eb6
commit e62f2f86fa
7 changed files with 1368 additions and 0 deletions

View file

@ -0,0 +1,461 @@
# SuperSync E2E Test Plan
## Status: PLANNED (Not Yet Implemented)
This plan describes full end-to-end tests for operation-log sync between two browser clients using the real super-sync-server. These tests complement the existing Karma-based integration tests by testing the complete sync flow including network, authentication, and UI.
**Prerequisite:** Basic sync functionality should be working before implementing these tests.
---
## Overview
Test sync between two independent browser clients using:
- Real super-sync-server running in Docker
- Two Playwright browser contexts (isolated IndexedDB, localStorage)
- Same user account configured on both clients
- Real HTTP requests to sync server
## Multi-Client Simulation
Playwright's **Browser Contexts** provide complete isolation:
```
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
│ Context A │ │ SuperSync │ │ Context B │
│ (Client A) │ │ Server │ │ (Client B) │
│ │ │ │ │ │
│ IndexedDB A │──upload──▶│ SQLite DB │◀──upload──│ IndexedDB B │
│ clientId: X │ │ (shared) │ │ clientId: Y │
│ │◀─download─│ │─download─▶│ │
└─────────────┘ └─────────────────┘ └─────────────┘
```
Each context has its own:
- IndexedDB (SUP_OPS store with operations)
- localStorage (unique clientId generated on first load)
- Cookies/session
The server is the only shared component - exactly like real multi-device sync.
---
## Implementation Steps
### Step 1: Add Test Mode to Server
**File:** `packages/super-sync-server/src/config.ts`
```typescript
export interface ServerConfig {
// ... existing fields ...
testMode?: {
enabled: boolean;
autoVerifyUsers: boolean;
};
}
```
Environment variable: `TEST_MODE=true`
### Step 2: Add Test-Only Endpoints
**File:** `packages/super-sync-server/src/server.ts` (or new `test-routes.ts`)
Only available when `TEST_MODE=true`:
```typescript
// Register + auto-verify + return JWT in one call
POST /api/test/create-user
Body: { email: string, password: string }
Response: { token: string, userId: number }
// Wipe all test data
POST /api/test/cleanup
Response: { cleaned: true }
```
### Step 3: Create Dockerfile
**File:** `packages/super-sync-server/Dockerfile.test`
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 1900
CMD ["node", "dist/index.js"]
```
### Step 4: Docker Compose Service
**File:** `docker-compose.yaml`
```yaml
services:
supersync:
build:
context: ./packages/super-sync-server
dockerfile: Dockerfile.test
ports:
- '1900:1900'
environment:
- PORT=1900
- TEST_MODE=true
- JWT_SECRET=e2e-test-secret-minimum-32-chars-long
- DATA_DIR=/data
tmpfs:
- /data # In-memory SQLite for test isolation
```
### Step 5: NPM Scripts
**File:** `package.json`
```json
{
"e2e:supersync": "docker compose up -d supersync && npm run e2e -- --grep @supersync; docker compose down supersync"
}
```
### Step 6: SuperSync Page Object
**File:** `e2e/pages/supersync.page.ts`
```typescript
import { type Page, type Locator } from '@playwright/test';
import { BasePage } from './base.page';
export class SuperSyncPage extends BasePage {
readonly syncBtn: Locator;
readonly providerSelect: Locator;
readonly baseUrlInput: Locator;
readonly accessTokenInput: Locator;
readonly saveBtn: Locator;
readonly syncSpinner: Locator;
readonly syncCheckIcon: Locator;
constructor(page: Page) {
super(page);
this.syncBtn = page.locator('button.sync-btn');
this.providerSelect = page.locator('formly-field-mat-select mat-select');
this.baseUrlInput = page.locator('.e2e-baseUrl input');
this.accessTokenInput = page.locator('.e2e-accessToken textarea');
this.saveBtn = page.locator('mat-dialog-actions button[mat-stroked-button]');
this.syncSpinner = page.locator('.sync-btn mat-icon.spin');
this.syncCheckIcon = page.locator('.sync-btn mat-icon.sync-state-ico');
}
async setupSuperSync(config: { baseUrl: string; accessToken: string }): Promise<void> {
await this.syncBtn.click();
await this.providerSelect.waitFor({ state: 'visible' });
await this.providerSelect.click();
const superSyncOption = this.page
.locator('mat-option')
.filter({ hasText: 'SuperSync' });
await superSyncOption.waitFor({ state: 'visible' });
await superSyncOption.click();
await this.baseUrlInput.waitFor({ state: 'visible' });
await this.baseUrlInput.fill(config.baseUrl);
await this.accessTokenInput.fill(config.accessToken);
await this.saveBtn.click();
}
async triggerSync(): Promise<void> {
await this.syncBtn.click();
await Promise.race([
this.syncSpinner.waitFor({ state: 'visible', timeout: 1000 }).catch(() => {}),
this.syncCheckIcon.waitFor({ state: 'visible', timeout: 1000 }).catch(() => {}),
]);
}
async waitForSyncComplete(): Promise<void> {
await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 });
await this.syncCheckIcon.waitFor({ state: 'visible' });
}
}
```
### Step 7: Test Helpers
**File:** `e2e/utils/supersync-helpers.ts`
```typescript
const SUPERSYNC_BASE_URL = 'http://localhost:1900';
export async function createTestUser(
testId: string,
): Promise<{ email: string; token: string }> {
const response = await fetch(`${SUPERSYNC_BASE_URL}/api/test/create-user`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: `test-${testId}@e2e.local`,
password: 'TestPassword123!',
}),
});
if (!response.ok) throw new Error(`Failed to create test user: ${response.status}`);
return response.json();
}
export async function cleanupTestData(): Promise<void> {
await fetch(`${SUPERSYNC_BASE_URL}/api/test/cleanup`, { method: 'POST' });
}
export function getSuperSyncConfig(token: string) {
return { baseUrl: SUPERSYNC_BASE_URL, accessToken: token };
}
```
### Step 8: Extend Test Fixtures
**File:** `e2e/fixtures/test.fixture.ts`
Add `testRunId` for unique test isolation:
```typescript
testRunId: async ({}, use, testInfo) => {
const runId = `${Date.now()}-${testInfo.workerIndex}`;
await use(runId);
},
```
---
## Test Scenarios
### File: `e2e/tests/sync/supersync.spec.ts`
```typescript
import { test, expect } from '../../fixtures/test.fixture';
import { SuperSyncPage } from '../../pages/supersync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { createTestUser, getSuperSyncConfig } from '../../utils/supersync-helpers';
import { waitForAppReady } from '../../utils/waits';
test.describe('@supersync SuperSync E2E', () => {
test('basic sync: Client A creates task, Client B sees it', async ({
browser,
baseURL,
testRunId,
}) => {
// 1. Create shared test user
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user.token);
// 2. Set up Client A
const contextA = await browser.newContext({ storageState: undefined, baseURL });
const pageA = await contextA.newPage();
await pageA.goto('/');
await waitForAppReady(pageA);
const syncA = new SuperSyncPage(pageA);
const workA = new WorkViewPage(pageA, `A-${testRunId}`);
// 3. Configure sync on Client A
await syncA.setupSuperSync(syncConfig);
// 4. Create task on Client A
const taskName = `Task-${testRunId}-from-A`;
await workA.addTask(taskName);
// 5. Sync Client A (upload)
await syncA.triggerSync();
await syncA.waitForSyncComplete();
// 6. Set up Client B
const contextB = await browser.newContext({ storageState: undefined, baseURL });
const pageB = await contextB.newPage();
await pageB.goto('/');
await waitForAppReady(pageB);
const syncB = new SuperSyncPage(pageB);
// 7. Configure sync on Client B (same account)
await syncB.setupSuperSync(syncConfig);
// 8. Sync Client B (download)
await syncB.triggerSync();
await syncB.waitForSyncComplete();
// 9. Verify Client B has the task
await expect(pageB.locator(`task:has-text("${taskName}")`)).toBeVisible();
// Cleanup
await contextA.close();
await contextB.close();
});
test('bidirectional: both clients create tasks', async ({
browser,
baseURL,
testRunId,
}) => {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user.token);
// Set up both clients
const contextA = await browser.newContext({ storageState: undefined, baseURL });
const pageA = await contextA.newPage();
await pageA.goto('/');
await waitForAppReady(pageA);
const syncA = new SuperSyncPage(pageA);
const workA = new WorkViewPage(pageA, `A-${testRunId}`);
await syncA.setupSuperSync(syncConfig);
const contextB = await browser.newContext({ storageState: undefined, baseURL });
const pageB = await contextB.newPage();
await pageB.goto('/');
await waitForAppReady(pageB);
const syncB = new SuperSyncPage(pageB);
const workB = new WorkViewPage(pageB, `B-${testRunId}`);
await syncB.setupSuperSync(syncConfig);
// Both create tasks
const taskFromA = `Task-${testRunId}-from-A`;
const taskFromB = `Task-${testRunId}-from-B`;
await workA.addTask(taskFromA);
await workB.addTask(taskFromB);
// Client A syncs (uploads)
await syncA.triggerSync();
await syncA.waitForSyncComplete();
// Client B syncs (uploads + downloads)
await syncB.triggerSync();
await syncB.waitForSyncComplete();
// Client A syncs again (downloads B's task)
await syncA.triggerSync();
await syncA.waitForSyncComplete();
// Verify both see both tasks
await expect(pageA.locator(`task:has-text("${taskFromA}")`)).toBeVisible();
await expect(pageA.locator(`task:has-text("${taskFromB}")`)).toBeVisible();
await expect(pageB.locator(`task:has-text("${taskFromA}")`)).toBeVisible();
await expect(pageB.locator(`task:has-text("${taskFromB}")`)).toBeVisible();
await contextA.close();
await contextB.close();
});
test('conflict: both edit same task', async ({ browser, baseURL, testRunId }) => {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user.token);
// Set up Client A, create task, sync
const contextA = await browser.newContext({ storageState: undefined, baseURL });
const pageA = await contextA.newPage();
await pageA.goto('/');
await waitForAppReady(pageA);
const syncA = new SuperSyncPage(pageA);
const workA = new WorkViewPage(pageA, `A-${testRunId}`);
await syncA.setupSuperSync(syncConfig);
const taskName = `Shared-${testRunId}`;
await workA.addTask(taskName);
await syncA.triggerSync();
await syncA.waitForSyncComplete();
// Set up Client B, sync to get the task
const contextB = await browser.newContext({ storageState: undefined, baseURL });
const pageB = await contextB.newPage();
await pageB.goto('/');
await waitForAppReady(pageB);
const syncB = new SuperSyncPage(pageB);
await syncB.setupSuperSync(syncConfig);
await syncB.triggerSync();
await syncB.waitForSyncComplete();
// Both have the task now
await expect(pageB.locator(`task:has-text("${taskName}")`)).toBeVisible();
// Client A marks done (offline)
const taskA = pageA.locator(`task:has-text("${taskName}")`);
await taskA.locator('.mat-mdc-checkbox').click();
// Client B changes something else (offline)
// ... (could add notes, change estimate, etc.)
// Client A syncs
await syncA.triggerSync();
await syncA.waitForSyncComplete();
// Client B syncs - may detect conflict
await syncB.triggerSync();
await syncB.waitForSyncComplete();
// Final sync to converge
await syncA.triggerSync();
await syncA.waitForSyncComplete();
// Verify consistent state (task count should match)
const countA = await pageA.locator('task').count();
const countB = await pageB.locator('task').count();
expect(countA).toBe(countB);
await contextA.close();
await contextB.close();
});
});
```
---
## Data Isolation Strategy
- **Unique test user per test**: `test-{timestamp}-{workerIndex}@e2e.local`
- **Task prefixes**: `A-{testRunId}-TaskName` for clear identification
- **In-memory SQLite**: Server uses `tmpfs` mount, no data persists between runs
- **Isolated browser contexts**: Each client has separate IndexedDB
---
## Running the Tests
```bash
# Start server and run all supersync tests
npm run e2e:supersync
# Manual execution (server must be running)
docker compose up -d supersync
npm run e2e:playwright:file e2e/tests/sync/supersync.spec.ts
docker compose down supersync
```
---
## Files to Create/Modify
| File | Action |
| -------------------------------------------- | ------------------------- |
| `packages/super-sync-server/src/config.ts` | Add testMode config |
| `packages/super-sync-server/src/auth.ts` | Add test mode bypass |
| `packages/super-sync-server/src/server.ts` | Add test-only routes |
| `packages/super-sync-server/Dockerfile.test` | Create |
| `docker-compose.yaml` | Add supersync service |
| `package.json` | Add e2e:supersync scripts |
| `e2e/pages/supersync.page.ts` | Create |
| `e2e/utils/supersync-helpers.ts` | Create |
| `e2e/fixtures/test.fixture.ts` | Add testRunId |
| `e2e/tests/sync/supersync.spec.ts` | Create |
---
## Comparison with Integration Tests
| Aspect | Karma Integration Tests | Playwright E2E Tests |
| ----------- | -------------------------- | ----------------------- |
| Location | `src/app/.../integration/` | `e2e/tests/sync/` |
| Server | Mocked/simulated | Real super-sync-server |
| Browser | ChromeHeadless (single) | Multiple contexts |
| Speed | Fast (~seconds) | Slower (~minutes) |
| Purpose | Logic verification | Full flow validation |
| When to run | Every PR | Before release / manual |
Both test types are valuable. Integration tests catch logic bugs quickly; E2E tests catch integration issues in the full stack.

View file

@ -0,0 +1,93 @@
import { type Page, type Locator } from '@playwright/test';
import { BasePage } from './base.page';
export interface SuperSyncConfig {
baseUrl: string;
accessToken: string;
}
/**
* Page object for SuperSync configuration and sync operations.
* Used for E2E tests that verify multi-client sync via the super-sync-server.
*/
export class SuperSyncPage extends BasePage {
readonly syncBtn: Locator;
readonly providerSelect: Locator;
readonly baseUrlInput: Locator;
readonly accessTokenInput: Locator;
readonly saveBtn: Locator;
readonly syncSpinner: Locator;
readonly syncCheckIcon: Locator;
readonly syncErrorIcon: Locator;
constructor(page: Page) {
super(page);
this.syncBtn = page.locator('button.sync-btn');
this.providerSelect = page.locator('formly-field-mat-select mat-select');
this.baseUrlInput = page.locator('.e2e-baseUrl input');
this.accessTokenInput = page.locator('.e2e-accessToken textarea');
this.saveBtn = page.locator('mat-dialog-actions button[mat-stroked-button]');
this.syncSpinner = page.locator('.sync-btn mat-icon.spin');
this.syncCheckIcon = page.locator('.sync-btn mat-icon.sync-state-ico');
this.syncErrorIcon = page.locator('.sync-btn mat-icon.error');
}
/**
* Configure SuperSync with server URL and access token.
*/
async setupSuperSync(config: SuperSyncConfig): Promise<void> {
await this.syncBtn.click();
await this.providerSelect.waitFor({ state: 'visible' });
await this.providerSelect.click();
// Select SuperSync option
const superSyncOption = this.page
.locator('mat-option')
.filter({ hasText: 'SuperSync' });
await superSyncOption.waitFor({ state: 'visible' });
await superSyncOption.click();
// Fill configuration
await this.baseUrlInput.waitFor({ state: 'visible' });
await this.baseUrlInput.fill(config.baseUrl);
await this.accessTokenInput.fill(config.accessToken);
// Save
await this.saveBtn.click();
}
/**
* Trigger a sync operation by clicking the sync button.
*/
async triggerSync(): Promise<void> {
await this.syncBtn.click();
// Wait for sync to start or complete immediately
await Promise.race([
this.syncSpinner.waitFor({ state: 'visible', timeout: 2000 }).catch(() => {}),
this.syncCheckIcon.waitFor({ state: 'visible', timeout: 2000 }).catch(() => {}),
]);
}
/**
* Wait for sync to complete (spinner gone, check icon visible).
*/
async waitForSyncComplete(timeout = 30000): Promise<void> {
await this.syncSpinner.waitFor({ state: 'hidden', timeout });
await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 5000 });
}
/**
* Check if sync resulted in an error.
*/
async hasSyncError(): Promise<boolean> {
return this.syncErrorIcon.isVisible();
}
/**
* Perform a full sync and wait for completion.
*/
async syncAndWait(): Promise<void> {
await this.triggerSync();
await this.waitForSyncComplete();
}
}

View file

@ -0,0 +1,465 @@
import { test as base, expect } from '@playwright/test';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
isServerHealthy,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
/**
* SuperSync E2E Tests
*
* These tests verify multi-client sync using the real super-sync-server.
* They mirror scenarios from sync-scenarios.integration.spec.ts but test
* the full stack including UI, network, and real IndexedDB isolation.
*
* Prerequisites:
* - super-sync-server running on localhost:1900 with TEST_MODE=true
* - Frontend running on localhost:4242
*
* Run with: npm run e2e:supersync
*/
/**
* Generate a unique test run ID for data isolation.
*/
const generateTestRunId = (workerIndex: number): string => {
return `${Date.now()}-${workerIndex}`;
};
base.describe('@supersync SuperSync E2E', () => {
// Skip all tests if server is not running
base.beforeAll(async () => {
const healthy = await isServerHealthy();
if (!healthy) {
base.skip();
}
});
/**
* Scenario 2.1: Client A Creates, Client B Downloads
*
* This is the simplest sync scenario - one client creates data,
* another client downloads it.
*
* Setup: Client A and B, empty server
*
* Actions:
* 1. Client A creates "Task 1", syncs
* 2. Client B syncs (download)
*
* Expected:
* - Client A: has Task 1
* - Client B: has Task 1 (received via sync)
* - Server: has 1 operation
*/
base(
'2.1 Client A creates task, Client B downloads it',
async ({ browser, baseURL }, testInfo) => {
const testRunId = generateTestRunId(testInfo.workerIndex);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
// Create shared test user (both clients use same account)
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Set up Client A
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
// Step 1: Client A creates a task
const taskName = `Task-${testRunId}-from-A`;
await clientA.workView.addTask(taskName);
// Step 2: Client A syncs (upload)
await clientA.sync.syncAndWait();
// Verify Client A still has the task
await waitForTask(clientA.page, taskName);
// Set up Client B (fresh context = isolated IndexedDB)
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Step 3: Client B syncs (download)
await clientB.sync.syncAndWait();
// Verify Client B has the task from Client A
await waitForTask(clientB.page, taskName);
// Final assertions
const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`);
const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`);
await expect(taskLocatorA).toBeVisible();
await expect(taskLocatorB).toBeVisible();
} finally {
// Cleanup
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
},
);
/**
* Scenario 2.2: Both Clients Create Different Tasks
*
* Setup: A and B connected, empty server
*
* Actions:
* 1. Client A creates "Task A", syncs
* 2. Client B creates "Task B", syncs
* 3. Client A syncs (download)
*
* Expected: Both clients have both tasks
*/
base(
'2.2 Both clients create different tasks',
async ({ browser, baseURL }, testInfo) => {
const testRunId = generateTestRunId(testInfo.workerIndex);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Set up both clients
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Step 1: Client A creates Task A
const taskA = `TaskA-${testRunId}`;
await clientA.workView.addTask(taskA);
// Step 2: Client A syncs (upload Task A)
await clientA.sync.syncAndWait();
// Step 3: Client B creates Task B
const taskB = `TaskB-${testRunId}`;
await clientB.workView.addTask(taskB);
// Step 4: Client B syncs (upload Task B, download Task A)
await clientB.sync.syncAndWait();
// Step 5: Client A syncs (download Task B)
await clientA.sync.syncAndWait();
// Verify both clients have both tasks
await waitForTask(clientA.page, taskA);
await waitForTask(clientA.page, taskB);
await waitForTask(clientB.page, taskA);
await waitForTask(clientB.page, taskB);
await expect(clientA.page.locator(`task:has-text("${taskA}")`)).toBeVisible();
await expect(clientA.page.locator(`task:has-text("${taskB}")`)).toBeVisible();
await expect(clientB.page.locator(`task:has-text("${taskA}")`)).toBeVisible();
await expect(clientB.page.locator(`task:has-text("${taskB}")`)).toBeVisible();
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
},
);
/**
* Scenario 1.3: Update Task and Sync
*
* Setup: Client A with existing task
*
* Actions:
* 1. Client A creates "Task 1", syncs
* 2. Client B syncs (download)
* 3. Client A marks task as done, syncs
* 4. Client B syncs
*
* Expected: Both clients see task as done
*/
base(
'1.3 Update propagates between clients',
async ({ browser, baseURL }, testInfo) => {
const testRunId = generateTestRunId(testInfo.workerIndex);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Set up Client A and create task
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
const taskName = `Task-${testRunId}-update`;
await clientA.workView.addTask(taskName);
await clientA.sync.syncAndWait();
// Set up Client B and sync to get the task
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.syncAndWait();
// Verify Client B has the task
await waitForTask(clientB.page, taskName);
// Client A marks task as done
const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`);
await taskLocatorA.locator('.mat-mdc-checkbox').click();
// Client A syncs the update
await clientA.sync.syncAndWait();
// Client B syncs to receive the update
await clientB.sync.syncAndWait();
// Verify both show task as done
const checkboxA = taskLocatorA.locator('.mat-mdc-checkbox');
const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`);
const checkboxB = taskLocatorB.locator('.mat-mdc-checkbox');
await expect(checkboxA).toBeChecked();
await expect(checkboxB).toBeChecked();
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
},
);
/**
* Scenario 1.4: Delete Task and Sync
*
* Actions:
* 1. Client A creates task, syncs
* 2. Client B syncs (download)
* 3. Client A deletes task, syncs
* 4. Client B syncs
*
* Expected: Task removed from both clients
*/
base(
'1.4 Delete propagates between clients',
async ({ browser, baseURL }, testInfo) => {
const testRunId = generateTestRunId(testInfo.workerIndex);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Set up Client A and create task
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
const taskName = `Task-${testRunId}-delete`;
await clientA.workView.addTask(taskName);
await clientA.sync.syncAndWait();
// Set up Client B and sync
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.syncAndWait();
// Verify Client B has the task
await waitForTask(clientB.page, taskName);
// Client A deletes the task
const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`);
await taskLocatorA.hover();
const deleteBtn = taskLocatorA.locator('button[aria-label="Delete task"]');
await deleteBtn.click();
// Confirm deletion if dialog appears
const confirmBtn = clientA.page.locator(
'mat-dialog-actions button:has-text("Delete")',
);
if (await confirmBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await confirmBtn.click();
}
// Client A syncs the deletion
await clientA.sync.syncAndWait();
// Client B syncs to receive the deletion
await clientB.sync.syncAndWait();
// Verify task is removed from both clients
await expect(
clientA.page.locator(`task:has-text("${taskName}")`),
).not.toBeVisible();
await expect(
clientB.page.locator(`task:has-text("${taskName}")`),
).not.toBeVisible();
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
},
);
/**
* Scenario 3.1: Concurrent Edits on Same Task
*
* This tests basic conflict handling - both clients edit the same
* task without seeing each other's changes first.
*
* Actions:
* 1. Client A creates task, syncs
* 2. Client B syncs (download)
* 3. Client A marks task done (no sync yet)
* 4. Client B adds notes to task (no sync yet)
* 5. Client A syncs
* 6. Client B syncs
*
* Expected: Conflict detected or auto-merged, final state consistent
*/
base(
'3.1 Concurrent edits handled gracefully',
async ({ browser, baseURL }, testInfo) => {
const testRunId = generateTestRunId(testInfo.workerIndex);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Set up both clients
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
const taskName = `Task-${testRunId}-conflict`;
await clientA.workView.addTask(taskName);
await clientA.sync.syncAndWait();
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.syncAndWait();
// Both clients now have the task
await waitForTask(clientA.page, taskName);
await waitForTask(clientB.page, taskName);
// Client A marks done (creates local op)
const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`);
await taskLocatorA.locator('.mat-mdc-checkbox').click();
// Client B marks done too (concurrent edit)
const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`);
await taskLocatorB.locator('.mat-mdc-checkbox').click();
// Client A syncs first
await clientA.sync.syncAndWait();
// Client B syncs (may detect concurrent edit)
await clientB.sync.syncAndWait();
// Client A syncs again to converge
await clientA.sync.syncAndWait();
// Verify both clients have consistent state
const countA = await clientA.page.locator('task').count();
const countB = await clientB.page.locator('task').count();
expect(countA).toBe(countB);
// Task should exist on both
await expect(taskLocatorA).toBeVisible();
await expect(taskLocatorB).toBeVisible();
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
},
);
/**
* Scenario 2.3: Client A Creates Parent, Client B Creates Subtask
*
* Tests parent-child task relationships syncing correctly.
*
* Actions:
* 1. Client A creates parent task, syncs
* 2. Client B syncs (downloads parent)
* 3. Client B creates subtask under parent, syncs
* 4. Client A syncs (downloads subtask)
*
* Expected: Both clients have parent with subtask
*/
base(
'2.3 Client A creates parent, Client B creates subtask',
async ({ browser, baseURL }, testInfo) => {
const testRunId = generateTestRunId(testInfo.workerIndex);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Set up Client A and create parent task
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
const parentTaskName = `Parent-${testRunId}`;
await clientA.workView.addTask(parentTaskName);
await clientA.sync.syncAndWait();
// Set up Client B and sync to get the parent task
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.syncAndWait();
// Verify Client B has the parent task
await waitForTask(clientB.page, parentTaskName);
// Client B creates a subtask under the parent
const subtaskName = `Subtask-${testRunId}`;
const parentTaskB = clientB.page.locator(`task:has-text("${parentTaskName}")`);
await clientB.workView.addSubTask(parentTaskB, subtaskName);
// Client B syncs (uploads subtask)
await clientB.sync.syncAndWait();
// Client A syncs (downloads subtask)
await clientA.sync.syncAndWait();
// Verify both clients have parent and subtask
// First expand the parent task to see subtasks
const parentTaskA = clientA.page.locator(`task:has-text("${parentTaskName}")`);
const expandBtnA = parentTaskA.locator('.expand-btn');
if (await expandBtnA.isVisible()) {
await expandBtnA.click();
}
const expandBtnB = parentTaskB.locator('.expand-btn');
if (await expandBtnB.isVisible()) {
await expandBtnB.click();
}
// Wait for subtasks to be visible
await waitForTask(clientA.page, subtaskName);
await waitForTask(clientB.page, subtaskName);
// Verify subtask exists on both
await expect(
clientA.page.locator(`task:has-text("${subtaskName}")`),
).toBeVisible();
await expect(
clientB.page.locator(`task:has-text("${subtaskName}")`),
).toBeVisible();
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
},
);
});

View file

@ -0,0 +1,197 @@
import { type Browser, type BrowserContext, type Page } from '@playwright/test';
import { SuperSyncPage, type SuperSyncConfig } from '../pages/supersync.page';
import { WorkViewPage } from '../pages/work-view.page';
import { waitForAppReady } from './waits';
/**
* SuperSync server URL for E2E tests.
* Server must be running with TEST_MODE=true.
*/
export const SUPERSYNC_BASE_URL = 'http://localhost:1900';
/**
* Test user credentials returned from the server.
*/
export interface TestUser {
email: string;
token: string;
userId: number;
}
/**
* A simulated client for E2E sync tests.
* Wraps a browser context, page, and page objects.
*/
export interface SimulatedE2EClient {
context: BrowserContext;
page: Page;
workView: WorkViewPage;
sync: SuperSyncPage;
clientName: string;
}
/**
* Create a test user on the SuperSync server.
* Requires server to be running with TEST_MODE=true.
*
* @param testId - Unique test identifier for user email
* @returns Test user with email and JWT token
*/
export const createTestUser = async (testId: string): Promise<TestUser> => {
const email = `test-${testId}@e2e.local`;
const password = 'TestPassword123!';
const headers = new Headers();
headers.set('Content-Type', 'application/json');
const response = await fetch(`${SUPERSYNC_BASE_URL}/api/test/create-user`, {
method: 'POST',
headers,
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to create test user: ${response.status} - ${text}`);
}
const data = await response.json();
return {
email,
token: data.token,
userId: data.userId,
};
};
/**
* Clean up all test data on the server.
* Call this in test teardown if needed.
*/
export const cleanupTestData = async (): Promise<void> => {
const response = await fetch(`${SUPERSYNC_BASE_URL}/api/test/cleanup`, {
method: 'POST',
});
if (!response.ok) {
console.warn(`Cleanup failed: ${response.status}`);
}
};
/**
* Check if the SuperSync server is running and healthy.
*/
export const isServerHealthy = async (): Promise<boolean> => {
try {
const response = await fetch(`${SUPERSYNC_BASE_URL}/health`, {
method: 'GET',
signal: AbortSignal.timeout(2000),
});
return response.ok;
} catch {
return false;
}
};
/**
* Get SuperSync configuration for a test user.
*/
export const getSuperSyncConfig = (user: TestUser): SuperSyncConfig => {
return {
baseUrl: SUPERSYNC_BASE_URL,
accessToken: user.token,
};
};
/**
* Create a simulated E2E client with its own isolated browser context.
*
* Each client has:
* - Separate browser context (isolated IndexedDB, localStorage)
* - Unique clientId generated by the app on first load
* - WorkViewPage for task operations
* - SuperSyncPage for sync operations
*
* @param browser - Playwright browser instance
* @param baseURL - App base URL (e.g., http://localhost:4242)
* @param clientName - Human-readable name for debugging (e.g., "A", "B")
* @param testPrefix - Test prefix for task naming
*/
export const createSimulatedClient = async (
browser: Browser,
baseURL: string,
clientName: string,
testPrefix: string,
): Promise<SimulatedE2EClient> => {
const context = await browser.newContext({
storageState: undefined, // Clean slate - no shared state
userAgent: `PLAYWRIGHT SYNC-CLIENT-${clientName}`,
baseURL,
});
const page = await context.newPage();
// Set up error logging
page.on('pageerror', (error) => {
console.error(`[Client ${clientName}] Page error:`, error.message);
});
page.on('console', (msg) => {
if (msg.type() === 'error') {
console.error(`[Client ${clientName}] Console error:`, msg.text());
}
});
// Navigate to app and wait for ready
await page.goto('/');
await waitForAppReady(page);
const workView = new WorkViewPage(page, `${clientName}-${testPrefix}`);
const sync = new SuperSyncPage(page);
return {
context,
page,
workView,
sync,
clientName,
};
};
/**
* Close a simulated client and clean up resources.
*/
export const closeClient = async (client: SimulatedE2EClient): Promise<void> => {
await client.context.close();
};
/**
* Wait for a task with given name to appear on the page.
*/
export const waitForTask = async (
page: Page,
taskName: string,
timeout = 15000,
): Promise<void> => {
const escapedName = taskName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
await page.waitForSelector(`task:has-text("${escapedName}")`, { timeout });
};
/**
* Count tasks matching a pattern on the page.
*/
export const countTasks = async (page: Page, pattern?: string): Promise<number> => {
if (pattern) {
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return page.locator(`task:has-text("${escapedPattern}")`).count();
}
return page.locator('task').count();
};
/**
* Check if a task exists on the page.
*/
export const hasTask = async (page: Page, taskName: string): Promise<boolean> => {
const escapedName = taskName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const count = await page.locator(`task:has-text("${escapedName}")`).count();
return count > 0;
};

View file

@ -20,6 +20,15 @@ export interface ServerConfig {
pass?: string;
from: string;
};
/**
* Test mode configuration. When enabled, provides endpoints for E2E testing.
* NEVER enable in production!
*/
testMode?: {
enabled: boolean;
/** Automatically verify users on registration (skip email verification) */
autoVerifyUsers: boolean;
};
}
const DEFAULT_CONFIG: ServerConfig = {
@ -113,6 +122,17 @@ export const loadConfigFromEnv = (
};
}
// Test mode configuration
if (process.env.TEST_MODE === 'true') {
if (process.env.NODE_ENV === 'production') {
throw new Error('TEST_MODE cannot be enabled in production');
}
config.testMode = {
enabled: true,
autoVerifyUsers: true,
};
}
// Validation
if (!Number.isInteger(config.port) || config.port <= 0) {
throw new Error(`Invalid port configuration: ${config.port}`);

View file

@ -11,6 +11,7 @@ import { initDb } from './db';
import { apiRoutes } from './api';
import { pageRoutes } from './pages';
import { syncRoutes, startCleanupJobs, stopCleanupJobs } from './sync';
import { testRoutes } from './test-routes';
export { ServerConfig, loadConfigFromEnv };
@ -93,6 +94,12 @@ export const createServer = (
// Sync Routes (operation-based sync)
await fastifyServer.register(syncRoutes, { prefix: '/api/sync' });
// Test Routes (only in test mode)
if (fullConfig.testMode?.enabled) {
await fastifyServer.register(testRoutes, { prefix: '/api/test' });
Logger.warn('TEST MODE ENABLED - Test routes available at /api/test/*');
}
// Page Routes
await fastifyServer.register(pageRoutes, { prefix: '/' });

View file

@ -0,0 +1,125 @@
/**
* Test-only routes for E2E testing.
* These routes are only available when TEST_MODE=true.
*
* NEVER enable in production!
*/
import { FastifyInstance } from 'fastify';
import * as bcrypt from 'bcryptjs';
import * as jwt from 'jsonwebtoken';
import { getDb } from './db';
import { Logger } from './logger';
const BCRYPT_ROUNDS = 12;
const JWT_EXPIRY = '7d';
const getJwtSecret = (): string => {
return process.env.JWT_SECRET || 'super-sync-dev-secret-do-not-use-in-production';
};
interface CreateUserBody {
email: string;
password: string;
}
export const testRoutes = async (fastify: FastifyInstance): Promise<void> => {
/**
* Create a test user with auto-verification.
* Returns a JWT token immediately without email verification.
*/
fastify.post<{ Body: CreateUserBody }>(
'/create-user',
{
schema: {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 8 },
},
},
},
},
async (request, reply) => {
const { email, password } = request.body;
const db = getDb();
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
try {
// Check if user already exists
const existingUser = db
.prepare('SELECT id, email FROM users WHERE email = ?')
.get(email) as { id: number; email: string } | undefined;
let userId: number;
if (existingUser) {
// User exists, just return a token for them
userId = existingUser.id;
Logger.info(`[TEST] Returning existing user: ${email} (ID: ${userId})`);
} else {
// Create user with is_verified=1 (skip email verification)
const info = db
.prepare(
`
INSERT INTO users (email, password_hash, is_verified, verification_token, verification_token_expires_at)
VALUES (?, ?, 1, NULL, NULL)
`,
)
.run(email, passwordHash);
userId = info.lastInsertRowid as number;
Logger.info(`[TEST] Created test user: ${email} (ID: ${userId})`);
}
// Generate JWT token
const token = jwt.sign({ userId, email }, getJwtSecret(), {
expiresIn: JWT_EXPIRY,
});
return reply.status(201).send({
token,
userId,
email,
});
} catch (err: unknown) {
Logger.error('[TEST] Failed to create test user:', err);
return reply.status(500).send({
error: 'Failed to create test user',
message: (err as Error).message,
});
}
},
);
/**
* Clean up all test data.
* Wipes users, operations, sync state, and devices.
*/
fastify.post('/cleanup', async (_request, reply) => {
const db = getDb();
try {
// Delete in correct order due to foreign key constraints
db.exec('DELETE FROM operations');
db.exec('DELETE FROM sync_devices');
db.exec('DELETE FROM user_sync_state');
db.exec('DELETE FROM tombstones');
db.exec('DELETE FROM users');
Logger.info('[TEST] All test data cleaned up');
return reply.send({ cleaned: true });
} catch (err: unknown) {
Logger.error('[TEST] Cleanup failed:', err);
return reply.status(500).send({
error: 'Cleanup failed',
message: (err as Error).message,
});
}
});
Logger.info('[TEST] Test routes registered at /api/test/*');
};