mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
24dc503eb6
commit
e62f2f86fa
7 changed files with 1368 additions and 0 deletions
461
docs/ai/sync/supersync-e2e-test-plan.md
Normal file
461
docs/ai/sync/supersync-e2e-test-plan.md
Normal 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.
|
||||
93
e2e/pages/supersync.page.ts
Normal file
93
e2e/pages/supersync.page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
465
e2e/tests/sync/supersync.spec.ts
Normal file
465
e2e/tests/sync/supersync.spec.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
197
e2e/utils/supersync-helpers.ts
Normal file
197
e2e/utils/supersync-helpers.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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: '/' });
|
||||
|
||||
|
|
|
|||
125
packages/super-sync-server/src/test-routes.ts
Normal file
125
packages/super-sync-server/src/test-routes.ts
Normal 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/*');
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue