super-productivity/e2e/tests/sync/supersync-server-migration.spec.ts
Johannes Millan 827aede24f test(e2e): add sync-stall recovery to flaky supersync setup and migration
setupSuperSync now re-triggers sync once if the sync-state icon never
appears, recovering first-client setup stalls under parallel CI load.
The multi-migration test re-syncs Client B when an op hasn't downloaded
yet, since B has auto-sync disabled and DOM polling alone can't recover
a not-yet-pulled task. Both paths run only after the original wait times
out, so passing runs are unaffected.
2026-05-28 13:58:19 +02:00

330 lines
12 KiB
TypeScript

import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
const MIGRATION_TEST_TIMEOUT = 180000;
const waitForTasksAfterSync = async (
client: SimulatedE2EClient,
taskNames: string[],
maxAttempts = 3,
): Promise<void> => {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await client.sync.syncAndWait();
} catch (error) {
lastError = error;
}
try {
await Promise.all(
taskNames.map((taskName) => waitForTask(client.page, taskName, 5000)),
);
expect(await client.sync.hasSyncError()).toBe(false);
return;
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error
? lastError
: new Error('Expected synced tasks to become visible');
};
/**
* SuperSync Server Migration E2E Tests
*
* Tests scenarios where a client switches from one sync server to another.
* This simulates server migration, self-hosting changes, or account switches.
*
* The critical bug being tested:
* When a client with existing data (lastServerSeq > 0) connects to a new empty server,
* the server detects a "gap" and the client resets its lastServerSeq.
* However, the client was only uploading incremental operations (not full state),
* causing data loss for other clients that join the new server.
*
* Expected behavior: Client should upload a full state snapshot when migrating
* to ensure all data is transferred to the new server.
*/
// Run server migration tests serially to avoid rate limiting when creating multiple test users
test.describe.serial('@supersync SuperSync Server Migration', () => {
/**
* Server Migration Scenario: Client A migrates to new server, Client B receives all data
*
* This test reproduces the bug where data is lost during server migration:
*
* Setup:
* 1. Client A creates tasks, projects, tags on server 1
* 2. Client A syncs successfully to server 1
*
* Migration:
* 3. Create a new test user (simulates switching to a new/fresh server)
* 4. Client A changes sync config to the new server credentials
* 5. Client A syncs to the "new server"
* - Server detects gap (client has lastServerSeq > 0, server is empty)
* - Client should upload full state snapshot (not just incremental ops)
*
* Verification:
* 6. Client B joins the new server (fresh client)
* 7. Client B syncs
* 8. Client B should have ALL of Client A's data (tasks, projects, tags)
*/
test('Client A migrates to new server, Client B receives all data', async ({
browser,
baseURL,
testRunId,
}, testInfo) => {
testInfo.setTimeout(MIGRATION_TEST_TIMEOUT);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
// === PHASE 1: Setup with "old server" (user1) ===
console.log('[Test] Phase 1: Setting up Client A with initial server');
const user1 = await createTestUser(`${testRunId}-server1`);
const syncConfig1 = getSuperSyncConfig(user1);
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig1);
// Create test data on Client A
const task1 = `Task1-${testRunId}`;
const task2 = `Task2-${testRunId}`;
const task3 = `Task3-${testRunId}`;
await clientA.workView.addTask(task1);
await clientA.workView.addTask(task2);
await clientA.workView.addTask(task3);
// Sync to "old server"
await clientA.sync.syncAndWait();
console.log('[Test] Client A synced to initial server');
// Verify tasks exist on Client A
await waitForTask(clientA.page, task1);
await waitForTask(clientA.page, task2);
await waitForTask(clientA.page, task3);
// === PHASE 2: Simulate server migration ===
console.log('[Test] Phase 2: Migrating Client A to new server');
// Create a new test user (simulates a fresh/new server)
const user2 = await createTestUser(`${testRunId}-server2`);
const syncConfig2 = getSuperSyncConfig(user2);
// Client A changes to new server credentials
// This simulates switching sync providers or migrating to a new server
await clientA.sync.setupSuperSync(syncConfig2);
// Sync to the "new server"
// This is where the bug occurs:
// - Server detects gap (sinceSeq > 0 but server is empty)
// - Client resets lastServerSeq to 0
// - Client should upload FULL STATE (not just pending ops)
await clientA.sync.syncAndWait();
console.log('[Test] Client A synced to new server (migration complete)');
// Verify Client A still has all tasks after migration
await waitForTask(clientA.page, task1);
await waitForTask(clientA.page, task2);
await waitForTask(clientA.page, task3);
// === PHASE 3: Client B joins new server ===
console.log('[Test] Phase 3: Client B joining new server');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig2);
// Client B syncs - should receive ALL of Client A's data
await waitForTasksAfterSync(clientB, [task1, task2, task3]);
console.log('[Test] Client B synced with new server');
// === PHASE 4: Verification ===
console.log('[Test] Phase 4: Verifying Client B has all data');
// This is the critical assertion - Client B should have ALL tasks
// If the bug exists, Client B will only have partial data or no tasks
await waitForTask(clientB.page, task1);
await waitForTask(clientB.page, task2);
await waitForTask(clientB.page, task3);
// Additional verification - count tasks to ensure no duplicates
const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`);
const taskCountB = await taskLocatorB.count();
expect(taskCountB).toBe(3);
console.log('[Test] SUCCESS: Client B received all data after server migration');
} finally {
if (clientA) await closeClient(clientA).catch(() => {});
if (clientB) await closeClient(clientB).catch(() => {});
}
});
/**
* Server Migration with Pending Local Changes
*
* Tests that pending local operations are preserved during server migration.
* Client creates new data, then migrates before syncing.
*/
test('Client A migrates with pending local changes, all data syncs', async ({
browser,
baseURL,
testRunId,
}, testInfo) => {
testInfo.setTimeout(MIGRATION_TEST_TIMEOUT);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
// Setup with initial server
const user1 = await createTestUser(`${testRunId}-server1`);
const syncConfig1 = getSuperSyncConfig(user1);
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig1);
// Create and sync initial data
const task1 = `Initial-${testRunId}`;
await clientA.workView.addTask(task1);
// Wait for task to be fully created before syncing
await waitForTask(clientA.page, task1);
await clientA.sync.syncAndWait();
console.log('[Test] Task 1 created and synced to server 1');
// Create MORE data AFTER syncing (pending local changes)
const task2 = `Pending-${testRunId}`;
await clientA.workView.addTask(task2);
// Wait for task to be fully created in store before migration
await waitForTask(clientA.page, task2);
// Extra delay to ensure operation is written to log (not just UI visible)
await clientA.page.waitForTimeout(500);
console.log('[Test] Task 2 created (pending local change)');
// DON'T sync yet - task2 is a pending local change
// Migrate to new server
const user2 = await createTestUser(`${testRunId}-server2`);
const syncConfig2 = getSuperSyncConfig(user2);
console.log('[Test] Migrating to new server...');
await clientA.sync.setupSuperSync(syncConfig2);
// Sync to new server (should include both synced and pending data)
// Multiple sync cycles to ensure all data is uploaded to the new server
await clientA.sync.syncAndWait();
await clientA.page.waitForTimeout(500);
await clientA.sync.syncAndWait();
await clientA.page.waitForTimeout(500);
// Third sync to ensure everything is confirmed
await clientA.sync.syncAndWait();
console.log('[Test] Migration sync completed');
// Verify Client A still has both tasks after migration
await waitForTask(clientA.page, task1);
await waitForTask(clientA.page, task2);
// Brief delay before Client B joins to ensure server has processed all operations
await clientA.page.waitForTimeout(500);
// Client B joins
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig2);
await waitForTasksAfterSync(clientB, [task1, task2]);
console.log('[Test] Client B sync completed');
// Allow extra time for store updates to propagate to UI
await clientB.page.waitForTimeout(1000);
// Verify Client B has BOTH tasks with extended timeout
await waitForTask(clientB.page, task1, 15000);
await waitForTask(clientB.page, task2, 15000);
const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`);
const taskCountB = await taskLocatorB.count();
expect(taskCountB).toBe(2);
console.log('[Test] SUCCESS: Pending local changes preserved during migration');
} finally {
if (clientA) await closeClient(clientA).catch(() => {});
if (clientB) await closeClient(clientB).catch(() => {});
}
});
/**
* Multiple Migrations: Client A migrates twice
*
* Tests that multiple server migrations work correctly.
*/
test('Client A can migrate multiple times without data loss', async ({
browser,
baseURL,
testRunId,
}, testInfo) => {
testInfo.setTimeout(180000); // 3 minutes — 3 migrations + Client B sync runs hot under parallel load
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
// First server
const user1 = await createTestUser(`${testRunId}-server1`);
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(getSuperSyncConfig(user1));
const task1 = `Server1-${testRunId}`;
await clientA.workView.addTask(task1);
await clientA.sync.syncAndWait();
// First migration
const user2 = await createTestUser(`${testRunId}-server2`);
await clientA.sync.setupSuperSync(getSuperSyncConfig(user2));
const task2 = `Server2-${testRunId}`;
await clientA.workView.addTask(task2);
await clientA.sync.syncAndWait();
// Second migration
const user3 = await createTestUser(`${testRunId}-server3`);
await clientA.sync.setupSuperSync(getSuperSyncConfig(user3));
const task3 = `Server3-${testRunId}`;
await clientA.workView.addTask(task3);
await clientA.sync.syncAndWait();
// Verify Client A has all tasks
await waitForTask(clientA.page, task1);
await waitForTask(clientA.page, task2);
await waitForTask(clientA.page, task3);
// Client B joins the final server
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(getSuperSyncConfig(user3));
await clientB.sync.syncAndWait();
// Client B should have ALL tasks from all migrations. Under parallel CI
// load B's initial download can settle a beat before A's most recent op
// is pullable, so re-sync once if a task hasn't arrived yet (B has no
// auto-sync, so DOM polling alone can't recover a not-yet-downloaded op).
for (const task of [task1, task2, task3]) {
try {
await waitForTask(clientB.page, task, 15000);
} catch {
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, task);
}
}
console.log('[Test] SUCCESS: Multiple migrations preserved all data');
} finally {
if (clientA) await closeClient(clientA).catch(() => {});
if (clientB) await closeClient(clientB).catch(() => {});
}
});
});