mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* 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>
567 lines
24 KiB
TypeScript
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(() => {});
|
|
}
|
|
}
|
|
}
|