super-productivity/e2e/pages/sync.page.ts
Johannes Millan b50d5f6d96
fix(sync): dedupe surgical-sync retries and keep the import author through pruning (#9089)
* fix(sync): deduplicate surgical sync retries

Persist split-file configuration and acknowledge operation IDs already committed remotely after a lost upload response.

* fix(sync): preserve import author during clock pruning

Keep the active causal full-state author in oversized stored clocks and reuse the stored protected IDs when classifying response-loss retries.

* test(sync): harden supersync failure coverage

Exercise real upload endpoints and exact operation IDs across response loss, validation failures, schema blockers, full-state boundaries, vector pruning, and concurrent edits.

* fix(sync): heal a corrupt primary on duplicate-only uploads

The .bak recovery path caches the CORRUPT primary's rev precisely so this
cycle's conditional overwrite repairs sync-ops.json. The duplicate-retry
short-circuit read that cache and returned before the write, leaving the
primary corrupt whenever the recovered buffer already held every pending
op. Flag the recovered entry and let those uploads fall through.

Also stop synthesising a serverSeq for ops already in the buffer: the
field is optional, the upload and download paths number ops differently,
and a mixed batch could hand two ops the same value.

* perf(sync): look the full-state author up once per upload

Batch upload is off by default, so the guarded batch path was not the one
serving production: the serial path queries the causal full-state author
per op inside a single transaction, and a clock of 21-50 entries passes
validation and trips the guard on every one of them. Memoize the author
per transaction and resolve it lazily, so only an op whose clock actually
overflows pays, and only once. This also retires the batch pre-scan and
its loop-carried author, leaving both paths on one mechanism.

Report the lookup through ProcessOperationResult so the upload summary
stops under-reporting round-trips, and record why reconstructing the
stored protected set loosens id-collision detection.

* test(sync): wait for the committed title in renameTask

renameTask blurred the textarea, slept 300ms and returned without ever
checking the rename landed. Blur -> dispatch -> re-render outruns that
delay on a loaded machine, so a following sync uploads without the rename
op and the caller asserts against a task that was never renamed — which
is what supersync 3.1 hits on CI but never locally.

Wait for the new title instead, mirroring markTaskDone's done-state wait
and the e2e no-waitForTimeout rule.

* test(sync): dispatch focus so renameTask actually commits

renameTask relied on el.focus() to emit a focus event, but these tests
drive two clients as separate pages and only one page can hold focus, so
on CI the event often never fires. TaskTitleComponent then keeps
_isFocused=false, and resetToLastExternalValueTrigger resets tmpValue to
the stored title on the next task-object emission. Blur therefore
computes wasChanged=false, task.component skips update(), and the rename
is silently dropped without ever becoming an op.

That is supersync 3.1: client A's rename lives only in tmpValue, A syncs
and uploads nothing, B uploads its done op, A downloads it, the task ref
changes and the title reverts to the original — exactly the state the CI
artifact captured. A real user always has real focus, so the app itself
is unaffected.

Dispatch focus explicitly, mirroring the synthetic input/blur already
used here. Also correct the previous commit's claim: the toBeVisible
wait matches tmpValue, a component-local signal rendered in both
template branches, so it never observed the committed title and could
not have fixed this.

* docs: revert incidental prettier reformat of unrelated docs

The master merge ran prettier across files it pulled in, reformatting
three documents this branch has no business touching: markdown table
cell padding plus *emphasis* -> _emphasis_, with no content change.
handover.md documents two unrelated branches entirely.

Restores them to master. vector-clocks.md keeps its edits — those are
this branch's own and describe the pruning protection.

* refactor(sync): drop the full-state author lookup's roundtrip accounting

resolveFullStateAuthor memoizes per transaction, so the lookup it counts
fires at most once per upload — the plumbing existed to report a number
that is always 0 or 1, on a log line already counting dozens. The memo
and its own accounting cancelled out.

Removing didQuery lets resolveFullStateAuthor return string | undefined
and getPruneProtectedIds return string[], instead of both carrying a
tuple purely to feed the counter.

uploadDbRoundtrips and the batch path's own counter are untouched.

* test(sync): assert the committed store title in renameTask

The focus dispatch did not fix supersync 3.1 — the shard failed again
with an identical snapshot (original title, done, rename gone), so that
diagnosis was wrong. Stop guessing at the trigger and make the helper
able to observe the thing in question.

task-title renders tmpValue, a component-local signal, in BOTH its
editing and idle branches. Every DOM assertion here therefore matches as
soon as the synthetic input event fires, whether or not an op was ever
captured — which is why two rounds of "wait for the title" changed
nothing. Read the store instead, via the __e2eTestHelpers.store hook the
timeSpent helper already uses.

This is a diagnostic as much as a fix: it splits the two remaining
explanations. If renameTask now fails, the rename never becomes an op
and the bug is in how the test drives the edit. If it passes and 3.1
still fails at the merge assertion, the op is captured and lost during
sync — a real defect, and the test is right to fail.

* test(sync): move the 3.1 disjoint-merge rewrite out to #9095

3.1 was the last red shard, and it turned out to be right: the
store-backed renameTask passes, so the rename IS captured as an op, and
the test still fails at the merge assertion — the op is committed and
then lost during sync. Filed as #9095.

That bug is pre-existing and cannot be reached by anything in this PR:
the file-based adapter is not used by SuperSync, and the server-side
author memo only engages for clocks over 20 entries where this test
carries about three. 3.1 is also the only test here that exercises
neither of this PR's fixes — it races a title change against a
move-to-done, which is conflict resolution, not retry dedup or clock
pruning. So it moves to #9095 rather than holding verified sync fixes
red.

The rest of the hardening stays: the fault injections whose globs never
matched a real endpoint, the schema-mismatch test that asserted nothing,
and the compaction suite that called an endpoint which never existed are
what actually cover the fixes here.

Restoring the old 3.1 puts a misleading test back, so it now carries a
comment saying why it proves little and where the real one lives. The
strengthened version is kept on test/issue-9095-disjoint-merge-repro.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-17 00:46:01 +02:00

567 lines
24 KiB
TypeScript

import { type Page, type Locator, expect } from '@playwright/test';
import { BasePage } from './base.page';
export class SyncPage extends BasePage {
readonly syncBtn: Locator;
readonly providerSelect: Locator;
readonly baseUrlInput: Locator;
readonly userNameInput: Locator;
readonly passwordInput: Locator;
readonly syncFolderInput: Locator;
readonly saveBtn: Locator;
readonly syncSpinner: Locator;
readonly syncCheckIcon: Locator;
readonly encryptionPasswordInput: Locator;
readonly enableEncryptionBtn: Locator;
readonly disableEncryptionBtn: Locator;
constructor(page: Page) {
super(page);
this.syncBtn = page.locator('button.sync-btn');
this.providerSelect = page.locator('formly-field-mat-select mat-select');
this.baseUrlInput = page.locator('.e2e-baseUrl input');
this.userNameInput = page.locator('.e2e-userName input');
this.passwordInput = page.locator('.e2e-password input');
this.syncFolderInput = page.locator('.e2e-syncFolderPath input');
this.saveBtn = page.locator('mat-dialog-actions button[mat-flat-button]');
this.syncSpinner = page.locator('.sync-btn mat-icon.spin');
this.syncCheckIcon = page.locator('.sync-btn mat-icon.sync-state-ico');
// Encryption-related locators
// Note: encryptionPasswordInput is no longer used directly - password is entered in a dialog
this.encryptionPasswordInput = page.locator(
'.e2e-file-based-encrypt-key input[type="password"]',
);
// Enable encryption button is in Advanced Settings (file-based providers)
this.enableEncryptionBtn = page.locator(
'.e2e-file-based-enable-encryption-btn button',
);
// Disable encryption button is in the encryption status box (main view)
this.disableEncryptionBtn = page.locator('.e2e-disable-encryption-btn button');
}
async setupWebdavSync(
config: {
baseUrl: string;
username: string;
password: string;
syncFolderPath: string;
isEncryptionEnabled?: boolean;
encryptionPassword?: string;
isUseSplitSyncFiles?: boolean;
/**
* Set the encryption password in the setup-time "Encrypt before first
* upload?" dialog (instead of the post-setup Enable Encryption button), so
* the very first sync is encrypted. Requires `encryptionPassword`.
*/
encryptAtSetup?: boolean;
},
options: { isReconfigure?: boolean } = {},
): Promise<void> {
// Try entire setup flow up to 2 times (dialog-level retry)
for (let dialogAttempt = 0; dialogAttempt < 2; dialogAttempt++) {
if (dialogAttempt > 0) {
console.log(`[setupWebdavSync] Dialog-level retry attempt ${dialogAttempt + 1}`);
}
// Dismiss any visible snackbars/toasts that might block clicks
const snackBar = this.page.locator('.mat-mdc-snack-bar-container');
if (await snackBar.isVisible({ timeout: 500 }).catch(() => false)) {
const dismissBtn = snackBar.locator('button');
if (await dismissBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await dismissBtn.click().catch(() => {});
}
await this.page.waitForTimeout(500);
}
// Wait for any pending navigation to complete before opening the dialog.
// Angular hash-based routing (e.g., from / to /#/tag/TODAY/tasks) can be in-flight,
// which causes Playwright to block element interactions with
// "waiting for navigation to finish" errors.
await this.page.waitForLoadState('domcontentloaded').catch(() => {});
await this.page
.waitForURL(/#\/(tag|project)\/.+\/tasks/, { timeout: 10000 })
.catch(() => {});
// Ensure sync button is visible and clickable
await this.syncBtn.waitFor({ state: 'visible', timeout: 10000 });
// Click sync button to open settings dialog
// Use noWaitAfter to prevent blocking on Angular hash navigation
await this.syncBtn.click({
button: options.isReconfigure ? 'right' : 'left',
timeout: 5000,
noWaitAfter: true,
});
// Wait for dialog to appear
const dialog = this.page.locator('mat-dialog-container, .mat-mdc-dialog-container');
const dialogVisible = await dialog
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
// If dialog didn't open, try clicking again
if (!dialogVisible) {
await this.page.waitForTimeout(500);
await this.syncBtn.click({
button: options.isReconfigure ? 'right' : 'left',
force: true,
noWaitAfter: true,
});
await dialog.waitFor({ state: 'visible', timeout: 5000 });
}
// Wait for dialog to be fully loaded
await this.page.waitForLoadState('networkidle');
await this.providerSelect.waitFor({ state: 'visible', timeout: 10000 });
// Wait a moment for Angular animations
await this.page.waitForTimeout(500);
// Click on provider select to open dropdown with retry
const webdavOption = this.page.locator('mat-option').filter({ hasText: 'WebDAV' });
const selectElement = this.providerSelect;
const selectValueText = this.page.locator(
'formly-field-mat-select .mat-mdc-select-value-text',
);
let selectionSucceeded = false;
for (let attempt = 0; attempt < 5; attempt++) {
// Ensure the select is in view
await selectElement.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(async () => {
// If scrollIntoViewIfNeeded fails, try scrolling the dialog content
const dialogContent = this.page.locator('mat-dialog-content');
if (await dialogContent.isVisible()) {
await dialogContent.evaluate((el) => el.scrollTo(0, 0));
}
});
await this.page.waitForTimeout(300);
// Focus and click the select element
await selectElement.focus().catch(() => {});
await this.page.waitForTimeout(200);
// Try multiple ways to open the dropdown
if (attempt === 0) {
await selectElement.click().catch(() => {});
} else if (attempt === 1) {
await this.page.keyboard.press('Space');
} else if (attempt === 2) {
await this.page.keyboard.press('ArrowDown');
} else {
await selectElement.click({ force: true }).catch(() => {});
}
await this.page.waitForTimeout(500);
// Wait for any mat-option to appear (dropdown opened)
const anyOption = this.page.locator('mat-option').first();
const anyOptionVisible = await anyOption
.waitFor({ state: 'visible', timeout: 3000 })
.then(() => true)
.catch(() => false);
if (anyOptionVisible) {
const webdavVisible = await webdavOption
.waitFor({ state: 'visible', timeout: 3000 })
.then(() => true)
.catch(() => false);
if (webdavVisible) {
await webdavOption.click();
// KEY FIX: Verify selection actually took effect by checking mat-select value
const selectionVerified = await selectValueText
.waitFor({ state: 'visible', timeout: 3000 })
.then(async () => {
const text = await selectValueText.textContent();
return text?.includes('WebDAV') ?? false;
})
.catch(() => false);
if (selectionVerified) {
// Selection confirmed - now wait for Formly to process the model change
await this.page.waitForTimeout(500); // Allow Formly hideExpression to evaluate
// Wait for WebDAV form fields with increased timeout
const fieldVisible = await this.baseUrlInput
.waitFor({ state: 'visible', timeout: 8000 })
.then(() => true)
.catch(() => false);
if (fieldVisible) {
selectionSucceeded = true;
break; // Form fields are visible, we can proceed
}
}
}
}
// Close dropdown and retry
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(500);
}
if (selectionSucceeded) {
// Final verification with increased timeout
await this.baseUrlInput.waitFor({ state: 'visible', timeout: 15000 });
// Fill in the configuration
await this.baseUrlInput.fill(config.baseUrl);
await this.userNameInput.fill(config.username);
await this.passwordInput.fill(config.password);
await this.syncFolderInput.fill(config.syncFolderPath);
if (config.isUseSplitSyncFiles !== undefined) {
await this.expandAdvancedSettings();
const splitSyncCheckbox = dialog.getByRole('checkbox', {
name: /Surgical sync/i,
});
await splitSyncCheckbox.setChecked(config.isUseSplitSyncFiles);
await expect(splitSyncCheckbox).toBeChecked({
checked: config.isUseSplitSyncFiles,
});
}
// Save the configuration
await this.saveBtn.click();
// A fresh file-based setup now opens the optional "Encrypt before first
// upload?" dialog (disableClose) before the config is persisted. Either
// set the password here (setup-time E2EE) or skip it so setup proceeds
// unencrypted.
if (config.encryptAtSetup && config.encryptionPassword) {
await this._fillSetupEncryptionDialog(config.encryptionPassword);
} else {
await this._skipSetupEncryptionDialogIfPresent();
}
// Wait for dialog to close
await dialog.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
await this.page.waitForTimeout(500);
// Legacy path: enable encryption via the post-setup Enable Encryption
// button (skipped when it was already set at setup above).
if (
config.isEncryptionEnabled &&
config.encryptionPassword &&
!config.encryptAtSetup
) {
await this.waitForSyncReady();
await this.enableEncryption(config.encryptionPassword);
}
return; // Success - exit the method
}
// Selection failed after all attempts - close dialog and retry from scratch
console.log(
'[setupWebdavSync] All dropdown attempts failed, closing dialog to retry',
);
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(1000);
// Ensure dialog is closed before retrying
await dialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
// If we get here, both dialog-level attempts failed
throw new Error(
'[setupWebdavSync] Failed to setup WebDAV sync after multiple dialog-level retries',
);
}
/**
* Dismisses the optional "Encrypt before first upload?" setup dialog that
* opens on a fresh file-based setup (see DialogSyncCfgComponent.save()). Clicks
* Skip so setup stays unencrypted. No-op if the dialog did not appear.
*/
private async _skipSetupEncryptionDialogIfPresent(): Promise<void> {
// The dialog opens only AFTER save()'s awaited provider-auth/connection check
// and a lazy import() of the dialog chunk, so it appears a beat after the
// Save click — often after CI load stalls it for a second or more. Use
// waitFor(), NOT isVisible(): isVisible() returns the CURRENT state
// immediately (its `timeout` never polls), so it raced the dialog and
// returned false, leaving the disableClose modal open with its backdrop
// blocking the rest of the test. waitFor() actually polls until it appears.
const skipBtn = this.page.locator('.e2e-setup-encrypt-skip');
const appeared = await skipBtn
.waitFor({ state: 'visible', timeout: 20000 })
.then(() => true)
.catch(() => false);
if (appeared) {
await skipBtn.click();
await skipBtn.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
}
}
/**
* Fills the setup-time "Encrypt before first upload?" dialog with the given
* password and submits, so the config save persists the key and the first
* sync is encrypted. Mirrors the robust fill used by enableEncryption().
*/
private async _fillSetupEncryptionDialog(password: string): Promise<void> {
const passwordInput = this.page.locator('.e2e-setup-encrypt-password');
const confirmInput = this.page.locator('.e2e-setup-encrypt-confirm-password');
const submitBtn = this.page.locator('.e2e-setup-encrypt-submit');
// The dialog opens only after save()'s awaited auth check + lazy import(),
// which can exceed 5s under CI load (see _skipSetupEncryptionDialogIfPresent).
await passwordInput.waitFor({ state: 'visible', timeout: 20000 });
await passwordInput.click();
await passwordInput.fill(password);
await expect(passwordInput).toHaveValue(password);
// Insert into the focused confirm field — fill() has occasionally left the
// value on the first field in CI (see enableEncryption()).
await confirmInput.click();
await expect(confirmInput).toBeFocused();
await this.page.keyboard.press('ControlOrMeta+A');
await this.page.keyboard.insertText(password);
await expect(confirmInput).toHaveValue(password);
await expect(passwordInput).toHaveValue(password);
await expect(submitBtn).toBeEnabled({ timeout: 5000 });
await submitBtn.click();
await submitBtn.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
}
async triggerSync(): Promise<void> {
// Dismiss any open dialogs/overlays that might block the sync button
const overlay = this.page.locator('.cdk-overlay-backdrop');
if (await overlay.isVisible({ timeout: 500 }).catch(() => false)) {
// Try pressing Escape to close any open dialog
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(300);
// If overlay is still there, try clicking outside to dismiss
if (await overlay.isVisible({ timeout: 300 }).catch(() => false)) {
await overlay.click({ force: true }).catch(() => {});
await this.page.waitForTimeout(300);
}
}
// Use noWaitAfter to prevent blocking on Angular hash navigation
await this.syncBtn.click({ noWaitAfter: true });
// Wait for any sync operation to start (spinner appears or completes immediately)
await Promise.race([
this.syncSpinner.waitFor({ state: 'visible', timeout: 1000 }).catch(() => {}),
this.syncCheckIcon.waitFor({ state: 'visible', timeout: 1000 }).catch(() => {}),
]);
}
async waitForSyncComplete(): Promise<void> {
// Wait for sync spinner to disappear
await this.syncSpinner.waitFor({ state: 'hidden', timeout: 20000 }); // Reduced from 30s to 20s
// Verify check icon appears
await this.syncCheckIcon.waitFor({ state: 'visible' });
}
/**
* Waits for sync to be ready after page load.
* This ensures the provider config has been loaded from IndexedDB.
*/
async waitForSyncReady(): Promise<void> {
// Wait for sync button to show the check icon (indicates provider is ready)
// The sync button shows a plain sync icon when not ready, and check/done_all when ready
await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 10000 });
}
/**
* Expands the Advanced settings section in the sync dialog.
* The formly-collapsible component uses a custom structure with .collapsible-header
*/
async expandAdvancedSettings(): Promise<void> {
// Find the Advanced collapsible header by its text content
const collapsibleHeader = this.page.locator(
'formly-collapsible .collapsible-header:has-text("Advanced")',
);
// Wait for it to be visible
await collapsibleHeader.waitFor({ state: 'visible', timeout: 5000 });
// Check if the panel is already expanded by looking for .collapsible-panel sibling
const collapsiblePanel = this.page.locator('formly-collapsible .collapsible-panel');
const isExpanded = await collapsiblePanel.isVisible().catch(() => false);
if (!isExpanded) {
await collapsibleHeader.click();
// Wait for expansion animation
await this.page.waitForTimeout(500);
// Wait for panel to be visible
await collapsiblePanel.waitFor({ state: 'visible', timeout: 3000 });
}
}
/**
* Enables encryption for file-based providers (WebDAV, Dropbox, LocalFile).
*
* Flow:
* 1. Opens sync settings dialog
* 2. Expands Advanced settings
* 3. Clicks "Enable Encryption" button
* 4. Fills password in the enable encryption dialog
* 5. Confirms encryption
*/
async enableEncryption(password: string): Promise<void> {
// Wait for any pending navigation to complete before opening the dialog.
await this.page.waitForLoadState('domcontentloaded').catch(() => {});
await this.page
.waitForURL(/#\/(tag|project)\/.+\/tasks/, { timeout: 10000 })
.catch(() => {});
// Open sync settings dialog using right-click
// Use noWaitAfter to prevent blocking on Angular hash navigation
await this.syncBtn.click({ button: 'right', noWaitAfter: true });
const settingsDialog = this.page.locator(
'mat-dialog-container, .mat-mdc-dialog-container',
);
await settingsDialog.waitFor({ state: 'visible', timeout: 5000 });
// Expand advanced settings to access encryption button
await this.expandAdvancedSettings();
// Check if encryption is already enabled
const isEnabled = await this.disableEncryptionBtn.isVisible().catch(() => false);
if (isEnabled) {
// Encryption already enabled, close dialog and return
await this.page.keyboard.press('Escape');
await settingsDialog
.first()
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {});
return;
}
// Click the "Enable Encryption" button to open encryption dialog
await this.enableEncryptionBtn.waitFor({ state: 'visible', timeout: 5000 });
await expect(this.enableEncryptionBtn).toBeEnabled({ timeout: 5000 });
await this.enableEncryptionBtn.click();
// Wait for the encryption dialog to appear
// The dialog has title "Enable Encryption?" and contains password fields
const encryptionDialog = this.page.locator('mat-dialog-container').filter({
hasText: 'Enable Encryption?',
});
await encryptionDialog.waitFor({ state: 'visible', timeout: 5000 });
// Scope each input to its labelled mat-form-field. Targeting by nth-index on
// input[type="password"] within the dialog has been observed to leave the
// confirm field empty — likely because the visible-input order is not
// guaranteed during Angular's render cycle.
const passwordField = encryptionDialog
.locator('mat-form-field')
.filter({ has: this.page.locator('mat-label:has-text("Encryption Password")') });
const confirmField = encryptionDialog
.locator('mat-form-field')
.filter({ has: this.page.locator('mat-label:has-text("Confirm")') });
const passwordInput = passwordField.locator('input').first();
const confirmInput = confirmField.locator('input').first();
// Fill password field
await passwordInput.waitFor({ state: 'visible', timeout: 5000 });
await passwordInput.click();
await passwordInput.fill(password);
await expect(passwordInput).toHaveValue(password);
// Fill confirm password field. In CI, fill() has occasionally left focus on
// the first password field, duplicating the value there and leaving confirm
// empty. Insert through the focused confirm input and assert both values.
await confirmInput.waitFor({ state: 'visible', timeout: 5000 });
await confirmInput.click();
await expect(confirmInput).toBeFocused();
await this.page.keyboard.press('ControlOrMeta+A');
await this.page.keyboard.insertText(password);
await expect(confirmInput).toHaveValue(password);
await expect(passwordInput).toHaveValue(password);
// Click the "Enable Encryption" button in this dialog
// It's the mat-flat-button with a lock icon
const confirmBtn = encryptionDialog.locator(
'button[mat-flat-button]:has-text("Enable Encryption")',
);
await confirmBtn.waitFor({ state: 'visible', timeout: 5000 });
await expect(confirmBtn).toBeEnabled({ timeout: 5000 });
await confirmBtn.click();
// Wait for encryption dialog to close
// Use a unique element from the encryption dialog that won't exist after close
await encryptionDialog.waitFor({ state: 'hidden', timeout: 60000 });
// The settings dialog should auto-close after encryption is enabled
// Wait a moment for the state to settle
await this.page.waitForTimeout(500);
// Close settings dialog if still open
const isSettingsStillOpen = await settingsDialog
.first()
.isVisible()
.catch(() => false);
if (isSettingsStillOpen) {
await this.page.keyboard.press('Escape');
await settingsDialog
.first()
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {});
}
}
/**
* Disables encryption for file-based providers.
* Opens the sync settings dialog and clicks the "Disable Encryption" button.
* Note: The Disable Encryption button is in the main view (encryption status box),
* NOT in the Advanced Config section.
*/
async disableEncryptionForFileBased(): Promise<void> {
// Wait for any pending navigation to complete before opening the dialog.
await this.page.waitForLoadState('domcontentloaded').catch(() => {});
await this.page
.waitForURL(/#\/(tag|project)\/.+\/tasks/, { timeout: 10000 })
.catch(() => {});
// Open sync settings dialog using right-click
// Use noWaitAfter to prevent blocking on Angular hash navigation
await this.syncBtn.click({ button: 'right', noWaitAfter: true });
const dialog = this.page.locator('mat-dialog-container, .mat-mdc-dialog-container');
await dialog.waitFor({ state: 'visible', timeout: 5000 });
// Wait for dialog content to render
await this.page.waitForTimeout(500);
// Click the Disable Encryption button (in the encryption status box, main view)
await this.disableEncryptionBtn.scrollIntoViewIfNeeded();
await this.disableEncryptionBtn.waitFor({ state: 'visible', timeout: 5000 });
await this.disableEncryptionBtn.click();
// Wait for confirmation dialog "Disable Encryption?" to appear
const confirmDialog = this.page.locator('mat-dialog-container').filter({
hasText: 'Disable Encryption?',
});
await confirmDialog.waitFor({ state: 'visible', timeout: 5000 });
// Click the confirm button
const confirmBtn = confirmDialog.locator('button[mat-flat-button]');
await confirmBtn.waitFor({ state: 'visible', timeout: 5000 });
await confirmBtn.click();
// Wait for confirmation dialog to close — this is the reliable completion signal.
// The operation uploads an unencrypted snapshot to WebDAV (network I/O),
// updates IndexedDB, and clears caches before closing the dialog.
// Use generous timeout for slow CI/network.
await confirmDialog.waitFor({ state: 'hidden', timeout: 30000 });
// Both dialogs auto-close (confirmation via dialogRef.close(), then
// settings via closeAllDialogs()). Wait for all dialogs to be gone.
await expect(this.page.locator('mat-dialog-container')).toHaveCount(0, {
timeout: 10000,
});
// Dismiss success snackbar if visible (could block sync button)
const snackBar = this.page.locator('.mat-mdc-snack-bar-container');
if (await snackBar.isVisible({ timeout: 500 }).catch(() => false)) {
const dismissBtn = snackBar.locator('button');
if (await dismissBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await dismissBtn.click().catch(() => {});
}
await snackBar.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
}
}