mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 17:05:48 +00:00
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.
330 lines
12 KiB
TypeScript
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(() => {});
|
|
}
|
|
});
|
|
});
|