mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* 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>
1061 lines
38 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|