mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-21 02:20:12 +00:00
* 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 in41b47be4silently 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 inb3cbdbd41), - 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 in22f68027). 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
297 lines
12 KiB
TypeScript
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(() => {});
|
|
}
|
|
});
|
|
});
|