test(sync): harden supersync e2e against flaky late decrypt dialog

The scheduled master E2E (run 27927390789, SuperSync 3/6 shard) flaked on
"Bidirectional sync works after encryption change via import". The stuck
overlay was the "Decryption Failed" dialog (dialog-handle-decrypt-error):
it fires asynchronously after a re-sync, and the setupSuperSync dialog
loop only handles it within its bounded rounds. A late re-fire was never
dismissed, so the final one-shot expect(...).toHaveCount(0) watched the
disableClose dialog for the full 15s and timed out.

Replace the one-shot late-dialog wait + close assertion with a converging
expect.toPass() poll that dismisses whichever late, non-self-closing dialog
is present each round (enable-encryption or decrypt-error), then re-checks
the overlay is empty — handling appearances that land early, late, or never.
Extract the decrypt-error handling into a shared _handleDecryptErrorDialog
helper reused by the loop and the drain.

Verified via prettier/eslint/tsc; not run live (the @supersync docker e2e
can't reach the server from the dev sandbox).
This commit is contained in:
Johannes Millan 2026-06-23 12:54:49 +02:00
parent a1a2c54fa5
commit 0a6a692187

View file

@ -954,27 +954,10 @@ export class SuperSyncPage extends BasePage {
.isVisible()
.catch(() => false);
if (decryptErrorVisible) {
if (config.decryptionFailedPassword) {
console.log(
'[SuperSyncPage] Decryption Failed dialog — entering password to retry',
);
const passwordInput = decryptErrorDialog.locator('input[type="password"]');
await passwordInput.fill(config.decryptionFailedPassword);
const retryBtn = decryptErrorDialog.locator(
'button[mat-flat-button][color="primary"]',
);
await retryBtn.click();
await decryptErrorDialog.waitFor({ state: 'hidden', timeout: 30000 });
} else {
console.log(
'[SuperSyncPage] Decryption Failed dialog — no password provided, cancelling',
);
const cancelBtn = decryptErrorDialog.locator(
'mat-dialog-actions button[mat-button]',
);
await cancelBtn.click();
await decryptErrorDialog.waitFor({ state: 'hidden', timeout: 5000 });
}
await this._handleDecryptErrorDialog(
decryptErrorDialog,
config.decryptionFailedPassword,
);
continue;
}
@ -988,37 +971,52 @@ export class SuperSyncPage extends BasePage {
await this.page.waitForTimeout(1000);
}
// After a successful sync on SuperSync, _promptSuperSyncEncryptionIfNeeded()
// fires asynchronously (lazy import) and may open the enable_encryption dialog.
// Handle it BEFORE asserting all dialogs are closed — the dialog has
// disableClose:true and won't close on its own.
const lateEnableEncDialog = await enableEncryptionDialog
.first()
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
if (lateEnableEncDialog) {
console.log(
'[SuperSyncPage] Late enable-encryption dialog appeared — setting password',
);
const topEncDlg = enableEncryptionDialog.last();
const confirmBtn = topEncDlg.locator('button[mat-flat-button]');
// Re-fill until the value-accessor binds and the button enables — a
// fill() on a freshly-opened dialog can land before [(ngModel)] is wired,
// leaving the field empty and the confirm button permanently disabled.
await expect(async () => {
await topEncDlg.locator('input[type="password"]').first().fill(defaultPassword);
await topEncDlg.locator('input[type="password"]').nth(1).fill(defaultPassword);
await expect(confirmBtn).toBeEnabled({ timeout: 1000 });
}).toPass({ timeout: 10000 });
await confirmBtn.click();
await topEncDlg.waitFor({ state: 'hidden', timeout: 15000 });
}
// Wait for all dialogs to close
await expect(this.page.locator('mat-dialog-container')).toHaveCount(0, {
timeout: 15000,
});
// After a successful sync on SuperSync, dialogs can still fire async past
// the loop above and neither closes on its own:
// - _promptSuperSyncEncryptionIfNeeded() (lazy import) opens the
// mandatory enable-encryption dialog (disableClose:true), and
// - a re-sync that re-hits still-encrypted server ops re-opens the
// "Decryption Failed" dialog.
// Both must be dismissed before the overlay can empty, and on a loaded CI
// runner they can land after a one-shot appearance wait would elapse —
// which made a one-shot `toHaveCount(0)` flake: it sat watching the stuck
// dialog for the full timeout and never reached 0. Poll instead — dismiss
// whichever late dialog is present each round, then re-check the overlay is
// empty. This converges whether a dialog appears early, late, or never.
const lateDecryptErrorDialog = this.page.locator('dialog-handle-decrypt-error');
await expect(async () => {
if (
await enableEncryptionDialog
.first()
.isVisible()
.catch(() => false)
) {
console.log(
'[SuperSyncPage] Late enable-encryption dialog appeared — setting password',
);
await this._fillAndConfirmEncryptionDialog(
enableEncryptionDialog.last(),
defaultPassword,
);
} else if (await lateDecryptErrorDialog.isVisible().catch(() => false)) {
await this._handleDecryptErrorDialog(
lateDecryptErrorDialog,
config.decryptionFailedPassword,
);
} else {
// No dialog up yet — give a late async one a chance to appear before
// treating the overlay as settled, so the poll can't slip through a
// transient empty gap before the disableClose dialog fires. If one
// appears, the toHaveCount below fails and the next round dismisses it.
await Promise.race([
enableEncryptionDialog.first().waitFor({ state: 'visible', timeout: 5000 }),
lateDecryptErrorDialog.waitFor({ state: 'visible', timeout: 5000 }),
]).catch(() => {});
}
await expect(this.page.locator('mat-dialog-container')).toHaveCount(0, {
timeout: 1000,
});
}).toPass({ timeout: 45000 });
// Wait for sync to complete (either already done or triggered after dialog)
const checkAlreadyVisible = await this.syncCheckIcon.isVisible().catch(() => false);
@ -1370,6 +1368,41 @@ export class SuperSyncPage extends BasePage {
await this.ensureOverlaysClosed();
}
/**
* Dismiss the "Decryption Failed" dialog (dialog-handle-decrypt-error). It
* appears when the server still holds ops encrypted with a previous password
* (e.g. after an encryption change via import). With the old password it is
* retried ("Retry Decrypt"); without one it is cancelled. It can also fire
* late/async after a re-sync, so it is handled both in the setup dialog loop
* and in the final dialog drain.
*/
private async _handleDecryptErrorDialog(
decryptErrorDialog: Locator,
decryptionFailedPassword?: string,
): Promise<void> {
if (decryptionFailedPassword) {
console.log(
'[SuperSyncPage] Decryption Failed dialog — entering password to retry',
);
const passwordInput = decryptErrorDialog.locator('input[type="password"]');
await passwordInput.fill(decryptionFailedPassword);
const retryBtn = decryptErrorDialog.locator(
'button[mat-flat-button][color="primary"]',
);
await retryBtn.click();
await decryptErrorDialog.waitFor({ state: 'hidden', timeout: 30000 });
} else {
console.log(
'[SuperSyncPage] Decryption Failed dialog — no password provided, cancelling',
);
const cancelBtn = decryptErrorDialog.locator(
'mat-dialog-actions button[mat-button]',
);
await cancelBtn.click();
await decryptErrorDialog.waitFor({ state: 'hidden', timeout: 5000 });
}
}
/**
* Poll for the encryption button state after provider selection.
* The provider change listener asynchronously loads config from IndexedDB