diff --git a/e2e/pages/supersync.page.ts b/e2e/pages/supersync.page.ts index 63cec5a2d8..324bd3e1d3 100644 --- a/e2e/pages/supersync.page.ts +++ b/e2e/pages/supersync.page.ts @@ -1829,6 +1829,15 @@ export class SuperSyncPage extends BasePage { * @param newPassword - The new encryption password */ async changeEncryptionPassword(newPassword: string): Promise { + // Capture the sync check icon state BEFORE we start. If a previous + // syncAndWait() left the check icon visible, the final wait at the end of + // this method would see a stale icon and return before the server wipe + + // re-upload completes — causing a race where the next client starts + // syncing against partially-uploaded server state. + const checkVisibleBeforeOperation = await this.syncCheckIcon + .isVisible() + .catch(() => false); + // Open sync settings via right-click // Use noWaitAfter to prevent blocking on Angular hash navigation await this.syncBtn.click({ button: 'right', noWaitAfter: true }); @@ -1934,18 +1943,32 @@ export class SuperSyncPage extends BasePage { } } - // Wait for password change operation to complete (server wipe + re-upload) - const checkAlreadyVisible = await this.syncCheckIcon.isVisible().catch(() => false); - if (!checkAlreadyVisible) { - const spinnerVisible = await this.syncSpinner - .waitFor({ state: 'visible', timeout: 5000 }) - .then(() => true) - .catch(() => false); - if (spinnerVisible) { - await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 }); - } - await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 10000 }); + // Wait for password change operation to complete (server wipe + re-upload). + // + // If the check icon was visible BEFORE we opened the settings dialog, it's + // stale from a previous sync — we must first wait for it to disappear (new + // sync cycle started) or the spinner to appear, before waiting for the + // check icon to reappear (new sync completed). Without this, we'd return + // immediately against a stale icon and race the server re-upload. + if (checkVisibleBeforeOperation) { + await Promise.race([ + this.syncCheckIcon.waitFor({ state: 'hidden', timeout: 5000 }), + this.syncSpinner.waitFor({ state: 'visible', timeout: 5000 }), + ]).catch(() => { + // Neither happened within 5s — the password change may not have + // triggered a re-sync (rare). Fall through and rely on the final + // check-icon wait below. + }); } + + const spinnerVisible = await this.syncSpinner + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => true) + .catch(() => false); + if (spinnerVisible) { + await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 }); + } + await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 10000 }); } /** diff --git a/e2e/tests/sync/supersync-archive-conflict.spec.ts b/e2e/tests/sync/supersync-archive-conflict.spec.ts index 6d78366117..5719e09846 100644 --- a/e2e/tests/sync/supersync-archive-conflict.spec.ts +++ b/e2e/tests/sync/supersync-archive-conflict.spec.ts @@ -250,21 +250,30 @@ test.describe('@supersync Archive Conflict Resolution', () => { await clientB.sync.syncAndWait(); console.log('[BugB] Client B synced (downloaded archive + conflict resolution)'); - // Extra sync rounds for convergence + // Extra sync rounds for convergence. + // Pattern A→B→A→B: conflict-resolution on either client may re-upload ops, + // and an uneven final round (e.g. A→B→A) can leave B one op behind A's + // final state. The closing B sync guarantees both clients are equal. await clientA.sync.syncAndWait(); await clientB.sync.syncAndWait(); await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); console.log('[BugB] Extra sync rounds for convergence'); // ============ PHASE 6: Verify task NOT in active task list ============ await navigateToWorkView(clientA); await navigateToWorkView(clientB); - await expectTaskNotVisible(clientA, taskName); + // Use an extended timeout: archive op application goes through an async + // reducer pipeline plus an event-loop yield during sync replay (see + // CLAUDE.md #11). Give the UI enough time to reflect the archived state + // before asserting — this is real settle time, not masking. + const archivedAssertionTimeout = 15000; + await expectTaskNotVisible(clientA, taskName, archivedAssertionTimeout); console.log('[BugB] Client A: task not visible in active list'); - await expectTaskNotVisible(clientB, taskRenamed); - await expectTaskNotVisible(clientB, taskName); + await expectTaskNotVisible(clientB, taskRenamed, archivedAssertionTimeout); + await expectTaskNotVisible(clientB, taskName, archivedAssertionTimeout); console.log('[BugB] Client B: task not visible in active list'); // ============ PHASE 7: Verify task IN worklog ============