test(e2e): harden LWW archive and encryption password-change tests

- supersync-archive-conflict: add closing B sync to the convergence
  rounds (A→B→A→B) so B is not one op behind A's final conflict-
  resolution state; bump active-list assertion timeout to 15s to
  cover the async reducer pipeline + sync-replay event-loop yield.
- supersync.page: fix race in changeEncryptionPassword where a stale
  check icon from the previous syncAndWait() caused the method to
  return before the server wipe + re-upload finished, leaving the
  next client to race against partially-uploaded server state.
  Now captures the check-icon state at entry and waits for it to
  clear (same pattern syncAndWait uses).
This commit is contained in:
Johannes Millan 2026-04-19 16:55:52 +02:00
parent 32dbc95ed9
commit de33234976
2 changed files with 47 additions and 15 deletions

View file

@ -1829,6 +1829,15 @@ export class SuperSyncPage extends BasePage {
* @param newPassword - The new encryption password
*/
async changeEncryptionPassword(newPassword: string): Promise<void> {
// 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 });
}
/**

View file

@ -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 ============