super-productivity/e2e/tests/sync/supersync.spec.ts
Johannes Millan b50d5f6d96
fix(sync): dedupe surgical-sync retries and keep the import author through pruning (#9089)
* fix(sync): deduplicate surgical sync retries

Persist split-file configuration and acknowledge operation IDs already committed remotely after a lost upload response.

* fix(sync): preserve import author during clock pruning

Keep the active causal full-state author in oversized stored clocks and reuse the stored protected IDs when classifying response-loss retries.

* test(sync): harden supersync failure coverage

Exercise real upload endpoints and exact operation IDs across response loss, validation failures, schema blockers, full-state boundaries, vector pruning, and concurrent edits.

* fix(sync): heal a corrupt primary on duplicate-only uploads

The .bak recovery path caches the CORRUPT primary's rev precisely so this
cycle's conditional overwrite repairs sync-ops.json. The duplicate-retry
short-circuit read that cache and returned before the write, leaving the
primary corrupt whenever the recovered buffer already held every pending
op. Flag the recovered entry and let those uploads fall through.

Also stop synthesising a serverSeq for ops already in the buffer: the
field is optional, the upload and download paths number ops differently,
and a mixed batch could hand two ops the same value.

* perf(sync): look the full-state author up once per upload

Batch upload is off by default, so the guarded batch path was not the one
serving production: the serial path queries the causal full-state author
per op inside a single transaction, and a clock of 21-50 entries passes
validation and trips the guard on every one of them. Memoize the author
per transaction and resolve it lazily, so only an op whose clock actually
overflows pays, and only once. This also retires the batch pre-scan and
its loop-carried author, leaving both paths on one mechanism.

Report the lookup through ProcessOperationResult so the upload summary
stops under-reporting round-trips, and record why reconstructing the
stored protected set loosens id-collision detection.

* test(sync): wait for the committed title in renameTask

renameTask blurred the textarea, slept 300ms and returned without ever
checking the rename landed. Blur -> dispatch -> re-render outruns that
delay on a loaded machine, so a following sync uploads without the rename
op and the caller asserts against a task that was never renamed — which
is what supersync 3.1 hits on CI but never locally.

Wait for the new title instead, mirroring markTaskDone's done-state wait
and the e2e no-waitForTimeout rule.

* test(sync): dispatch focus so renameTask actually commits

renameTask relied on el.focus() to emit a focus event, but these tests
drive two clients as separate pages and only one page can hold focus, so
on CI the event often never fires. TaskTitleComponent then keeps
_isFocused=false, and resetToLastExternalValueTrigger resets tmpValue to
the stored title on the next task-object emission. Blur therefore
computes wasChanged=false, task.component skips update(), and the rename
is silently dropped without ever becoming an op.

That is supersync 3.1: client A's rename lives only in tmpValue, A syncs
and uploads nothing, B uploads its done op, A downloads it, the task ref
changes and the title reverts to the original — exactly the state the CI
artifact captured. A real user always has real focus, so the app itself
is unaffected.

Dispatch focus explicitly, mirroring the synthetic input/blur already
used here. Also correct the previous commit's claim: the toBeVisible
wait matches tmpValue, a component-local signal rendered in both
template branches, so it never observed the committed title and could
not have fixed this.

* docs: revert incidental prettier reformat of unrelated docs

The master merge ran prettier across files it pulled in, reformatting
three documents this branch has no business touching: markdown table
cell padding plus *emphasis* -> _emphasis_, with no content change.
handover.md documents two unrelated branches entirely.

Restores them to master. vector-clocks.md keeps its edits — those are
this branch's own and describe the pruning protection.

* refactor(sync): drop the full-state author lookup's roundtrip accounting

resolveFullStateAuthor memoizes per transaction, so the lookup it counts
fires at most once per upload — the plumbing existed to report a number
that is always 0 or 1, on a log line already counting dozens. The memo
and its own accounting cancelled out.

Removing didQuery lets resolveFullStateAuthor return string | undefined
and getPruneProtectedIds return string[], instead of both carrying a
tuple purely to feed the counter.

uploadDbRoundtrips and the batch path's own counter are untouched.

* test(sync): assert the committed store title in renameTask

The focus dispatch did not fix supersync 3.1 — the shard failed again
with an identical snapshot (original title, done, rename gone), so that
diagnosis was wrong. Stop guessing at the trigger and make the helper
able to observe the thing in question.

task-title renders tmpValue, a component-local signal, in BOTH its
editing and idle branches. Every DOM assertion here therefore matches as
soon as the synthetic input event fires, whether or not an op was ever
captured — which is why two rounds of "wait for the title" changed
nothing. Read the store instead, via the __e2eTestHelpers.store hook the
timeSpent helper already uses.

This is a diagnostic as much as a fix: it splits the two remaining
explanations. If renameTask now fails, the rename never becomes an op
and the bug is in how the test drives the edit. If it passes and 3.1
still fails at the merge assertion, the op is captured and lost during
sync — a real defect, and the test is right to fail.

* test(sync): move the 3.1 disjoint-merge rewrite out to #9095

3.1 was the last red shard, and it turned out to be right: the
store-backed renameTask passes, so the rename IS captured as an op, and
the test still fails at the merge assertion — the op is committed and
then lost during sync. Filed as #9095.

That bug is pre-existing and cannot be reached by anything in this PR:
the file-based adapter is not used by SuperSync, and the server-side
author memo only engages for clocks over 20 entries where this test
carries about three. 3.1 is also the only test here that exercises
neither of this PR's fixes — it races a title change against a
move-to-done, which is conflict resolution, not retry dedup or clock
pruning. So it moves to #9095 rather than holding verified sync fixes
red.

The rest of the hardening stays: the fault injections whose globs never
matched a real endpoint, the schema-mismatch test that asserted nothing,
and the compaction suite that called an endpoint which never existed are
what actually cover the fixes here.

Restoring the old 3.1 puts a misleading test back, so it now carries a
comment saying why it proves little and where the real one lives. The
strengthened version is kept on test/issue-9095-disjoint-merge-repro.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-17 00:46:01 +02:00

1061 lines
38 KiB
TypeScript

import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
getTaskElement,
getParentTaskElement,
markTaskDone,
markSubtaskDone,
expandTask,
deleteTask,
renameTask,
startTimeTracking,
stopTimeTracking,
waitForTaskTimeSpent,
getTaskCount,
hasTaskOnClient,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import {
expectTaskVisible,
expectTaskNotVisible,
expectTaskDone,
expectTaskOnAllClients,
expectSubtaskVisible,
expectSubtaskDone,
expectSubtaskNotDone,
expectEqualTaskCount,
expectTaskCount,
} from '../../utils/supersync-assertions';
import { waitForAppReady } from '../../utils/waits';
/**
* 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:1901 with TEST_MODE=true
* - Frontend running on localhost:4242
*
* Run with: npm run e2e:supersync
*/
test.describe('@supersync SuperSync E2E', () => {
/**
* 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
*/
test('2.1 Client A creates task, Client B downloads it', async ({
browser,
baseURL,
testRunId,
}) => {
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
await expectTaskOnAllClients([clientA, clientB], taskName);
} 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
*/
test('2.2 Both clients create different tasks', async ({
browser,
baseURL,
testRunId,
}) => {
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);
// Wait for task to be fully created in store
await waitForTask(clientA.page, taskA);
// Step 2: Client A syncs (upload Task A)
await clientA.sync.syncAndWait();
console.log('[Test] Client A synced Task A');
// Step 3: Client B creates Task B
const taskB = `TaskB-${testRunId}`;
await clientB.workView.addTask(taskB);
// Wait for task to be fully created in store
await waitForTask(clientB.page, taskB);
// Step 4: Client B syncs (upload Task B, download Task A)
await clientB.sync.syncAndWait();
console.log('[Test] Client B synced (uploaded Task B, downloaded Task A)');
// Step 5: Client A syncs (download Task B)
await clientA.sync.syncAndWait();
console.log('[Test] Client A synced (downloaded Task B)');
// Wait for UI to settle after sync
await clientA.page.waitForTimeout(500);
await clientB.page.waitForTimeout(500);
// 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 expectTaskOnAllClients([clientA, clientB], taskA);
await expectTaskOnAllClients([clientA, clientB], taskB);
} 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
*/
test('1.3 Update propagates between clients', async ({
browser,
baseURL,
testRunId,
}) => {
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
await markTaskDone(clientA, taskName);
// 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
await expectTaskDone(clientA, taskName);
await expectTaskDone(clientB, taskName);
} 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
*/
test('1.4 Delete propagates between clients', async ({
browser,
baseURL,
testRunId,
}) => {
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
await deleteTask(clientA, taskName);
// 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 expectTaskNotVisible(clientA, taskName);
await expectTaskNotVisible(clientB, taskName);
} 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
*
* WEAK — do not trust this as conflict coverage. Both clients change the SAME
* field (isDone), so there is no disjoint merge to get wrong, and the only
* assertion is that the task still exists. Strengthening it to rename-vs-done
* uncovered real data loss: the rename is committed as an op and then lost in
* sync. The stronger version lives in #9095 and stays out of this PR because it
* fails for a pre-existing reason unrelated to the fixes here.
*/
test('3.1 Concurrent edits handled gracefully', async ({
browser,
baseURL,
testRunId,
}) => {
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)
await markTaskDone(clientA, taskName);
// Client B marks done too (concurrent edit)
await markTaskDone(clientB, taskName);
// 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
await expectEqualTaskCount([clientA, clientB]);
// Task should exist on both
await expectTaskOnAllClients([clientA, clientB], taskName);
} 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
*/
test('2.3 Client A creates parent, Client B creates subtask', async ({
browser,
baseURL,
testRunId,
}) => {
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
// Use a name that won't match the parent (no testRunId overlap)
const subtaskName = `ChildOfParent-${Date.now()}`;
const parentTaskB = getTaskElement(clientB, 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
await expandTask(clientA, parentTaskName);
await expandTask(clientB, parentTaskName);
// Wait for subtasks to be visible
await waitForTask(clientA.page, subtaskName);
await waitForTask(clientB.page, subtaskName);
// Verify subtask exists on both
await expectSubtaskVisible(clientA, subtaskName);
await expectSubtaskVisible(clientB, subtaskName);
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario 4.1: Complex Chain of Actions
*
* This test simulates a realistic workflow where two clients
* perform various actions in sequence, syncing between each step.
*
* Chain of operations:
* 1. Client A: Create parent task "Project X"
* 2. Sync A → Server → Sync B
* 3. Client B: Add subtask "Research" under "Project X"
* 4. Sync B → Server → Sync A
* 5. Client A: Mark "Research" as done
* 6. Client A: Add subtask "Implementation"
* 7. Sync A → Server → Sync B
* 8. Client B: Add subtask "Testing"
* 9. Client B: Mark "Testing" as done
* 10. Sync B → Server → Sync A
* 11. Verify final state matches on both clients
*
* Expected: Both clients have identical final state with:
* - Parent task "Project X"
* - "Research" (done)
* - "Implementation" (not done)
* - "Testing" (done)
*/
test('4.1 Complex chain of actions syncs correctly', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = Date.now();
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// ============ PHASE 1: Initial Setup ============
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// ============ PHASE 2: Client A Creates Initial Task ============
const parentTaskName = `ProjectX-${uniqueId}`;
await clientA.workView.addTask(parentTaskName);
console.log(`[Chain Test] Client A created task: ${parentTaskName}`);
// Sync: A → Server → B
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
// Verify B has the task
await waitForTask(clientB.page, parentTaskName);
console.log('[Chain Test] Client B received initial task');
// ============ PHASE 3: Client B Adds First Subtask ============
const subtask1Name = `Research-${uniqueId}`;
const parentTaskLocatorB = getTaskElement(clientB, parentTaskName);
await clientB.workView.addSubTask(parentTaskLocatorB, subtask1Name);
console.log(`[Chain Test] Client B added subtask: ${subtask1Name}`);
// Sync: B → Server → A
await clientB.sync.syncAndWait();
await clientA.sync.syncAndWait();
// Verify A has the parent task with subtask
await expandTask(clientA, parentTaskName);
await waitForTask(clientA.page, subtask1Name);
console.log('[Chain Test] Client A received subtask from B');
// ============ PHASE 4: Client A Marks Subtask Done and Adds Another ============
await markSubtaskDone(clientA, subtask1Name);
console.log(`[Chain Test] Client A marked ${subtask1Name} as done`);
const subtask2Name = `Implementation-${uniqueId}`;
const parentTaskLocatorA = getTaskElement(clientA, parentTaskName);
await clientA.workView.addSubTask(parentTaskLocatorA, subtask2Name);
console.log(`[Chain Test] Client A added subtask: ${subtask2Name}`);
// Sync: A → Server → B
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
// Verify B has the updates
await expandTask(clientB, parentTaskName);
await waitForTask(clientB.page, subtask2Name);
console.log('[Chain Test] Client B received done status and new subtask');
// ============ PHASE 5: Client B Adds Third Subtask and Marks It Done ============
const subtask3Name = `Testing-${uniqueId}`;
await clientB.workView.addSubTask(
getTaskElement(clientB, parentTaskName),
subtask3Name,
);
console.log(`[Chain Test] Client B added subtask: ${subtask3Name}`);
await markSubtaskDone(clientB, subtask3Name);
console.log(`[Chain Test] Client B marked ${subtask3Name} as done`);
// Sync: B → Server → A
await clientB.sync.syncAndWait();
await clientA.sync.syncAndWait();
// ============ PHASE 6: Final Verification ============
console.log('[Chain Test] Verifying final state...');
// Both clients should have the parent task
const finalParentA = getParentTaskElement(clientA, parentTaskName);
const finalParentB = getParentTaskElement(clientB, parentTaskName);
await expect(finalParentA).toBeVisible({ timeout: 10000 });
await expect(finalParentB).toBeVisible({ timeout: 10000 });
// Expand parents to see subtasks
await expandTask(clientA, parentTaskName);
await expandTask(clientB, parentTaskName);
// Verify all three subtasks exist on both clients
// Research (done - marked by Client A in Phase 4)
await expectSubtaskDone(clientA, subtask1Name);
await expectSubtaskDone(clientB, subtask1Name);
// Implementation (not done)
await expectSubtaskNotDone(clientA, subtask2Name);
await expectSubtaskNotDone(clientB, subtask2Name);
// Testing (done - marked by Client B in Phase 5)
await expectSubtaskDone(clientA, subtask3Name);
await expectSubtaskDone(clientB, subtask3Name);
// Count total tasks should match
await expectEqualTaskCount([clientA, clientB]);
await expectTaskCount(clientA, 4); // 1 parent + 3 subtasks
console.log('[Chain Test] ✓ All verifications passed!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario 5.1: Task Archiving and Worklog Sync
*
* Tests that archived tasks appear in worklog on both clients after sync.
*
* Setup: Client A and B with shared account
*
* Actions:
* 1. Client A creates tasks and marks them done
* 2. Client A syncs
* 3. Client B syncs (receives tasks)
* 4. Client B archives tasks via "Finish Day"
* 5. Client B syncs (uploads archive operation)
* 6. Client A syncs (receives archive)
* 7. Both clients verify tasks appear in worklog
*
* Expected:
* - Both clients show archived tasks in worklog
* - Task titles visible in worklog entries
*/
test('5.1 Archived tasks appear in worklog on both clients', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = Date.now();
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
// Create shared test user
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// ============ PHASE 1: Client A Creates and Completes Tasks ============
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
// Create two tasks
const task1Name = `Archive-Task1-${uniqueId}`;
const task2Name = `Archive-Task2-${uniqueId}`;
await clientA.workView.addTask(task1Name);
console.log(`[Archive Test] Client A created task: ${task1Name}`);
await clientA.workView.addTask(task2Name);
console.log(`[Archive Test] Client A created task: ${task2Name}`);
// Mark both tasks as done
await markTaskDone(clientA, task1Name);
console.log(`[Archive Test] Client A marked ${task1Name} as done`);
await markTaskDone(clientA, task2Name);
console.log(`[Archive Test] Client A marked ${task2Name} as done`);
// ============ PHASE 2: Sync Tasks to Server ============
await clientA.sync.syncAndWait();
console.log('[Archive Test] Client A synced tasks');
// ============ PHASE 3: Client B Downloads Tasks ============
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.syncAndWait();
console.log('[Archive Test] Client B synced (downloaded tasks)');
// Verify Client B has the tasks
await waitForTask(clientB.page, task1Name);
await waitForTask(clientB.page, task2Name);
console.log('[Archive Test] Client B received both tasks');
// ============ PHASE 4: Client B Archives Tasks via Finish Day ============
// Click "Finish Day" button to go to daily summary
const finishDayBtn = clientB.page.locator('.e2e-finish-day');
await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 });
await finishDayBtn.click();
console.log('[Archive Test] Client B clicked Finish Day');
// Wait for daily summary page
await clientB.page.waitForURL(/daily-summary/, { timeout: 10000 });
await clientB.page.waitForLoadState('networkidle');
console.log('[Archive Test] Client B on daily summary page');
// Click the "Save and go home" button to archive tasks
const saveAndGoHomeBtn = clientB.page.locator(
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
);
await saveAndGoHomeBtn.waitFor({ state: 'visible', timeout: 10000 });
await saveAndGoHomeBtn.click();
console.log('[Archive Test] Client B clicked Save and go home (archiving)');
// Wait for navigation back to work view.
// Use negative lookahead to avoid matching /tag/TODAY/daily-summary prematurely.
await clientB.page.waitForURL(/(tag\/TODAY(?!\/daily-summary))/, {
timeout: 10000,
});
await clientB.page.waitForLoadState('networkidle');
console.log('[Archive Test] Client B back on work view after archiving');
// ============ PHASE 5: Sync Archive Operation ============
await clientB.sync.syncAndWait();
console.log('[Archive Test] Client B synced (uploaded archive)');
// Client A syncs to receive archive
await clientA.sync.syncAndWait();
console.log('[Archive Test] Client A synced (downloaded archive)');
// ============ PHASE 6: Verify Worklog on Both Clients ============
// Navigate Client A to worklog
await clientA.page.goto('/#/tag/TODAY/history');
await clientA.page.waitForLoadState('networkidle');
await clientA.page.waitForSelector('history', { timeout: 10000 });
console.log('[Archive Test] Client A navigated to worklog');
// Navigate Client B to worklog
await clientB.page.goto('/#/tag/TODAY/history');
await clientB.page.waitForLoadState('networkidle');
await clientB.page.waitForSelector('history', { timeout: 10000 });
console.log('[Archive Test] Client B navigated to worklog');
// Expand the current day's worklog to see tasks
// Click on the week row to expand it
const expandWorklogA = async (): Promise<void> => {
const weekRow = clientA.page.locator('.week-row').first();
if (await weekRow.isVisible()) {
await weekRow.click();
await clientA.page.waitForTimeout(500);
}
};
const expandWorklogB = async (): Promise<void> => {
const weekRow = clientB.page.locator('.week-row').first();
if (await weekRow.isVisible()) {
await weekRow.click();
await clientB.page.waitForTimeout(500);
}
};
await expandWorklogA();
await expandWorklogB();
// Verify tasks appear in worklog on both clients
// Tasks are shown in .task-title within the worklog table
const task1InWorklogA = clientA.page.locator(
`.task-summary-table .task-title:has-text("${task1Name}")`,
);
const task2InWorklogA = clientA.page.locator(
`.task-summary-table .task-title:has-text("${task2Name}")`,
);
const task1InWorklogB = clientB.page.locator(
`.task-summary-table .task-title:has-text("${task1Name}")`,
);
const task2InWorklogB = clientB.page.locator(
`.task-summary-table .task-title:has-text("${task2Name}")`,
);
await expect(task1InWorklogA).toBeVisible({ timeout: 10000 });
await expect(task2InWorklogA).toBeVisible({ timeout: 10000 });
console.log('[Archive Test] Client A worklog has both tasks');
await expect(task1InWorklogB).toBeVisible({ timeout: 10000 });
await expect(task2InWorklogB).toBeVisible({ timeout: 10000 });
console.log('[Archive Test] Client B worklog has both tasks');
console.log('[Archive Test] ✓ All verifications passed!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario 6.1: Time Tracking Sync
*
* Tests that time tracked on one client syncs to another client.
*
* Setup: Client A and B with shared account
*
* Actions:
* 1. Client A creates a task
* 2. Client A starts time tracking on the task
* 3. Wait for time to accumulate (3 seconds)
* 4. Client A stops time tracking
* 5. Client A syncs
* 6. Client B syncs
* 7. Verify Client B sees the tracked time
*
* Expected:
* - Client B shows timeSpent > 0 for the task
* - Both clients have matching time values
*/
test('6.1 Time tracking syncs between clients', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = Date.now();
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
// Create shared test user
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// ============ PHASE 1: Client A Creates Task ============
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
const taskName = `TimeTrack-${uniqueId}`;
await clientA.workView.addTask(taskName);
console.log(`[TimeTrack Test] Client A created task: ${taskName}`);
// ============ PHASE 2: Start Time Tracking ============
await startTimeTracking(clientA, taskName);
console.log('[TimeTrack Test] Client A started time tracking');
// Verify tracking started
const taskLocatorA = getTaskElement(clientA, taskName);
const playIndicator = taskLocatorA.locator('.play-indicator');
await expect(playIndicator).toBeVisible({ timeout: 5000 });
console.log('[TimeTrack Test] Time tracking active');
// ============ PHASE 3: Accumulate Time ============
// Wait for the first global tracking tick before stopping. A fixed sleep can
// race the app timer under CI load and leave the task with 0 recorded time.
await waitForTaskTimeSpent(clientA, taskName, 10000);
console.log('[TimeTrack Test] Time accumulated');
// ============ PHASE 4: Stop Time Tracking ============
await stopTimeTracking(clientA, taskName);
console.log('[TimeTrack Test] Client A stopped time tracking');
// Wait for tracking to stop
await expect(playIndicator).not.toBeVisible({ timeout: 5000 });
// Verify time was recorded on Client A via persisted state. The row can
// hide the visual time while hover controls are mounted, and sub-minute
// values render as "-" in the UI.
const timeSpentA = await waitForTaskTimeSpent(clientA, taskName, 5000);
console.log(`[TimeTrack Test] Client A recorded time: ${timeSpentA}ms`);
// ============ PHASE 5: Sync to Server ============
await clientA.sync.syncAndWait();
console.log('[TimeTrack Test] Client A synced');
// ============ PHASE 6: Client B Downloads ============
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Add delay to ensure any auto-sync from setup has time to start/finish (reduced from 2000ms)
// or to avoid race conditions with "Sync already in progress"
await clientB.page.waitForTimeout(500);
await clientB.sync.syncAndWait();
console.log('[TimeTrack Test] Client B synced');
// Reload to ensure UI reflects DB state (in case of sync UI glitch)
// Use goto instead of reload - more reliable with service workers
await clientB.page.goto(clientB.page.url(), {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await waitForAppReady(clientB.page);
// ============ PHASE 7: Verify Time on Client B ============
// Wait for task to appear
try {
await waitForTask(clientB.page, taskName);
} catch (e) {
const tasks = await clientB.page.locator('task .task-title').allTextContents();
console.log('[TimeTrack Test] Client B tasks found:', tasks);
throw e;
}
await expectTaskVisible(clientB, taskName);
// Verify time synced to Client B
const timeSpentB = await waitForTaskTimeSpent(clientB, taskName, 10000);
console.log(`[TimeTrack Test] Client B shows time: ${timeSpentB}ms`);
// Both clients should have the same synced timeSpent value
expect(timeSpentB).toBe(timeSpentA);
console.log('[TimeTrack Test] Time values match on both clients');
console.log('[TimeTrack Test] ✓ All verifications passed!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario 7.1: Three-Client Eventual Consistency
*
* Tests that three clients all converge to the same state after
* concurrent operations and multiple sync rounds. This simulates
* a real-world scenario with phone, laptop, and desktop.
*
* Setup: Clients A, B, C with shared account
*
* Timeline:
* 1. Client A creates Task-1, syncs
* 2. Client B syncs (gets Task-1), creates Task-2
* 3. Client C syncs (gets Task-1), creates Task-3, renames Task-1
* 4. Client B syncs (uploads Task-2)
* 5. Client C syncs (uploads Task-3 + rename, downloads Task-2)
* 6. Client A syncs (downloads Task-2, Task-3, rename)
* 7. Client B syncs (downloads Task-3, rename)
* 8. All three clients should have identical state
*
* Expected:
* - All clients have Task-1 (renamed), Task-2, Task-3
* - No data loss from concurrent operations
* - System achieves eventual consistency
*/
test('7.1 Three clients achieve eventual consistency', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = Date.now();
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
let clientC: SimulatedE2EClient | null = null;
try {
// Create shared test user
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Task names
const task1Name = `Task1-${uniqueId}`;
const task1Renamed = `Task1-Renamed-${uniqueId}`;
const task2Name = `Task2-${uniqueId}`;
const task3Name = `Task3-${uniqueId}`;
// ============ PHASE 1: Client A Creates Task-1 ============
console.log('[3-Client Test] Phase 1: Client A creates Task-1');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
await clientA.workView.addTask(task1Name);
console.log(`[3-Client Test] Client A created: ${task1Name}`);
await clientA.sync.syncAndWait();
console.log('[3-Client Test] Client A synced');
// ============ PHASE 2: Client B Gets Task-1, Creates Task-2 ============
console.log('[3-Client Test] Phase 2: Client B syncs and creates Task-2');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.syncAndWait();
// Verify B has Task-1
await waitForTask(clientB.page, task1Name);
console.log('[3-Client Test] Client B received Task-1');
// B creates Task-2
await clientB.workView.addTask(task2Name);
console.log(`[3-Client Test] Client B created: ${task2Name}`);
// ============ PHASE 3: Client C Gets Task-1, Creates Task-3, Renames Task-1 ============
console.log(
'[3-Client Test] Phase 3: Client C syncs, creates Task-3, renames Task-1',
);
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
await clientC.sync.setupSuperSync(syncConfig);
await clientC.sync.syncAndWait();
// Verify C has Task-1 (but NOT Task-2 yet - B hasn't synced)
await waitForTask(clientC.page, task1Name);
console.log('[3-Client Test] Client C received Task-1');
// C creates Task-3
await clientC.workView.addTask(task3Name);
console.log(`[3-Client Test] Client C created: ${task3Name}`);
// C renames Task-1
await renameTask(clientC, task1Name, task1Renamed);
console.log(`[3-Client Test] Client C renamed Task-1 to: ${task1Renamed}`);
// ============ PHASE 4: Client B Syncs (Uploads Task-2) ============
console.log('[3-Client Test] Phase 4: Client B syncs (uploads Task-2)');
await clientB.sync.syncAndWait();
console.log('[3-Client Test] Client B synced');
// ============ PHASE 5: Client C Syncs (Uploads Task-3 + Rename, Downloads Task-2) ============
console.log('[3-Client Test] Phase 5: Client C syncs');
await clientC.sync.syncAndWait();
// Wait for DOM to settle after sync
await clientC.page.waitForLoadState('domcontentloaded');
// Verify C now has Task-2
await waitForTask(clientC.page, task2Name);
console.log('[3-Client Test] Client C received Task-2');
// ============ PHASE 6: Client A Syncs (Downloads Task-2, Task-3, Rename) ============
console.log('[3-Client Test] Phase 6: Client A syncs');
await clientA.sync.syncAndWait();
// Wait for DOM to settle after sync
await clientA.page.waitForLoadState('domcontentloaded');
// Verify A has all tasks with correct state
await waitForTask(clientA.page, task1Renamed);
await waitForTask(clientA.page, task2Name);
await waitForTask(clientA.page, task3Name);
console.log('[3-Client Test] Client A received all updates');
// ============ PHASE 7: Client B Syncs (Downloads Task-3, Rename) ============
console.log('[3-Client Test] Phase 7: Client B syncs');
await clientB.sync.syncAndWait();
// Wait for DOM to settle after sync
await clientB.page.waitForLoadState('domcontentloaded');
await clientB.page.waitForTimeout(500);
// Verify B has all tasks with correct state
await waitForTask(clientB.page, task1Renamed);
await waitForTask(clientB.page, task3Name);
console.log('[3-Client Test] Client B received all updates');
// ============ PHASE 8: Final Verification - All Clients Identical ============
console.log('[3-Client Test] Phase 8: Verifying eventual consistency');
const countA = await getTaskCount(clientA);
const countB = await getTaskCount(clientB);
const countC = await getTaskCount(clientC);
console.log(`[3-Client Test] Client A task count: ${countA}`);
console.log(`[3-Client Test] Client B task count: ${countB}`);
console.log(`[3-Client Test] Client C task count: ${countC}`);
// All clients should have exactly 3 tasks
await expectTaskCount(clientA, 3);
await expectTaskCount(clientB, 3);
await expectTaskCount(clientC, 3);
// Verify renamed Task-1 exists on all clients
expect(await hasTaskOnClient(clientA, task1Renamed)).toBe(true);
expect(await hasTaskOnClient(clientB, task1Renamed)).toBe(true);
expect(await hasTaskOnClient(clientC, task1Renamed)).toBe(true);
console.log('[3-Client Test] Task-1 (renamed) exists on all clients');
// Verify Task-2 exists on all clients
expect(await hasTaskOnClient(clientA, task2Name)).toBe(true);
expect(await hasTaskOnClient(clientB, task2Name)).toBe(true);
expect(await hasTaskOnClient(clientC, task2Name)).toBe(true);
console.log('[3-Client Test] Task-2 exists on all clients');
// Verify Task-3 exists on all clients
expect(await hasTaskOnClient(clientA, task3Name)).toBe(true);
expect(await hasTaskOnClient(clientB, task3Name)).toBe(true);
expect(await hasTaskOnClient(clientC, task3Name)).toBe(true);
console.log('[3-Client Test] Task-3 exists on all clients');
// Verify original Task-1 name no longer exists (it was renamed)
// Skip checking on C since C did the rename and might still have the old element in DOM
expect(await hasTaskOnClient(clientA, task1Name)).toBe(false);
expect(await hasTaskOnClient(clientB, task1Name)).toBe(false);
console.log('[3-Client Test] ✓ All three clients have identical state!');
console.log('[3-Client Test] ✓ Eventual consistency achieved!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
if (clientC) await closeClient(clientC);
}
});
});