super-productivity/e2e/tests/sync/supersync-server-migration.spec.ts
Johannes Millan 44e0762fba
Feat/issue 7330 bc1a3f (#7522)
* fix(sync): strip same-batch-archived task IDs from TAG/PROJECT LWW payloads

Issue #7330. lwwUpdateMetaReducer's orphan filter only sees taskState as it
is when each op runs. A TAG LWW Update applied before its sibling archive
op in the same bulk batch escapes the filter, leaving TODAY_TAG (or any
tag/project) referencing a task the very next op removes — user-visible
as "archived tasks reappear in today's view" on hibernate-wake.

bulkOperationsMetaReducer already collects archivingOrDeletingEntityIds
for the wholesale TASK LWW Update skip; this commit reuses that set to
pre-clean taskIds (and PROJECT-only backlogTaskIds) on TAG/PROJECT LWW
Update payloads before convertOpToAction. Cross-batch ordering is not
addressed — see open issue.

* fix(sync): surface post-sync validation failure as ERROR, not IN_SYNC

Issue #7330. The reporter saw "State validation failed after sync. Some
data may be inconsistent." while sync simultaneously reported status
IN_SYNC. validateAfterSync's boolean was discarded above the snackbar
layer (#6571 only added the snackbar; nothing flowed up to the status
pipeline).

Plumb the boolean through:
- validateAfterSync: void -> boolean
- _validateAndRepairAfterResolution: void -> boolean
- autoResolveConflictsLWW + processRemoteOps: add validationFailed
- DownloadOutcome.ops_processed + UploadOutcome.completed: add
  validationFailed
- sync-wrapper: if either result.validationFailed, setSyncStatus('ERROR')
  and return HANDLED_ERROR before the IN_SYNC mark

Tests: assert validateAfterSync returns the boolean, assert
processRemoteOps surfaces validationFailed, assert sync-wrapper sets
ERROR (not IN_SYNC) when either download or upload reports
validationFailed.

* fix(sync): always set top-level id on LWW Update payloads

lwwUpdateMetaReducer drops LWW Updates whose payload lacks a top-level
id ("Entity data has no id"). Two creation paths could produce such
payloads:

- createLWWUpdateOp passed entityState through unchanged, so a malformed
  selector result silently produced an unusable LWW op.
- _convertToLWWUpdatesIfNeeded built mergedEntity by spreading baseEntity
  and updateChanges; when baseEntity lacked id (corrupt DELETE payload)
  and updateChanges had id stripped, the merge had no id either.

Both sites now force the canonical entityId onto the payload. (#7330)

* fix(sync): close 3 secondary gaps in #7330 fixes

Multi-agent review surfaced three additional code paths mirroring the
same defects we fixed at the primary path:

G1: bulkOperationsMetaReducer's pre-scan only saw op.entityIds /
op.entityId for archive/delete ops. moveToArchive declares only
top-level task IDs, but the reducer cascades to subtasks via
[t.id, ...t.subTasks.map(st => st.id)]. deleteTask cascades the same
way via taskToDelete.subTaskIds. Same-batch TAG/PROJECT LWW Updates
referencing those subtasks slipped through the strip. Add
collectCascadedSubTaskIds to harvest subtask IDs from archive/delete
payloads.

G2: SyncWrapperService's LWW re-upload retry loop captured only
reuploadResult.localWinOpsCreated, throwing away validationFailed.
A retry-pass piggybacked download with failing post-sync validation
still reported IN_SYNC. Track reuploadValidationFailed across
retries and OR into the gate before IN_SYNC.

G3: OperationLogSyncService's downloadCallback (used by the
rejected-op handler) converted the download outcome into
DownloadResultForRejection, which has no validationFailed field.
A nested download triggering validation failure was lost before
uploadPendingOps returned. Route the boolean via the existing
piggybackValidationFailed flag in the closure scope.

Also fixes one cosmetic logging field (reuploadValidationFailed
included alongside download/upload in the ERROR log).

* fix(sync): defense-in-depth id derivation in lwwUpdateMetaReducer

Multi-agent review surfaced two follow-up improvements for #7330:

1. Consumer-side id fallback: a future LWW Update producer that
   forgets to backfill payload.id would silently regress the
   "Entity data has no id" bail. Recover from action.meta.entityId,
   which convertOpToAction always populates from the canonical
   op.entityId. Now a single consumer guards the whole class of
   missing-id producer regressions.

2. Tighten 7 conflict-resolution.service.spec assertions from
   jasmine.objectContaining({ localWinOpsCreated: N }) back to
   strict toEqual({...}). The loosening (added when validationFailed
   was introduced) hid future stray-field regressions; the validation
   path now returns a deterministic shape per code branch, so we can
   assert it.

* fix(sync): close issues found in follow-up review (#7521)

Multi-agent review of the prior #7330 follow-up commits surfaced four
real issues. All addressed here:

CRITICAL — singleton id pollution at producer + consumer
  Singletons use entityId='*' as a sentinel. Both the producer-side
  payload-id backfill (createLWWUpdateOp + _convertToLWWUpdatesIfNeeded)
  and the consumer-side meta.entityId fallback in lwwUpdateMetaReducer
  injected `id: '*'` into payloads. For singletons (GLOBAL_CONFIG,
  app-state, time-tracking) this leaks a synthetic `id: '*'` field
  into singleton feature state when the reducer spreads entityData.
  Gate id-injection on entityId \!== '*' at all three sites and move
  the consumer fallback past the singleton branch.

WARNING — null-safety in collectCascadedSubTaskIds
  `'actionPayload' in payload` check could pass for malformed
  payload {actionPayload: null}, then crash on the next property
  access. Tighten to a typeof check.

WARNING — retries-exhausted path bypasses validationFailed gate
  When the LWW re-upload loop exits via MAX retries with pending
  ops still > 0, sync-wrapper used UNKNOWN_OR_CHANGED without
  consulting reuploadValidationFailed. Validation failure during
  a retry pass is now elevated to ERROR — a more honest signal
  than UNKNOWN_OR_CHANGED, and consistent with the user-visible
  "State validation failed" snackbar they already saw.

SUGGESTION — devError on consumer fallback
  Producer regressions should scream loudly in dev, not slip
  through silently. The fallback now emits devError + applies
  the recovery, so a future LWW producer that forgets payload.id
  surfaces in dev rather than only-when-the-bail-fires.

* fix(sync): close validationFailed gap in USE_REMOTE and DELETE_MULTIPLE cascade

Three issues found by multi-agent review of the #7330 sync fixes:

1. forceDownloadRemoteState() discarded the validationFailed result
   from processRemoteOps. A USE_REMOTE conflict-resolution path that
   applied corrupt remote state would still mark sync IN_SYNC despite
   the snackbar. Now returns { validationFailed } and propagates it
   through _handleSyncImportConflict and DownloadOutcome.no_new_ops
   to SyncWrapperService, plus the two direct callers (manual force
   download and first-sync conflict resolution).

2. sync-wrapper retry-exhaustion priority: when LWW retries exhausted
   AND the initial download/upload had reported validationFailed, the
   wrapper returned UNKNOWN_OR_CHANGED instead of ERROR. The hoisted
   downloadValidationFailed/uploadValidationFailed are now checked
   inside the retry-exhaustion branch alongside reuploadValidationFailed.

3. bulk-hydration pre-scan handled DELETE_MULTIPLE in the loop but the
   collectCascadedSubTaskIds helper early-returned for that action type.
   Since deleteTasks payload only carries flat taskIds, subtask cascade
   must be derived from initial batch state (mirroring what
   handleDeleteTasks does at apply time). Co-batched TAG/PROJECT LWW
   Updates referencing those subtasks could otherwise still leak.

Adds regression tests for each path.

* refactor(sync): centralize LWW id backfill, extract singleton sentinel constant

Follow-up simplifications from the multi-agent review of #7330 fixes.

- Extract SINGLETON_ENTITY_ID = '*' + isSingletonEntityId() helper in
  entity-registry.ts. Replaces the bare '*' literal across
  conflict-resolution.service.ts, lww-update.meta-reducer.ts,
  validate-operation-payload.ts, operation-log-recovery.service.ts,
  and operation-log-migration.service.ts. (S2)

- Add LWW Update payload.id backfill at the apply boundary in
  convertOpToAction. Every applied LWW op now has its top-level id set
  from op.entityId (excluding singletons). The redundant defense-in-depth
  block in lwwUpdateMetaReducer (which derived id from meta.entityId) is
  removed — the converter is now the single chokepoint, with producers
  (createLWWUpdateOp, _convertToLWWUpdatesIfNeeded) keeping their
  enforcement for an explicit on-disk shape. (S1)

- stripBatchArchivedTaskIdsFromLwwPayload: drop non-string entries
  rather than preserving them, and skip the cleaned[] allocation when
  no rewrite is needed (two-pass with early exit on first hit). (S5+S7)

* refactor(sync): extract bulk-archive util, unify orphan-task filters, type validation propagation

Continued review follow-ups for #7330. Behavior unchanged.

- Move payload-archaeology helpers (collectCascadedSubTaskIds,
  stripBatchArchivedTaskIdsFromLwwPayload) out of bulk-hydration.meta-reducer
  into a co-located bulk-archive-filter.util.ts. The meta-reducer body
  drops back to its dispatcher role; the helpers gain an independent
  test surface and clearer naming. (S3)

- Add a shared filterTaskIdArraysFromTagOrProjectPayload helper. Both
  filterOrphanedTaskIdsFromEntityData (lww-update.meta-reducer; predicate:
  not in live state) and stripBatchArchivedTaskIdsFromLwwPayload (bulk
  meta-reducer; predicate: in same-batch archive set) now wrap it. The
  two callers run at different layers because their predicates resolve
  at different times — the shared helper is the array-walking +
  payload-cloning + warn-logging core. (S4)

- Replace the closure-captured piggybackValidationFailed boolean in
  uploadPendingOps with typed plumbing: validationFailed flows through
  DownloadResultForRejection and RejectionHandlingResult. The closure
  smuggle was fragile; the typed return is grep-able and survives
  refactors. The wider session-latch refactor proposed in review is
  deferred — current typed plumbing is well-tested. (S6 minimal)

* refactor(sync): replace validationFailed plumbing with session latch + integration test

Final review follow-up for #7330. Behavior unchanged; surface area shrinks.

The validation-failed signal previously rode through 7 typed boundaries:
DownloadOutcome.{ops_processed,no_new_ops}.validationFailed,
UploadOutcome.completed.validationFailed, processRemoteOps return,
forceDownloadRemoteState return, _handleSyncImportConflict return,
DownloadResultForRejection.validationFailed, RejectionHandlingResult
.validationFailed, plus a closure-captured piggybackValidationFailed in
uploadPendingOps. Threading it correctly required every call site to
remember to forward the boolean — a new variant or path that forgot
would silently let IN_SYNC ride over corrupt state.

- Add SyncSessionValidationService — a no-dep singleton with reset() /
  setFailed() / hasFailed(). RemoteOpsProcessingService.validateAfterSync
  and ConflictResolutionService.autoResolveConflictsLWW flip the latch
  when validation reports corruption. SyncWrapperService resets it at
  every entry point (sync(), _forceDownload(), resolveSyncConflict
  USE_REMOTE) and reads it once before deciding IN_SYNC vs ERROR.

- Drop validationFailed from DownloadOutcome variants, UploadOutcome,
  DownloadResultForRejection, RejectionHandlingResult, processRemoteOps
  return, autoResolveConflictsLWW return, forceDownloadRemoteState
  return, and _handleSyncImportConflict return. ~120 LOC of plumbing
  collapsed to single latch reads.

- Add a focused integration test
  (post-sync-validation.integration.spec.ts) that wires real
  RemoteOpsProcessingService + ConflictResolutionService against a
  stubbed ValidateStateService. Asserts the latch flips on validation
  failure and survives discarded-boolean callers — catching plumbing
  regressions a future code path could otherwise sneak past.

Net change vs current branch: ~120 LOC deleted from production sources,
~270 LOC added in new service + tests (latch unit tests + integration).

* fix(sync): close 3 latch-bypass gaps from codex review

1. SyncHydrationService.hydrateFromRemoteSync runs validateAndRepair()
   directly and previously dropped the result on failure. Snapshot
   hydration (file-based providers, USE_REMOTE force-download) would
   silently accept corrupt remote data — the wrapper would see a clean
   latch and report IN_SYNC. Now flips
   SyncSessionValidationService.setFailed() when isValid is false.

2. WsTriggeredDownloadService called downloadRemoteOps() outside the
   wrapper session contract, so any validation failure during a realtime
   apply was either dropped (next sync()'s reset cleared it) or leaked
   into the next session. The service now resets the latch up-front and
   reads it after the download, surfacing failures as
   setSyncStatus('ERROR').

3. convertOpToAction only backfilled payload.id when missing. A
   malformed/older remote LWW op with payload.id \!= op.entityId would
   slip through and update the WRONG entity in lwwUpdateMetaReducer
   (which trusts entityData.id). Now forces id from op.entityId for
   non-singleton LWW ops, making "entityId is canonical" a hard
   invariant at the apply boundary.

Adds regression tests for each: integration test for the snapshot path,
two new tests on ws-triggered-download (latch flip → ERROR; latch reset
between sessions), and a converter test for the entityId-mismatch case.

* test(sync): unstick three failing supersync e2e tests

Three independent flakes surfaced in the same run; root-caused from
playwright traces:

- daily-summary: time-estimate row icon was renamed timer→hourglass_empty
  on master (eca8d211a) but the selector was never updated on this branch.
  Cherry-pick of master's 4be835017 selector update.

- multi-migration: 120s test budget is too tight for 3 sequential
  setupSuperSync + 4 syncAndWait cycles under parallel @supersync load.
  Trace shows test reaches the post-sync 1.5s grace wait — Client B has
  already received all 3 tasks — and times out there. Bumped just this
  test to 180s.

- lww-conflict notification: done-toggle dispatches updateTask
  asynchronously; without an explicit wait, sync runs as a no-op (latch
  trace shows uploaded=0, status=IN_SYNC at 2459705) and the queued
  updateTask lands ~13ms later, flipping hasNoPendingOps back and hiding
  the check icon → syncCheckIcon waitFor times out. Added the same
  expect(...).toHaveClass(/isDone/) wait that the file's other LWW tests
  (lines 83, 376, 389) already use.

* refactor(sync): self-enforcing session-validation contract via withSession()

The previous SyncSessionValidationService API was reset() / setFailed() /
hasFailed() with a doc-comment contract: "every sync entry point must call
reset() before doing work." A new entry point added later (e.g., a
background download path) that forgot the reset would inherit a
leaked-failed latch from a prior session, and #7330's IN_SYNC-vs-ERROR
decision would misfire silently. The maintenance hazard was the largest
reason this code wasn't self-protecting.

Replace with a callback API:

  withSession<T>(work: () => Promise<T>): Promise<T>

Resets the latch at entry, marks a session active for the duration of
work, clears the marker on completion or error. setFailed() and reset()
log a noisy SyncLog.err if called outside an active session — a runtime
guard for "validation fired without an entry point opening a session."
Nested withSession() calls are detected and run in the outer session's
context (no inner reset clobbers outer state).

Three production entry points migrated:
- SyncWrapperService._sync() — body extracted to _syncBody(providerId)
  to avoid a 400-line indent diff
- SyncWrapperService._forceDownload()
- WsTriggeredDownloadService._downloadOps()

The fourth caller of reset() — the USE_REMOTE branch in
_handleLocalDataConflict — keeps using reset(), now correctly within the
session opened by _sync()'s withSession. It's a sub-scope re-scoping
inside an existing session, not a top-level entry point. The new
reset() docs make this distinction explicit.

Test surface:
- sync-session-validation.service.spec.ts rewritten: 12 tests cover the
  callback semantics, nested-session guard, and outside-session warnings.
- post-sync-validation.integration.spec.ts wraps each scenario in
  withSession() (mirrors production); replaces the old "reset() between
  sessions" test with one that asserts withSession() entry resets state.
- sync-wrapper.service.spec.ts unchanged — its tests fire setFailed()
  inside mocked download/upload callbacks, which now run inside _sync()'s
  session. 107/107 still pass.
- WsTriggeredDownloadService spec: latch.reset() in setup → _resetForTest()
  to avoid the new outside-session warning; the "stale latch" test seeds
  internal state via the test-only helper.

reset() and setFailed() are still public — they're load-bearing for
the USE_REMOTE sub-scope and for validation services that need to flip
the latch from inside session-wrapped flows. The contract is now:
top-level entry points use withSession; validation sites use setFailed
unchanged; only USE_REMOTE recovery uses reset.

#7330

* fix(sync): warn when convertOpToAction rewrites a mismatched payload.id

The id-rewrite guard added in 41b47be4 silently fixes producer/wire bugs
(an LWW op whose payload.id disagrees with op.entityId would otherwise
update the wrong entity in lwwUpdateMetaReducer). Correct in direction,
but invisible — if the assumption that "no legitimate code path produces
mismatched ops" ever breaks, we'd only learn from a user-reported corrupt
entity.

Log a SyncLog.warn with ids only (actionType, entityType, entityId,
payloadId) when the rewrite fires. Never log payload content — op log is
exportable and #7330 already caused us to audit that surface for user
data leaks.

Two new tests: warning fires with the expected id pair on mismatch; no
warning on the happy path. Sanity assertion verifies payload content
(title) doesn't appear in the log call args.

#7330

* test(sync): static check enumerates withSession() entry-point allow-list

CI-time guard for the latch maintenance hazard: greps the eight production
sync sources for `.withSession(` callers and asserts the count matches an
explicit allow-list (3 today: _sync, _forceDownload, _downloadOps).

Brittle on purpose. A future contributor adding a 5th sync entry point
will see this fail and have to read the contract in
sync-session-validation.service.ts before updating ALLOWED_ENTRY_POINTS.
That's the friction the runtime guards (the new withSession callback
API + outside-session warnings) can't deliver on their own — they catch
forgotten resets, but not "added a new top-level entry point without
considering whether it's a session boundary at all."

Wired into `npm run lint` via a new lint:sync-sessions step. Tried a
Karma spec first, but Karma doesn't serve the workspace at /base/ paths
without explicit config — a Node script via existing tools/ infrastructure
is simpler and runs the same in CI.

Verified by appending a fake 4th caller to operation-log-sync.service.ts
and confirming the script exits 1 with a contributor-friendly message
pointing at the contract file. Reverted before this commit.

#7330

* fix(sync): wrap ImmediateUploadService._performUpload in withSession()

Fourth #7330 entry point. uploadPendingOps() processes piggybacked
remote ops, which run validateAfterSync(); on corruption, validation
flips the SyncSessionValidationService latch. Without an explicit
withSession() wrapper here, the latch flip would either fire outside
any session (logged as a contract violation, dropped by the next
normal sync's reset) or — worse — go unread while _performUpload
claimed IN_SYNC based purely on result.uploadedCount. That reproduces
the exact #7330 surface on the immediate-upload path.

Wrap _performUpload's body in latch.withSession(...) and read
hasFailed() before any IN_SYNC / deferred-checkmark decision. On
failure (including a thrown upload after the latch flipped) emit
ERROR; transient errors with no latch flip remain silent.

Allow-list: ImmediateUploadService._performUpload added to
tools/check-sync-session-entry-points.js (now 4 entry points).

Tests: 5 new specs cover failure during piggybacked-op processing,
clean upload, LWW re-upload pass, latch reset between sessions, and
upload-throws-after-latch-flip.

Plan: docs/plans/2026-05-08-sync-run-service-refactor.md proposes a
type-enforced runner to replace the contract+lint pair. Deferred —
this PR closes the user-visible bug; the runner refactor is
value-over-time.

* chore(sync): drop withSession() entry-point lint check + deferred runner plan

The static check enumerates withSession() *callers* and asserts the set
matches an allow-list. It catches "added a withSession call without
updating the list" but not the inverse — "added a sync entry point
that should call withSession() but doesn't." _performUpload() was the
existence proof that the lint was silent on the actual failure mode.
Net: false confidence + maintenance churn for a problem nobody has.
The 4-entry-point contract is small enough for code review.

The deferred runner-refactor plan doc proposes a SyncRunService that
mints a SyncRunContext to enforce the contract via types. Two reviews
converged on "don't do it":
  - net more code (re-introduces the typed plumbing the latch was
    chosen to avoid in b3cbdbd41),
  - 3 of 4 entry points keep their own status logic (UNKNOWN_OR_CHANGED,
    deferred-checkmark) so the runner shell adds little structural value,
  - the hazard it closes (forgot withSession on entry point #5) is
    theoretical until that contributor exists.
Drop the doc rather than leave a Status:Proposal file rotting in repo.

withSession() itself stays — reset-and-only-reset at session entry is
load-bearing against leaked-failed latches; cheap insurance.

#7330

* docs(sync): correct entry-point list in SyncSessionValidationService

The top-of-file docstring listed `resolveSyncConflict() (USE_REMOTE
branch)` as a 4th entry point and omitted `ImmediateUploadService
._performUpload()` (added in 22f68027). Both inaccurate after the
ImmediateUploadService wrap and contradict the inline `reset()`
docs at line 104, which correctly identify USE_REMOTE as a sub-scope
inside `_sync()`'s session, not a session boundary.

Replace with the actual four entry points and call out USE_REMOTE
separately as a sub-scope re-scope.

#7330
2026-05-09 00:12:58 +02:00

297 lines
12 KiB
TypeScript

import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
/**
* 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(120000); // 2 minutes - migration tests need extra time
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 clientB.sync.syncAndWait();
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(120000); // 2 minutes - migration tests need extra time
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);
// First sync to pull data from server
await clientB.sync.syncAndWait();
await clientB.page.waitForTimeout(1000);
// Second sync to ensure all operations are applied
await clientB.sync.syncAndWait();
await clientB.page.waitForTimeout(1000);
// Third sync - some operations may need multiple cycles
await clientB.sync.syncAndWait();
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
await waitForTask(clientB.page, task1);
await waitForTask(clientB.page, task2);
await waitForTask(clientB.page, task3);
console.log('[Test] SUCCESS: Multiple migrations preserved all data');
} finally {
if (clientA) await closeClient(clientA).catch(() => {});
if (clientB) await closeClient(clientB).catch(() => {});
}
});
});