fix(sync): isolate provider encryption settings + enforce critical e2e coverage (#9044)

* docs(plugins): add microsoft 365 calendar provider plan

* docs(plugins): add microsoft 365 calendar provider plan

* docs(ios): plan internal testflight builds

* fix(sync): isolate provider encryption settings

* test: enforce critical end-to-end coverage
This commit is contained in:
Johannes Millan 2026-07-15 14:24:49 +02:00 committed by GitHub
parent 000d4726ac
commit a4dee9d5f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 864 additions and 1250 deletions

View file

@ -0,0 +1,177 @@
#!/usr/bin/env node
const fs = require('node:fs');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { chromium, expect } = require('@playwright/test');
const executablePath = process.argv[2];
if (!executablePath || !fs.existsSync(executablePath)) {
throw new Error(
`Packaged Electron executable not found: ${executablePath || '<none>'}`,
);
}
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-electron-smoke-'));
const xdgDir = path.join(userDataDir, 'xdg');
fs.mkdirSync(xdgDir, { recursive: true });
const getFreePort = async () =>
new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
reject(new Error('Could not allocate a local debugging port'));
return;
}
server.close((error) => (error ? reject(error) : resolve(address.port)));
});
});
const waitForCdp = async (endpoint, child, timeoutMs = 60_000) => {
const deadline = Date.now() + timeoutMs;
let lastError;
while (Date.now() < deadline) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Packaged Electron exited before opening CDP (${child.exitCode ?? child.signalCode})`,
);
}
try {
const response = await fetch(`${endpoint}/json/version`);
if (response.ok) return;
lastError = new Error(`CDP returned HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for packaged Electron CDP: ${lastError}`);
};
const waitForMainPage = async (browser, timeoutMs = 30_000) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const page = browser
.contexts()
.flatMap((context) => context.pages())
.find((candidate) => !candidate.url().startsWith('devtools://'));
if (page) return page;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error('Packaged Electron did not create a renderer window');
};
const stopChild = async (child) => {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill('SIGTERM');
await Promise.race([
new Promise((resolve) => child.once('exit', resolve)),
new Promise((resolve) => setTimeout(resolve, 8_000)),
]);
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
};
const run = async () => {
const debuggingPort = await getFreePort();
const endpoint = `http://127.0.0.1:${debuggingPort}`;
const child = spawn(
executablePath,
[
`--remote-debugging-port=${debuggingPort}`,
`--user-data-dir=${userDataDir}`,
'--no-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
],
{
env: {
...process.env,
ELECTRON_ENABLE_LOGGING: '1',
XDG_CONFIG_HOME: xdgDir,
XDG_CACHE_HOME: xdgDir,
XDG_DATA_HOME: xdgDir,
},
stdio: ['ignore', 'pipe', 'pipe'],
},
);
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
let browser;
try {
await waitForCdp(endpoint, child);
browser = await chromium.connectOverCDP(endpoint);
const pageErrors = [];
const observedPages = new WeakSet();
const observePageErrors = (candidate) => {
if (observedPages.has(candidate)) return;
observedPages.add(candidate);
candidate.on('pageerror', (error) => pageErrors.push(error.message));
};
for (const context of browser.contexts()) {
context.on('page', observePageErrors);
context.pages().forEach(observePageErrors);
}
const page = await waitForMainPage(browser);
await page.waitForLoadState('domcontentloaded');
await page.evaluate(() => {
localStorage.setItem('SUP_ONBOARDING_PRESET_DONE', 'true');
localStorage.setItem('SUP_ONBOARDING_HINTS_DONE', 'true');
localStorage.setItem('SUP_IS_SHOW_TOUR', 'true');
localStorage.setItem('SUP_EXAMPLE_TASKS_CREATED', 'true');
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.locator('.route-wrapper').first().waitFor({ state: 'visible' });
const taskTitle = `Packaged Electron smoke ${Date.now()}`;
const addTaskInput = page.locator('add-task-bar.global .main-input').first();
if (!(await addTaskInput.isVisible())) {
await page.locator('.tour-addBtn').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('.tour-addBtn').click();
}
await addTaskInput.waitFor({ state: 'visible', timeout: 10_000 });
await addTaskInput.fill(taskTitle);
const operationProcessed = page.waitForEvent('console', {
predicate: (message) =>
message.text().includes('OperationCaptureService: Processed action'),
timeout: 20_000,
});
await addTaskInput.press('Enter');
await expect(page.locator('task').filter({ hasText: taskTitle })).toBeVisible({
timeout: 20_000,
});
// Reload proves the renderer can also persist and hydrate the core task.
// This completion log fires after either the IndexedDB or Electron SQLite
// backend has finished the operation write and released its lock.
await operationProcessed;
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('task').filter({ hasText: taskTitle })).toBeVisible({
timeout: 20_000,
});
if (pageErrors.length) {
throw new Error(`Renderer page errors:\n${pageErrors.join('\n')}`);
}
console.log('Packaged Electron task-create-and-reload smoke passed.');
} finally {
await browser?.close().catch(() => undefined);
await stopChild(child);
}
};
run()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => {
fs.rmSync(userDataDir, { recursive: true, force: true });
});

View file

@ -68,11 +68,6 @@ export const waitForPluginAssets = async (
console.error('[Plugin Test] Failed to load plugin assets after all retries');
// In CI, this might be expected if assets aren't built properly
if (process.env.CI) {
console.warn('[Plugin Test] Plugin assets unavailable; skipping in CI');
}
return false;
};

View file

@ -39,20 +39,23 @@ export class SyncPage extends BasePage {
this.disableEncryptionBtn = page.locator('.e2e-disable-encryption-btn button');
}
async setupWebdavSync(config: {
baseUrl: string;
username: string;
password: string;
syncFolderPath: string;
isEncryptionEnabled?: boolean;
encryptionPassword?: string;
/**
* 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;
}): Promise<void> {
async setupWebdavSync(
config: {
baseUrl: string;
username: string;
password: string;
syncFolderPath: string;
isEncryptionEnabled?: boolean;
encryptionPassword?: string;
/**
* 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) {
@ -83,7 +86,11 @@ export class SyncPage extends BasePage {
// Click sync button to open settings dialog
// Use noWaitAfter to prevent blocking on Angular hash navigation
await this.syncBtn.click({ timeout: 5000, noWaitAfter: true });
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');
@ -95,7 +102,11 @@ export class SyncPage extends BasePage {
// If dialog didn't open, try clicking again
if (!dialogVisible) {
await this.page.waitForTimeout(500);
await this.syncBtn.click({ force: true, noWaitAfter: true });
await this.syncBtn.click({
button: options.isReconfigure ? 'right' : 'left',
force: true,
noWaitAfter: true,
});
await dialog.waitFor({ state: 'visible', timeout: 5000 });
}

View file

@ -0,0 +1,51 @@
import { expect, test } from '../../fixtures/test.fixture';
import {
assertNoRuntimeBrowserErrors,
attachPageErrorCollector,
installDevErrorDialogHandler,
} from '../../utils/runtime-errors';
import { waitForStatePersistence } from '../../utils/waits';
import { WorkViewPage } from '../../pages/work-view.page';
test.describe('First-run onboarding', () => {
test('applies a preset and does not show onboarding again after reload', async ({
isolatedContext,
}) => {
const page = await isolatedContext.newPage();
const runtimeErrors = attachPageErrorCollector(page, 'onboarding');
installDevErrorDialogHandler(page, 'onboarding');
await page.goto('/');
const onboarding = page.locator('onboarding-preset-selection');
await expect(onboarding).toBeVisible();
await expect(onboarding.locator('.preset-card')).toHaveCount(3);
await onboarding.getByRole('button', { name: /Simple Todo/ }).click();
await expect(onboarding).toBeHidden();
await expect(page.locator('task-list').first()).toBeVisible();
expect(
await page.evaluate(() => localStorage.getItem('SUP_ONBOARDING_PRESET_DONE')),
).toBe('true');
const taskTitle = `Simple Todo preset ${Date.now()}`;
await new WorkViewPage(page).addTask(taskTitle);
const task = page.locator('task').filter({ hasText: taskTitle }).first();
await expect(task).toBeVisible();
await task.hover();
await expect(task.locator('.start-task-btn')).toHaveCount(0);
await waitForStatePersistence(page);
await page.reload();
await expect(page.locator('task-list').first()).toBeVisible();
await expect(onboarding).toHaveCount(0);
const reloadedTask = page.locator('task').filter({ hasText: taskTitle }).first();
await expect(reloadedTask).toBeVisible();
await reloadedTask.hover();
await expect(reloadedTask.locator('.start-task-btn')).toHaveCount(0);
assertNoRuntimeBrowserErrors(runtimeErrors, 'onboarding');
await page.close();
});
});

View file

@ -13,11 +13,6 @@ test.describe('Doc Mode bundled plugin', () => {
const assetsAvailable = await waitForPluginAssets(page);
if (!assetsAvailable) {
if (process.env.CI) {
// Mirrors plugin-loading.spec.ts: assets may not be built in CI.
test.skip(true, 'Plugin assets not available in CI');
return;
}
throw new Error('Plugin assets not available — run `npm run prebuild`');
}

View file

@ -85,10 +85,6 @@ test.describe('Doc Mode Stage A migration', () => {
const assetsAvailable = await waitForPluginAssets(page);
if (!assetsAvailable) {
if (process.env.CI) {
test.skip(true, 'Plugin assets not available in CI');
return;
}
throw new Error('Plugin assets not available — run `npm run prebuild`');
}
@ -126,10 +122,6 @@ test.describe('Doc Mode Stage A migration', () => {
const assetsAvailable = await waitForPluginAssets(page);
if (!assetsAvailable) {
if (process.env.CI) {
test.skip(true, 'Plugin assets not available in CI');
return;
}
throw new Error('Plugin assets not available — run `npm run prebuild`');
}

View file

@ -13,7 +13,7 @@ test.describe('Project completion', () => {
await workViewPage.waitForTaskList();
});
test('complete a project and see the celebration', async ({ page }) => {
test('complete a project and reopen it from archived projects', async ({ page }) => {
// Arrange: a project with one done and one unfinished task
await projectPage.createProject('Test Project');
await projectPage.navigateToProjectByName('Test Project');
@ -56,11 +56,22 @@ test.describe('Project completion', () => {
await expect(celebration.getByText(/project complete/i)).toBeVisible();
await expect(celebration.getByText('Test Project')).toBeVisible();
// Close the celebration. There is no reopen/undo here — completion resolves
// tasks irreversibly; reactivation lives on the archived-projects page.
// Close the celebration and exercise the reactivation path.
await celebration.locator('.actions button').click();
await expect(celebration).toBeHidden();
await page.goto('/#/archived-projects');
const archivedProject = page
.locator('archived-projects-page .project-row')
.filter({ hasText: 'Test Project' });
await expect(archivedProject).toBeVisible();
await archivedProject.getByRole('button', { name: 'Reopen' }).click();
await expect(archivedProject).toHaveCount(0);
await projectPage.navigateToProjectByName('Test Project');
await expect(page).toHaveURL(/\/#\/project\/.+\/tasks/);
await expectNoGlobalError(page);
});
});

View file

@ -1,126 +1,43 @@
import { expect, test } from '../../fixtures/test.fixture';
import { ensureGlobalAddTaskBarOpen } from '../../utils/element-helpers';
/**
* Global Search E2E Tests
*
* Tests the search functionality:
* - Open search
* - Search for tasks
* - Navigate to search results
*/
const openGlobalSearch = async (page: import('@playwright/test').Page): Promise<void> => {
await page.keyboard.press('Shift+F');
await expect(page).toHaveURL(/\/#\/search$/);
await expect(page.locator('search-page')).toBeVisible();
};
test.describe('Global Search', () => {
test.describe.configure({ timeout: 30000 });
test('should open search with keyboard shortcut', async ({
test('opens from the configured keyboard shortcut and focuses the search field', async ({
page,
workViewPage,
testPrefix,
}) => {
await workViewPage.waitForTaskList();
// Create some tasks to search for
await workViewPage.addTask(`${testPrefix}-Searchable Task 1`);
await workViewPage.addTask(`${testPrefix}-Searchable Task 2`);
await openGlobalSearch(page);
// Use Ctrl+Shift+F or similar shortcut to open global search
await page.keyboard.press('Control+Shift+f');
await page.waitForTimeout(500);
// Just verify the app is still responsive (search shortcut may vary)
await expect(page.locator('task-list').first()).toBeVisible();
await expect(page.locator('search-page .search-field input')).toBeFocused();
});
test('should search for existing tasks', async ({ page, workViewPage, testPrefix }) => {
await workViewPage.waitForTaskList();
// Create a task with a unique name
const uniqueName = `${testPrefix}-UniqueSearchTerm`;
await workViewPage.addTask(uniqueName);
await expect(page.locator('task').filter({ hasText: uniqueName })).toBeVisible();
// Try to open search
await page.keyboard.press('Control+Shift+f');
await page.waitForTimeout(500);
const searchInput = page.locator('input[type="search"], command-bar input').first();
const isSearchOpen = await searchInput
.isVisible({ timeout: 3000 })
.catch(() => false);
if (isSearchOpen) {
// Type the search term
await searchInput.fill(uniqueName);
await page.waitForTimeout(500);
// Results should appear
const results = page.locator('.search-result, .autocomplete-option');
const hasResults = await results
.first()
.isVisible({ timeout: 3000 })
.catch(() => false);
if (hasResults) {
await expect(results.first()).toContainText(uniqueName);
}
}
});
test('should use autocomplete in add task bar', async ({
test('finds an existing task and navigates to it', async ({
page,
workViewPage,
taskPage,
testPrefix,
}) => {
await workViewPage.waitForTaskList();
// Create an initial task
const taskName = `${testPrefix}-AutoComplete Target`;
const taskName = `${testPrefix}-UniqueSearchResult`;
await workViewPage.addTask(taskName);
await expect(page.locator('task').filter({ hasText: taskName })).toBeVisible();
// Ensure the add task bar is open and get the input
const addTaskInput = await ensureGlobalAddTaskBarOpen(page);
await openGlobalSearch(page);
await page.locator('search-page .search-field input').fill(taskName);
// Type part of the task name
await addTaskInput.fill(testPrefix);
await page.waitForTimeout(500);
const result = page
.locator('search-page mat-list-item')
.filter({ hasText: taskName });
await expect(result).toHaveCount(1);
await result.click();
// Clear the input
await addTaskInput.clear();
await page.keyboard.press('Escape');
// Verify app is responsive
await expect(page.locator('task-list').first()).toBeVisible();
});
test('should filter tasks in current view', async ({
page,
workViewPage,
testPrefix,
}) => {
await workViewPage.waitForTaskList();
// Create multiple tasks
await workViewPage.addTask(`${testPrefix}-Alpha Task`);
await workViewPage.addTask(`${testPrefix}-Beta Task`);
await workViewPage.addTask(`${testPrefix}-Gamma Task`);
// All tasks should be visible
await expect(
page.locator('task').filter({ hasText: `${testPrefix}-Alpha` }),
).toBeVisible();
await expect(
page.locator('task').filter({ hasText: `${testPrefix}-Beta` }),
).toBeVisible();
await expect(
page.locator('task').filter({ hasText: `${testPrefix}-Gamma` }),
).toBeVisible();
// Verify task count
const taskCount = await page.locator('task').count();
expect(taskCount).toBeGreaterThanOrEqual(3);
await expect(page).toHaveURL(/\/#\/tag\/TODAY\/tasks/);
await expect(taskPage.getTaskByText(taskName)).toBeVisible();
});
});

View file

@ -1,11 +1,10 @@
import { test as base, expect } from '@playwright/test';
import { test as base, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
isServerHealthy,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import { ImportPage } from '../../pages/import.page';
@ -15,7 +14,7 @@ import { waitForAppReady } from '../../utils/waits';
* Import + Sync E2E Tests
*
* These tests verify that imported backup data syncs correctly between clients.
* This includes active tasks, archived tasks (worklog), and projects.
* This includes active tasks, subtasks, and replacement of existing state.
*
* Prerequisites:
* - super-sync-server running on localhost:1901 with TEST_MODE=true
@ -24,56 +23,30 @@ import { waitForAppReady } from '../../utils/waits';
* Run with: npm run e2e:playwright -- --grep @importsync
*/
const generateTestRunId = (workerIndex: number): string => {
return `${Date.now()}-${workerIndex}`;
};
/** Default encryption password used by setupSuperSync's mandatory encryption dialog */
const ENCRYPTION_PASSWORD = 'e2e-default-encryption-pw';
base.describe('@importsync @supersync Import + Sync E2E', () => {
let serverHealthy: boolean | null = null;
base.beforeEach(async ({}, testInfo) => {
if (serverHealthy === null) {
serverHealthy = await isServerHealthy();
if (!serverHealthy) {
console.warn(
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
);
}
}
testInfo.skip(!serverHealthy, 'SuperSync server not running');
});
/**
* Scenario: Import backup file and sync to second client
*
* This test verifies that importing a backup file creates sync operations
* that propagate all data (including archives) to other clients.
* that propagate the imported active data to other clients.
*
* Setup: Client A and B with shared SuperSync account, empty server
*
* Actions:
* 1. Client A imports JSON backup file containing:
* - Active tasks (with subtasks)
* - Archived tasks in archiveYoung/archiveOld
* - Projects and tags
* - Active tasks with a parent/subtask relationship
* 2. Client A syncs
* 3. Client B syncs
*
* Verify:
* - Client B has all active tasks
* - Client B has archived tasks in worklog view
* - Projects and tags match
* - Client B has all active tasks and the imported subtask relationship
*/
// SKIPPED: Pre-existing issue. After configuring sync and then importing a backup,
// the imported state doesn't persist - likely overwritten by sync activity.
// The import+sync interaction needs investigation in the app code itself.
base.skip(
base(
'Import backup file on Client A, sync to Client B',
async ({ browser, baseURL }, testInfo) => {
const testRunId = generateTestRunId(testInfo.workerIndex);
async ({ browser, baseURL, testRunId }) => {
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
@ -85,7 +58,11 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
// ============ PHASE 1: Client A Sets Up Sync and Imports Backup ============
console.log('[Import Test] Phase 1: Client A importing backup');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
await clientA.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: ENCRYPTION_PASSWORD,
});
// Navigate to import page
const importPage = new ImportPage(clientA.page);
@ -114,7 +91,12 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
// Re-enable sync after import (import overwrites globalConfig including sync settings)
console.log('[Import Test] Re-enabling sync after import');
await clientA.sync.setupSuperSync(syncConfig);
await clientA.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: ENCRYPTION_PASSWORD,
syncImportChoice: 'local',
});
// ============ PHASE 2: Client A Syncs to Server ============
console.log('[Import Test] Phase 2: Client A syncing to server');
@ -149,63 +131,33 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
await waitForTask(clientB.page, 'E2E Import Test - Simple Active Task');
// Verify both active tasks are visible
const activeTask1 = clientB.page.locator(
'task:has-text("E2E Import Test - Active Task With Subtask")',
);
const activeTask2 = clientB.page.locator(
'task:has-text("E2E Import Test - Simple Active Task")',
);
const activeTask1 = clientB.page
.locator('task:has-text("E2E Import Test - Active Task With Subtask")')
.first();
const activeTask2 = clientB.page
.locator('task:has-text("E2E Import Test - Simple Active Task")')
.first();
await expect(activeTask1).toBeVisible({ timeout: 10000 });
await expect(activeTask2).toBeVisible({ timeout: 10000 });
console.log('[Import Test] Client B has both active tasks');
// Expand parent task to see subtask
const expandBtn = activeTask1.locator('.expand-btn');
if (await expandBtn.isVisible()) {
await expandBtn.click();
await waitForTask(clientB.page, 'E2E Import Test - Subtask of Active Task');
console.log('[Import Test] Client B has subtask visible');
}
// The imported subtask is nested under its parent and follows the
// parent's visibility toggle, proving the relationship survived sync.
const subTask = activeTask1
.locator('task')
.filter({ hasText: 'E2E Import Test - Subtask of Active Task' })
.first();
const toggleSubTasksBtn = activeTask1.locator('.toggle-sub-tasks-btn');
await expect(toggleSubTasksBtn).toBeVisible();
await expect(subTask).toBeVisible();
await toggleSubTasksBtn.click();
await expect(subTask).toBeHidden();
await toggleSubTasksBtn.click();
await expect(subTask).toBeVisible();
console.log('[Import Test] Client B has subtask visible');
// ============ PHASE 5: Verify Archived Tasks in Worklog ============
console.log('[Import Test] Phase 5: Verifying archived tasks in worklog');
// Navigate to worklog on Client B
await clientB.page.goto('/#/tag/TODAY/history');
await clientB.page.waitForLoadState('networkidle');
await clientB.page.waitForSelector('history', { timeout: 10000 });
// Expand worklog to see archived tasks
const weekRow = clientB.page.locator('.week-row').first();
if (await weekRow.isVisible()) {
await weekRow.click();
await clientB.page.waitForTimeout(500);
}
// Archived tasks should appear in the worklog
// The test backup has archived tasks with titles containing "E2E Import Test - Archived"
// Note: Archived tasks appear in worklog if they have timeSpentOnDay entries
// Our test backup has archived tasks with time entries
console.log('[Import Test] Checking for archived tasks in worklog...');
// Try to find archived task entries - they appear under the dates they were worked on
const hasArchivedTasks =
(await clientB.page
.locator('.task-summary-table .task-title')
.count()
.catch(() => 0)) > 0;
if (hasArchivedTasks) {
console.log('[Import Test] Client B worklog has archived task entries');
} else {
// Archive data may not show in TODAY tag worklog - navigate to project worklog
await clientB.page.goto('/#/project/test-project-1/history');
await clientB.page.waitForLoadState('networkidle');
console.log('[Import Test] Checking project worklog for archived tasks');
}
// ============ PHASE 6: Final Verification ============
console.log('[Import Test] Phase 6: Final state verification');
// ============ PHASE 5: Final Verification ============
console.log('[Import Test] Phase 5: Final state verification');
// Go back to project view on both clients and wait for app to be ready
await clientA.page.goto('/#/project/INBOX_PROJECT/tasks');
@ -237,10 +189,10 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
);
/**
* Scenario: Import with existing data (merge scenario)
* Scenario: Import with existing data (replacement scenario)
*
* Tests that importing a backup on one client merges with
* existing data on another client after sync.
* Tests that importing a backup on one client replaces the old baseline on
* every client after sync.
*
* Actions:
* 1. Client A creates a task "Existing Task A"
@ -252,17 +204,12 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
* 7. Client B syncs
*
* Verify:
* - Client A has: imported tasks + Existing Task A + Existing Task B
* - Client B has: imported tasks + Existing Task A + Existing Task B
* - Both clients have the imported tasks
* - Neither client keeps Existing Task A or Existing Task B
*/
// SKIPPED: Pre-existing issue. After backup import on Client A with existing synced data,
// the imported tasks don't appear in the TODAY view. The BACKUP_IMPORT operation's
// interaction with pre-existing server operations needs investigation.
// The simpler "Import backup file" test (above) verifies import+sync on a clean server.
base.skip(
'Import merges with existing synced data',
async ({ browser, baseURL }, testInfo) => {
const testRunId = generateTestRunId(testInfo.workerIndex);
base(
'Import replaces existing synced data on all clients',
async ({ browser, baseURL, testRunId }) => {
const uniqueId = Date.now();
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
@ -275,7 +222,11 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
console.log('[Merge Test] Phase 1: Creating initial tasks');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
await clientA.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: ENCRYPTION_PASSWORD,
});
// Client A creates a task
const existingTaskA = `ExistingA-${uniqueId}`;
@ -328,7 +279,12 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
// Re-enable sync after import (import overwrites globalConfig including sync settings)
console.log('[Merge Test] Re-enabling sync after import');
await clientA.sync.setupSuperSync(syncConfig);
await clientA.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: ENCRYPTION_PASSWORD,
syncImportChoice: 'local',
});
// ============ PHASE 3: Sync After Import ============
console.log('[Merge Test] Phase 3: Syncing after import');
@ -344,6 +300,14 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
});
await waitForAppReady(clientB.page);
for (const client of [clientA, clientB]) {
await client.page.goto('/#/project/INBOX_PROJECT/tasks', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await waitForAppReady(client.page);
}
// ============ PHASE 4: Verify Merged Data ============
console.log('[Merge Test] Phase 4: Verifying merged data');
@ -355,11 +319,16 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
console.log('[Merge Test] Client B received imported tasks via sync');
// Note: The import replaces state rather than merging, so existing tasks
// may be gone after import. This test documents the actual behavior.
// If merge behavior is desired, the app would need to implement it differently.
for (const client of [clientA, clientB]) {
await expect(
client.page.locator(`task:has-text("${existingTaskA}")`),
).not.toBeVisible();
await expect(
client.page.locator(`task:has-text("${existingTaskB}")`),
).not.toBeVisible();
}
console.log('[Merge Test] ✓ Import merge test passed!');
console.log('[Merge Test] ✓ Import replacement test passed!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);

View file

@ -14,21 +14,13 @@ import * as path from 'path';
import * as os from 'os';
/**
* SuperSync Backup Import ID Mismatch Bug Reproduction Test
* SuperSync Backup Import Replay Regression Tests
*
* BUG: When a backup is imported and synced, the client creates a BACKUP_IMPORT
* operation with a local ID. However, uploadSnapshot() does NOT send this ID to
* the server - the server generates its own ID. When the client later downloads
* operations, it doesn't recognize the server's BACKUP_IMPORT (different ID) as
* a duplicate, so it RE-APPLIES the old backup state, overwriting any tasks
* created after the import.
*
* ROOT CAUSE: Missing op.id parameter in uploadSnapshot() interface at
* src/app/op-log/sync-providers/provider.interface.ts:277-284
*
* EXPECTED: Tasks created after backup import should survive subsequent syncs.
* ACTUAL (BUG): Tasks created after backup import are LOST because the old
* BACKUP_IMPORT state is re-applied.
* The user-facing invariant is that tasks created after a backup import survive
* later syncs, including concurrent sync and repeated download cycles. The exact
* snapshot-operation ID forwarding is covered at the upload-service boundary;
* mandatory SuperSync encryption replaces the initial snapshot before a browser
* test can observe that transport-level detail directly.
*
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-backup-import-id-mismatch.spec.ts
*/
@ -149,186 +141,7 @@ const importBackup = async (
return !importFailed;
};
test.describe('@supersync Backup Import ID Mismatch Bug', () => {
/**
* Verifies that BACKUP_IMPORT operation IDs match between client and server.
*
* KNOWN LIMITATION: SuperSync's mandatory encryption creates a clean-slate
* SYNC_IMPORT that overwrites the initial BACKUP_IMPORT on the server.
* The upload code correctly sends op.id and snapshotOpType, but the
* encryption setup always creates a replacement SYNC_IMPORT with a new ID.
* This test cannot pass until encryption setup preserves the original op ID.
*
* The user-facing behavior (tasks surviving after backup import) is verified
* by the other tests in this file ('Tasks should survive...' and 'Multiple syncs...').
*/
test.fixme('Server stores BACKUP_IMPORT with matching client ID', async ({
browser,
baseURL,
testRunId,
}) => {
let clientA: SimulatedE2EClient | null = null;
let backupPath: string | null = null;
try {
const user = await createTestUser(testRunId + '-idmismatch');
const syncConfig = getSuperSyncConfig(user);
// ============ PHASE 1: Create task and export backup (no sync yet) ============
console.log('[ID Mismatch Test] Phase 1: Create task and export backup');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
// Create initial task that will be in the backup
const initialTaskName = `InitialTask-${testRunId}`;
await clientA.workView.addTask(initialTaskName);
await waitForTask(clientA.page, initialTaskName);
backupPath = await exportBackup(clientA.page);
console.log(`[ID Mismatch Test] Exported backup`);
// ============ PHASE 2: Import backup (creates BACKUP_IMPORT locally) ============
console.log('[ID Mismatch Test] Phase 2: Import backup');
const importSuccess = await importBackup(clientA.page, backupPath);
if (!importSuccess) {
throw new Error('Backup import failed');
}
console.log(
'[ID Mismatch Test] Backup imported (BACKUP_IMPORT created with local ID)',
);
// ============ PHASE 3: Get local BACKUP_IMPORT op ID from IndexedDB ============
console.log('[ID Mismatch Test] Phase 3: Getting local BACKUP_IMPORT op ID');
// Wait for IndexedDB writes to complete
await clientA.page.waitForTimeout(1000);
const localOpData = await clientA.page.evaluate(async () => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('SUP_OPS');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
interface CompactOp {
id: string;
o: string; // opType
a: string; // actionType short code
e: string; // entityType
}
const ops = await new Promise<Array<{ seq: number; op: CompactOp }>>(
(resolve, reject) => {
const tx = db.transaction('ops', 'readonly');
const store = tx.objectStore('ops');
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
},
);
db.close();
const debugInfo = ops.map((entry) => ({
seq: entry.seq,
opType: entry.op?.o,
entityType: entry.op?.e,
id: entry.op?.id?.substring(0, 20),
}));
const backupImportOp = ops.find((entry) => entry.op?.o === 'BACKUP_IMPORT');
return {
opId: backupImportOp?.op.id || null,
debugInfo,
totalOps: ops.length,
};
});
console.log(
`[ID Mismatch Test] Debug: Found ${localOpData.totalOps} ops:`,
JSON.stringify(localOpData.debugInfo),
);
const localOpId = localOpData.opId;
console.log(`[ID Mismatch Test] Local BACKUP_IMPORT op ID: ${localOpId}`);
expect(localOpId).toBeTruthy();
// ============ PHASE 4: Enable sync for the FIRST time (server is empty) ============
// IMPORTANT: By enabling sync on a fresh server, the pending BACKUP_IMPORT
// gets uploaded directly as a snapshot (no existing server data to conflict with).
console.log('[ID Mismatch Test] Phase 4: Enabling sync (server is empty)');
await clientA.sync.setupSuperSync(syncConfig);
await clientA.sync.syncAndWait();
console.log('[ID Mismatch Test] Sync completed - BACKUP_IMPORT uploaded to server');
// ============ PHASE 5: Query server for the operation ID it stored ============
console.log('[ID Mismatch Test] Phase 5: Querying server for stored operation ID');
// Query for BACKUP_IMPORT first, fall back to any snapshot op
let serverOpId: string | null = null;
let serverOpType: string | null = null;
try {
// Try BACKUP_IMPORT first
let opsResponse = await fetch(
`http://localhost:1901/api/test/user/${user.userId}/ops?opType=BACKUP_IMPORT&limit=1`,
);
if (opsResponse.ok) {
const opsData = (await opsResponse.json()) as {
ops: Array<{ id: string; opType: string }>;
};
console.log(`[ID Mismatch Test] BACKUP_IMPORT ops:`, JSON.stringify(opsData));
if (opsData.ops.length > 0) {
serverOpId = opsData.ops[0].id;
serverOpType = opsData.ops[0].opType;
}
}
// If no BACKUP_IMPORT found, check for SYNC_IMPORT (in case opType mapping differs)
if (!serverOpId) {
opsResponse = await fetch(
`http://localhost:1901/api/test/user/${user.userId}/ops?opType=SYNC_IMPORT&limit=1`,
);
if (opsResponse.ok) {
const opsData = (await opsResponse.json()) as {
ops: Array<{ id: string; opType: string }>;
};
console.log(`[ID Mismatch Test] SYNC_IMPORT ops:`, JSON.stringify(opsData));
if (opsData.ops.length > 0) {
serverOpId = opsData.ops[0].id;
serverOpType = opsData.ops[0].opType;
}
}
}
} catch (err) {
console.error(`[ID Mismatch Test] Server ops query failed:`, err);
}
console.log(`[ID Mismatch Test] Server op: type=${serverOpType}, id=${serverOpId}`);
// ============ PHASE 6: CRITICAL ASSERTION - IDs should match ============
console.log('[ID Mismatch Test] Phase 6: Comparing local and server op IDs');
console.log(`[ID Mismatch Test] Local ID: ${localOpId}`);
console.log(`[ID Mismatch Test] Server ID: ${serverOpId}`);
expect(serverOpId).toBeTruthy();
expect(
localOpId,
'Local BACKUP_IMPORT op ID should match server op ID. ' +
`Local: ${localOpId}, Server: ${serverOpId} (type: ${serverOpType})`,
).toBe(serverOpId);
console.log('[ID Mismatch Test] ✓ IDs MATCH!');
} finally {
if (clientA) await closeClient(clientA);
if (backupPath && fs.existsSync(backupPath)) {
fs.unlinkSync(backupPath);
}
}
});
test.describe('@supersync Backup Import Replay Regression', () => {
/**
* CRITICAL BUG REPRODUCTION TEST - With concurrent client causing rejection
*

View file

@ -51,10 +51,7 @@ test.describe('@supersync @import SYNC_IMPORT Clean Server State', () => {
* - Client B does NOT have the old data (Task-OLD)
* - Client B does NOT have old archived tasks in worklog
*/
// TODO: Skipped — BACKUP_IMPORT with isCleanSlate=true is not clearing old archived data on the server.
// The server receives the clean slate flag but Client B still sees old archived tasks.
// Needs server-side investigation.
test.skip('SYNC_IMPORT clears server state for fresh client joining later', async ({
test('SYNC_IMPORT clears server state for fresh client joining later', async ({
browser,
baseURL,
testRunId,
@ -194,10 +191,7 @@ test.describe('@supersync @import SYNC_IMPORT Clean Server State', () => {
* - Client C has ONLY Backup-2 data
* - Client C does NOT have Backup-1 remnants
*/
// TODO: Skipped — BACKUP_IMPORT with isCleanSlate=true is not clearing old data on the server.
// The second import's clean slate doesn't remove marker tasks from the first import.
// Needs server-side investigation.
test.skip('Multiple SYNC_IMPORTs overwrite each other cleanly', async ({
test('Multiple SYNC_IMPORTs overwrite each other cleanly', async ({
browser,
baseURL,
testRunId,
@ -322,10 +316,7 @@ test.describe('@supersync @import SYNC_IMPORT Clean Server State', () => {
* Verify:
* - Client B does NOT have OLD archived task
*/
// TODO: Skipped — BACKUP_IMPORT with isCleanSlate=true is not clearing old archived data on the server.
// Client B still receives old archived tasks despite the clean slate flag.
// Needs server-side investigation.
test.skip('SYNC_IMPORT properly clears old archived data from server', async ({
test('SYNC_IMPORT properly clears old archived data from server', async ({
browser,
baseURL,
testRunId,

View file

@ -10,60 +10,24 @@ import {
import { ImportPage } from '../../pages/import.page';
/**
* SuperSync Import with Encryption State Change E2E Tests
*
* These tests verify that importing data with different encryption settings
* properly handles the encryption state change:
* - Server data is wiped (encrypted ops can't mix with unencrypted)
* - A fresh snapshot is uploaded with correct encryption settings
* - Other clients can sync with the new encryption state
*
* This is the "tabula rasa" behavior for encryption state changes during import.
*
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-import-encryption-change.spec.ts
* SuperSync requires encryption. Importing an older backup whose global sync
* config says encryption was disabled must not disable it or mix the old server
* history with the imported state.
*/
test.describe('@supersync @encryption Import with Encryption State Change', () => {
/**
* Scenario: Import unencrypted backup while encrypted sync is active
*
* This tests the critical case where:
* - Client has encryption enabled with existing encrypted data on server
* - Client imports a backup that doesn't have encryption enabled
* - Server should be wiped and fresh unencrypted snapshot uploaded
* - New clients should sync without encryption
*
* Setup: Client A with encryption enabled
*
* Actions:
* 1. Client A sets up SuperSync with encryption enabled
* 2. Client A creates task "EncryptedTask" and syncs (encrypted)
* 3. Client A imports backup (has encryption disabled)
* 4. Server data should be wiped and unencrypted snapshot uploaded
* 5. Client B sets up SuperSync WITHOUT encryption
* 6. Client B syncs and should get imported data
*
* Verify:
* - Client A has imported tasks (not EncryptedTask)
* - Client B can sync WITHOUT encryption and has imported tasks
* - No encryption errors occur
*/
test('Import unencrypted backup while encrypted sync is active', async ({
test.describe('@supersync @encryption Import preserves mandatory encryption', () => {
test('legacy unencrypted backup keeps SuperSync encrypted and replaces server state', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = Date.now();
const encryptionPassword = `pass-${testRunId}`;
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const baseConfig = getSuperSyncConfig(user);
const encryptionPassword = `pass-${testRunId}`;
// ============ PHASE 1: Setup Client A with Encryption ============
console.log('[EncryptionChange] Phase 1: Setting up Client A with encryption');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync({
@ -72,472 +36,55 @@ test.describe('@supersync @encryption Import with Encryption State Change', () =
password: encryptionPassword,
});
// Create and sync encrypted task
const encryptedTask = `EncryptedTask-${uniqueId}`;
await clientA.workView.addTask(encryptedTask);
const taskBeforeImport = `BeforeImport-${uniqueId}`;
await clientA.workView.addTask(taskBeforeImport);
await clientA.sync.syncAndWait();
console.log(`[EncryptionChange] Client A created encrypted: ${encryptedTask}`);
// Verify task exists
await waitForTask(clientA.page, encryptedTask);
// ============ PHASE 2: Import Unencrypted Backup ============
console.log('[EncryptionChange] Phase 2: Client A importing unencrypted backup');
// Navigate to import page
const importPage = new ImportPage(clientA.page);
await importPage.navigateToImportPage();
// Import the backup file (has encryption disabled)
const backupPath = ImportPage.getFixturePath('test-backup.json');
// Set file on input (this triggers the import flow)
const fileInput = clientA.page.locator('file-imex input[type="file"]');
// Start listening for import completion BEFORE triggering the import
const importCompletePromise = clientA.page.waitForEvent('console', {
predicate: (msg) => msg.text().includes('Load(import) all data'),
timeout: 60000,
});
await fileInput.setInputFiles(backupPath);
// Handle "Encryption Settings Will Change" dialog that appears when
// importing a backup with different encryption settings
const encryptionChangeDialog = clientA.page.locator(
'mat-dialog-container:has-text("Encryption Settings Will Change")',
);
await encryptionChangeDialog.waitFor({ state: 'visible', timeout: 10000 });
console.log('[EncryptionChange] Encryption change dialog appeared');
const importAndChangeBtn = encryptionChangeDialog.locator(
'button:has-text("Import and Change Encryption")',
);
await importAndChangeBtn.click();
console.log('[EncryptionChange] Clicked Import and Change Encryption');
// Wait for dialog to close and import to complete
await encryptionChangeDialog.waitFor({ state: 'hidden', timeout: 15000 });
// Wait for import to complete
await clientA.page
.locator('file-imex input[type="file"]')
.setInputFiles(ImportPage.getFixturePath('test-backup.json'));
await importCompletePromise;
console.log('[EncryptionChange] Client A imported unencrypted backup');
// Navigate to work view
await clientA.page.goto('/#/work-view');
await clientA.page.waitForLoadState('networkidle');
await expect(
clientA.page.locator(
'mat-dialog-container:has-text("Encryption Settings Will Change")',
),
).toHaveCount(0);
// Wait for imported tasks to be visible
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
console.log('[EncryptionChange] Client A has imported tasks visible after import');
// Re-setup sync after import (import overwrites globalConfig)
// Use encryption disabled since the imported backup has encryption disabled
await clientA.sync.setupSuperSync({
...baseConfig,
isEncryptionEnabled: false,
syncImportChoice: 'local',
});
console.log('[EncryptionChange] Client A re-enabled sync without encryption');
// ============ PHASE 3: Sync After Import ============
console.log('[EncryptionChange] Phase 3: Client A syncing after import');
await clientA.sync.syncAndWait();
console.log('[EncryptionChange] Client A synced successfully');
// ============ PHASE 4: Client B Syncs Without Encryption ============
console.log('[EncryptionChange] Phase 4: Client B syncing without encryption');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
// Setup WITHOUT encryption - should work since import disabled encryption.
// Pass decryptionFailedPassword so the "Decryption Failed" dialog (caused by
// old encrypted ops still on the server) can be handled by entering the old password.
await clientB.sync.setupSuperSync({
...baseConfig,
isEncryptionEnabled: false,
decryptionFailedPassword: encryptionPassword,
isEncryptionEnabled: true,
password: encryptionPassword,
});
await clientB.sync.syncAndWait();
console.log('[EncryptionChange] Client B synced successfully');
// ============ PHASE 5: Verify Clean Slate ============
console.log('[EncryptionChange] Phase 5: Verifying encryption state change');
// Navigate to work view on both clients
await clientA.page.goto('/#/work-view');
await clientA.page.waitForLoadState('networkidle');
await clientB.page.goto('/#/work-view');
await clientB.page.waitForLoadState('networkidle');
// Wait for imported task to appear on both
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
// CRITICAL: Original encrypted task should be GONE
const encryptedTaskOnA = clientA.page.locator(`task:has-text("${encryptedTask}")`);
const encryptedTaskOnB = clientB.page.locator(`task:has-text("${encryptedTask}")`);
await expect(encryptedTaskOnA).not.toBeVisible({ timeout: 5000 });
await expect(encryptedTaskOnB).not.toBeVisible({ timeout: 5000 });
console.log('[EncryptionChange] ✓ Original encrypted task is GONE');
// Verify imported tasks are present on both clients
// Use .first() because the project page may show the task in both backlog and active sections
const importedTaskOnA = clientA.page
.locator('task:has-text("E2E Import Test - Active Task With Subtask")')
.first();
const importedTaskOnB = clientB.page
.locator('task:has-text("E2E Import Test - Active Task With Subtask")')
.first();
await expect(importedTaskOnA).toBeVisible({ timeout: 5000 });
await expect(importedTaskOnB).toBeVisible({ timeout: 5000 });
console.log('[EncryptionChange] ✓ Both clients have imported tasks');
// Verify no encryption errors on Client B (would indicate encrypted data leaked)
const hasError = await clientB.sync.hasSyncError();
expect(hasError).toBe(false);
console.log('[EncryptionChange] ✓ No encryption errors on Client B');
console.log('[EncryptionChange] ✓ Import encryption change test PASSED!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario: Import encrypted backup while unencrypted sync is active
*
* Tests the reverse scenario where:
* - Client has unencrypted sync active
* - Client imports a backup that has encryption enabled
* - Server should be wiped and encrypted snapshot uploaded
*
* Setup: Client A with unencrypted sync
*
* Actions:
* 1. Client A sets up SuperSync without encryption
* 2. Client A creates task "UnencryptedTask" and syncs
* 3. Client A imports backup (has encryption enabled in globalConfig.sync)
* 4. After import, user must provide encryption password for sync
* 5. Server data should be wiped and encrypted snapshot uploaded
* 6. Client B sets up SuperSync WITH encryption using same password
* 7. Client B syncs and should get imported data
*
* Verify:
* - Client A has imported tasks (not UnencryptedTask)
* - Client B can sync WITH encryption and has imported tasks
* - No encryption errors occur
*/
// TODO: Skip — server keeps old unencrypted ops after SYNC_IMPORT (isCleanSlate=true
// doesn't clear server data). Client B with encryption enabled can't decrypt old
// unencrypted ops — no password works. Needs server-side fix to wipe old data on SYNC_IMPORT.
test.skip('Import encrypted backup while unencrypted sync is active', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = Date.now();
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const baseConfig = getSuperSyncConfig(user);
const encryptionPassword = `pass-${testRunId}`;
// ============ PHASE 1: Setup Client A WITHOUT Encryption ============
console.log(
'[EncryptionChange-Reverse] Phase 1: Setting up Client A without encryption',
);
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync({
...baseConfig,
isEncryptionEnabled: false,
});
// Create and sync unencrypted task
const unencryptedTask = `UnencryptedTask-${uniqueId}`;
await clientA.workView.addTask(unencryptedTask);
await clientA.sync.syncAndWait();
console.log(
`[EncryptionChange-Reverse] Client A created unencrypted: ${unencryptedTask}`,
);
// Verify task exists
await waitForTask(clientA.page, unencryptedTask);
// ============ PHASE 2: Import Encrypted Backup ============
console.log(
'[EncryptionChange-Reverse] Phase 2: Client A importing encrypted backup',
);
// Navigate to import page
const importPage = new ImportPage(clientA.page);
await importPage.navigateToImportPage();
// Import the encrypted backup file (has encryption enabled in globalConfig.sync)
const backupPath = ImportPage.getFixturePath('test-backup-encrypted.json');
// Set file on input (this triggers the import flow)
const fileInput = clientA.page.locator('file-imex input[type="file"]');
// Start listening for import completion BEFORE triggering the import
const importCompletePromise = clientA.page.waitForEvent('console', {
predicate: (msg) => msg.text().includes('Load(import) all data'),
timeout: 60000,
});
await fileInput.setInputFiles(backupPath);
// The "Encryption Settings Will Change" dialog may or may not appear.
// It depends on whether the app detects the encryption mismatch.
// Wait to see if either the dialog appears or the import completes directly.
const encryptionChangeDialog = clientA.page.locator(
'mat-dialog-container:has-text("Encryption Settings Will Change")',
);
const importOutcome = await Promise.race([
encryptionChangeDialog
.waitFor({ state: 'visible', timeout: 10000 })
.then(() => 'encryption_dialog' as const),
importCompletePromise.then(() => 'import_completed' as const),
]).catch(() => 'timeout' as const);
if (importOutcome === 'encryption_dialog') {
console.log('[EncryptionChange-Reverse] Encryption change dialog appeared');
const importAndChangeBtn = encryptionChangeDialog.locator(
'button:has-text("Import and Change Encryption")',
);
await importAndChangeBtn.click();
console.log('[EncryptionChange-Reverse] Clicked Import and Change Encryption');
await encryptionChangeDialog.waitFor({ state: 'hidden', timeout: 15000 });
await importCompletePromise;
} else if (importOutcome === 'import_completed') {
console.log(
'[EncryptionChange-Reverse] Import completed without encryption dialog',
);
} else {
throw new Error('Import timed out waiting for completion');
}
console.log('[EncryptionChange-Reverse] Client A imported encrypted backup');
// Navigate to work view
await clientA.page.goto('/#/work-view');
await clientA.page.waitForLoadState('networkidle');
// Wait for imported tasks to be visible
await waitForTask(clientA.page, 'E2E Import Test - Encrypted Task With Subtask');
console.log(
'[EncryptionChange-Reverse] Client A has imported tasks visible after import',
);
// Re-setup sync after import with encryption enabled
// The backup had isEncryptionEnabled: true, so we need to provide a password
await clientA.sync.setupSuperSync({
...baseConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
syncImportChoice: 'local',
});
console.log('[EncryptionChange-Reverse] Client A re-enabled sync with encryption');
// ============ PHASE 3: Sync After Import ============
console.log('[EncryptionChange-Reverse] Phase 3: Client A syncing after import');
await clientA.sync.syncAndWait();
console.log('[EncryptionChange-Reverse] Client A synced successfully');
// ============ PHASE 4: Client B Syncs With Encryption ============
console.log('[EncryptionChange-Reverse] Phase 4: Client B syncing with encryption');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
// Setup WITH encryption - should work since import enabled encryption
await clientB.sync.setupSuperSync({
...baseConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
});
await clientB.sync.syncAndWait();
console.log('[EncryptionChange-Reverse] Client B synced successfully');
// ============ PHASE 5: Verify Clean Slate ============
console.log(
'[EncryptionChange-Reverse] Phase 5: Verifying encryption state change',
);
// Navigate to work view on both clients
await clientA.page.goto('/#/work-view');
await clientA.page.waitForLoadState('networkidle');
await clientB.page.goto('/#/work-view');
await clientB.page.waitForLoadState('networkidle');
// Wait for imported task to appear on both
await waitForTask(clientA.page, 'E2E Import Test - Encrypted Task With Subtask');
await waitForTask(clientB.page, 'E2E Import Test - Encrypted Task With Subtask');
// CRITICAL: Original unencrypted task should be GONE
const unencryptedTaskOnA = clientA.page.locator(
`task:has-text("${unencryptedTask}")`,
);
const unencryptedTaskOnB = clientB.page.locator(
`task:has-text("${unencryptedTask}")`,
);
await expect(unencryptedTaskOnA).not.toBeVisible({ timeout: 5000 });
await expect(unencryptedTaskOnB).not.toBeVisible({ timeout: 5000 });
console.log('[EncryptionChange-Reverse] Original unencrypted task is GONE');
// Verify imported tasks are present on both clients
// Use .first() because the project page may show the task in both backlog and active sections
const importedTaskOnA = clientA.page
.locator('task:has-text("E2E Import Test - Encrypted Task With Subtask")')
.first();
const importedTaskOnB = clientB.page
.locator('task:has-text("E2E Import Test - Encrypted Task With Subtask")')
.first();
await expect(importedTaskOnA).toBeVisible({ timeout: 5000 });
await expect(importedTaskOnB).toBeVisible({ timeout: 5000 });
console.log('[EncryptionChange-Reverse] Both clients have imported tasks');
// Verify no encryption errors on Client B
const hasError = await clientB.sync.hasSyncError();
expect(hasError).toBe(false);
console.log('[EncryptionChange-Reverse] No encryption errors on Client B');
console.log('[EncryptionChange-Reverse] Import encrypted backup test PASSED!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario: Bidirectional sync works after encryption state change via import
*
* After importing with encryption change, verify that:
* - Both clients can create new tasks
* - Tasks sync correctly in both directions
* - No encryption mismatches occur
*/
test('Bidirectional sync works after encryption change via import', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = Date.now();
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const baseConfig = getSuperSyncConfig(user);
const encryptionPassword = `pass-${testRunId}`;
// ============ Setup with encryption, then import unencrypted ============
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync({
...baseConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
});
// Initial sync to establish account
await clientA.sync.syncAndWait();
// Import unencrypted backup
const importPage = new ImportPage(clientA.page);
await importPage.navigateToImportPage();
const backupPath = ImportPage.getFixturePath('test-backup.json');
// Set file on input (this triggers the import flow)
const fileInput = clientA.page.locator('file-imex input[type="file"]');
// Start listening for import completion BEFORE triggering the import
const importCompletePromise = clientA.page.waitForEvent('console', {
predicate: (msg) => msg.text().includes('Load(import) all data'),
timeout: 60000,
});
await fileInput.setInputFiles(backupPath);
// Handle "Encryption Settings Will Change" dialog
const encryptionChangeDialog = clientA.page.locator(
'mat-dialog-container:has-text("Encryption Settings Will Change")',
);
await encryptionChangeDialog.waitFor({ state: 'visible', timeout: 10000 });
const importAndChangeBtn = encryptionChangeDialog.locator(
'button:has-text("Import and Change Encryption")',
);
await importAndChangeBtn.click();
await encryptionChangeDialog.waitFor({ state: 'hidden', timeout: 15000 });
// Wait for import to complete
await importCompletePromise;
await clientA.page.goto('/#/work-view');
await clientA.page.waitForLoadState('networkidle');
// Wait for imported tasks BEFORE re-configuring sync
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
// Re-configure without encryption and sync
await clientA.sync.setupSuperSync({
...baseConfig,
isEncryptionEnabled: false,
syncImportChoice: 'local',
});
await clientA.sync.syncAndWait();
// ============ Setup Client B without encryption ============
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
// Pass decryptionFailedPassword so the "Decryption Failed" dialog (caused by
// old encrypted ops still on the server) can be handled by entering the old password.
await clientB.sync.setupSuperSync({
...baseConfig,
isEncryptionEnabled: false,
decryptionFailedPassword: encryptionPassword,
});
await clientB.sync.syncAndWait();
// Navigate to work view after setup
await clientB.page.goto('/#/work-view');
await clientB.page.waitForLoadState('networkidle');
// Wait for Client B to have imported data
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
// ============ Bidirectional sync test ============
// Client A creates a task
const taskFromA = `FromA-${uniqueId}`;
await clientA.workView.addTask(taskFromA);
await clientA.sync.syncAndWait();
// Client B syncs and should see A's task
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, taskFromA);
// Client B creates a task
const taskFromB = `FromB-${uniqueId}`;
await clientB.workView.addTask(taskFromB);
await clientB.sync.syncAndWait();
// Client A syncs and should see B's task
await clientA.sync.syncAndWait();
await waitForTask(clientA.page, taskFromB);
// Verify both clients have both tasks
await expect(clientA.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible();
await expect(clientA.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible();
await expect(clientB.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible();
await expect(clientB.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible();
console.log('[BidiSync] ✓ Bidirectional sync works after encryption change!');
await expect(
clientB.page.locator(`task:has-text("${taskBeforeImport}")`),
).not.toBeVisible();
await expect(
clientB.page
.locator('task:has-text("E2E Import Test - Active Task With Subtask")')
.first(),
).toBeVisible();
await expect(clientB.sync.hasSyncError()).resolves.toBe(false);
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);

View file

@ -3,9 +3,11 @@ import { SuperSyncPage } from '../../pages/supersync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { waitForStatePersistence } from '../../utils/waits';
import {
closeClient,
createSimulatedClient,
createTestUser,
getSuperSyncConfig,
isServerHealthy,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import {
createLegacyMigratedClient,
@ -32,14 +34,6 @@ import legacyDataCollisionB from '../../fixtures/legacy-migration-collision-b.js
test.describe('@supersync @migration SuperSync Legacy Migration Sync', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
const healthy = await isServerHealthy();
if (!healthy) {
console.warn('SuperSync server not healthy. Skipping SuperSync tests.');
test.skip(true, 'SuperSync server not healthy');
}
});
/**
* Test: Both clients migrated from legacy - Keep local resolution
*
@ -480,12 +474,8 @@ test.describe('@supersync @migration SuperSync Legacy Migration Sync', () => {
* Verifies that archived tasks from legacy data survive the migration
* process and can be synced to other clients.
*
* Note: This test is skipped because the fresh client sync flow has
* timing issues with the setupSuperSync method. Archive sync is already
* covered by other SuperSync tests. The core legacy migration scenarios
* (tests 1-3) demonstrate the main functionality.
*/
test.skip('verify archive data is preserved after migration + sync', async ({
test('verify archive data is preserved after migration + sync', async ({
browser,
baseURL,
testRunId,
@ -500,10 +490,7 @@ test.describe('@supersync @migration SuperSync Legacy Migration Sync', () => {
context: Awaited<ReturnType<typeof browser.newContext>>;
page: Awaited<ReturnType<typeof browser.newPage>>;
} | null = null;
let clientB: {
context: Awaited<ReturnType<typeof browser.newContext>>;
page: Awaited<ReturnType<typeof browser.newPage>>;
} | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
// === Client A: Legacy migration with archived task ===
@ -538,27 +525,10 @@ test.describe('@supersync @migration SuperSync Legacy Migration Sync', () => {
// === Client B: Fresh client (no legacy data), syncs ===
// We use a fresh client to verify archive data transfers correctly
console.log('[Test] Creating fresh Client B...');
const contextB = await browser.newContext({
baseURL: url,
acceptDownloads: true,
});
const pageB = await contextB.newPage();
// Auto-accept dialogs for fresh client
pageB.on('dialog', async (dialog) => {
if (dialog.type() === 'confirm') {
await dialog.accept();
}
});
await pageB.goto('/');
// Wait for app to be ready
await pageB.waitForSelector('magic-side-nav', { state: 'visible', timeout: 30000 });
clientB = { context: contextB, page: pageB };
const syncPageB = new SuperSyncPage(pageB);
const workViewB = new WorkViewPage(pageB);
await workViewB.waitForTaskList();
clientB = await createSimulatedClient(browser, url, 'B', testRunId);
const pageB = clientB.page;
const syncPageB = clientB.sync;
const workViewB = clientB.workView;
// Setup sync - fresh client downloads data
await syncPageB.setupSuperSync({ ...syncConfig, waitForInitialSync: false });
@ -616,7 +586,7 @@ test.describe('@supersync @migration SuperSync Legacy Migration Sync', () => {
console.log('[Test] SUCCESS: Archive data preserved after migration + sync');
} finally {
if (clientA) await closeLegacyClient(clientA).catch(() => {});
if (clientB) await closeLegacyClient(clientB).catch(() => {});
if (clientB) await closeClient(clientB).catch(() => {});
}
});
});

View file

@ -19,6 +19,7 @@ import { SyncPage } from '../../pages/sync.page';
import { SuperSyncPage } from '../../pages/supersync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { waitForStatePersistence } from '../../utils/waits';
import { isWebDavServerUp } from '../../utils/check-webdav';
/**
* SuperSync Provider Switch (WebDAV SuperSync) E2E Tests
@ -38,21 +39,6 @@ import { waitForStatePersistence } from '../../utils/waits';
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-provider-switch-to-supersync.spec.ts
*/
/**
* Check if the WebDAV server is available.
*/
const isWebDAVAvailable = async (): Promise<boolean> => {
try {
const response = await fetch('http://localhost:2345', {
method: 'OPTIONS',
signal: AbortSignal.timeout(3000),
});
return response.ok || response.status === 200 || response.status === 207;
} catch {
return false;
}
};
test.describe('@supersync Provider Switch WebDAV to SuperSync', () => {
/**
* Scenario I.7: Switching from WebDAV to SuperSync migrates data via SYNC_IMPORT
@ -74,7 +60,10 @@ test.describe('@supersync Provider Switch WebDAV to SuperSync', () => {
test.setTimeout(180000);
// Skip if WebDAV server is not available
const webdavAvailable = await isWebDAVAvailable();
const webdavAvailable = await isWebDavServerUp();
if (!webdavAvailable && process.env.E2E_REQUIRE_WEBDAV === 'true') {
throw new Error('WebDAV is required for provider-switch coverage.');
}
testInfo.skip(!webdavAvailable, 'WebDAV server not running on localhost:2345');
const appUrl = baseURL || 'http://localhost:4242';

View file

@ -11,8 +11,11 @@ import {
WEBDAV_CONFIG_TEMPLATE,
createSyncFolder,
generateSyncFolderName,
setupSyncClient,
waitForSyncComplete,
} from '../../utils/sync-helpers';
import { SyncPage } from '../../pages/sync.page';
import { isWebDavServerUp } from '../../utils/check-webdav';
/**
* SuperSync Provider Switch E2E Tests
@ -30,21 +33,6 @@ import { SyncPage } from '../../pages/sync.page';
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-provider-switch.spec.ts
*/
/**
* Check if the WebDAV server is available.
*/
const isWebDAVAvailable = async (): Promise<boolean> => {
try {
const response = await fetch('http://localhost:2345', {
method: 'OPTIONS',
signal: AbortSignal.timeout(3000),
});
return response.ok || response.status === 200 || response.status === 207;
} catch {
return false;
}
};
test.describe('@supersync Provider Switch (WebDAV ↔ SuperSync)', () => {
/**
* Scenario: SuperSync WebDAV
@ -67,7 +55,10 @@ test.describe('@supersync Provider Switch (WebDAV ↔ SuperSync)', () => {
test.setTimeout(180000);
// Skip if WebDAV server is not available
const webdavAvailable = await isWebDAVAvailable();
const webdavAvailable = await isWebDavServerUp();
if (!webdavAvailable && process.env.E2E_REQUIRE_WEBDAV === 'true') {
throw new Error('WebDAV is required for provider-switch coverage.');
}
testInfo.skip(!webdavAvailable, 'WebDAV server not running on localhost:2345');
const appUrl = baseURL || 'http://localhost:4242';
@ -105,14 +96,17 @@ test.describe('@supersync Provider Switch (WebDAV ↔ SuperSync)', () => {
// Disable SuperSync and set up WebDAV
const syncPageA = new SyncPage(clientA.page);
await syncPageA.configureSyncProvider({
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${syncFolderName}`,
});
await syncPageA.setupWebdavSync(
{
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${syncFolderName}`,
},
{ isReconfigure: true },
);
console.log('[ProviderSwitch] Switched to WebDAV');
// Wait for WebDAV sync
await clientA.page.waitForTimeout(3000);
await syncPageA.triggerSync();
await waitForSyncComplete(clientA.page, syncPageA);
// Verify tasks still exist on Client A
await waitForTask(clientA.page, task1);
@ -122,24 +116,19 @@ test.describe('@supersync Provider Switch (WebDAV ↔ SuperSync)', () => {
// ============ PHASE 3: New client joins via WebDAV ============
console.log('[ProviderSwitch] Phase 3: New client joins via WebDAV');
contextB = await browser.newContext({
storageState: undefined,
viewport: { width: 1920, height: 1080 },
});
const pageB = await contextB.newPage();
await pageB.goto(appUrl);
await pageB.waitForLoadState('networkidle');
await pageB.waitForTimeout(2000);
const clientB = await setupSyncClient(browser, appUrl);
contextB = clientB.context;
const pageB = clientB.page;
const syncPageB = new SyncPage(pageB);
await syncPageB.configureSyncProvider({
await syncPageB.setupWebdavSync({
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${syncFolderName}`,
});
console.log('[ProviderSwitch] Client B joined via WebDAV');
// Wait for sync
await pageB.waitForTimeout(5000);
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
// Verify tasks on Client B
await waitForTask(pageB, task1);

View file

@ -77,9 +77,9 @@ test.describe('Sections', () => {
* `@for track` reorders nodes) and freshly-dropped tasks run an
* `expandInOnly` enter animation that holds them at `height: 0`. Wait
* for the element to be visible, then poll until it reports a real box
* so the read can't race the re-render. This is the failure the two
* `test.fixme`s below hit; keeping the guard here lets the active tests
* stay reliable instead of throwing "no bounding box".
* so the read can't race the re-render. This was the failure the former
* disabled cross-list scenarios hit; keeping the guard here prevents the
* active tests from throwing "no bounding box".
*
* The box captured *inside* the poll is the one returned re-reading
* after the poll reopens the very race the poll closes (the list can
@ -143,18 +143,18 @@ test.describe('Sections', () => {
* `expandInOnly` animation (`height: 0 -> *`, expand.ani.ts) and has a
* zero-height layout box mid-animation. `boundingBox()` returns `null` for
* that, which is the root cause of the "drag source/target has no bounding
* box" flake (and why the cross-section tests below are fixme'd).
* box" flake in the former disabled cross-section scenarios.
*
* Flipping the app's own `isDisableAnimations` config toggles the
* `@HostBinding('@.disabled')` on the root component (app.component.ts),
* which disables every `@trigger` including `expandInOnly` so dropped
* tasks render at full height instantly and their box never collapses.
*
* NOTE: only the intra-section reorder test uses this. The "drops a task
* into a section" test intentionally keeps animations on: it drags out of
* the no-section list into an *empty* section, and with animations off the
* source-removal reflow is instant, so the empty drop target can jump out
* from under the in-flight pointer and the drop misses.
* NOTE: the "drops a task into a section" test intentionally keeps
* animations on: it drags out of the no-section list into an *empty*
* section, and with animations off the source-removal reflow is instant,
* so the empty drop target can jump out from under the in-flight pointer
* and the drop misses. Reorder and cross-list scenarios disable them.
*/
const disableAnimations = async (
page: import('@playwright/test').Page,
@ -399,18 +399,13 @@ test.describe('Sections', () => {
await expect.poll(async () => (await sectionTaskTitles()).length).toBe(3);
});
// FIXME: cross-section drag is flaky in headless CDK for the same
// pointer-event reason as the section→no-section round-trip below — the
// source task's bounding box can collapse mid-gesture when the source
// section re-renders without it. Behavior is covered by section.reducer
// unit tests (cross-section moves via `addTaskToSection`) and by the
// component-level `undoneTasksBySection` spec.
test.fixme('moves a task from one section to a specific slot in another', async ({
test('moves a task from one section to another', async ({
page,
workViewPage,
projectPage,
}) => {
await setupTestProject(workViewPage, projectPage);
await disableAnimations(page);
await openProjectContextMenu(page);
await clickAddSection(page);
@ -442,10 +437,14 @@ test.describe('Sections', () => {
});
}
// Drag Zulu from Left onto Yankee in Right.
// Drag Zulu into Right's actual CDK drop list. Targeting a task row can
// race the row transform while CDK makes room for the incoming item.
const taskZulu = left.locator('task').filter({ hasText: 'Zulu' }).first();
const taskYankee = right.locator('task').filter({ hasText: 'Yankee' }).first();
await cdkDragTo(page, taskZulu.locator('done-toggle').first(), taskYankee);
await cdkDragTo(
page,
taskZulu.locator('done-toggle').first(),
right.locator('.task-list-inner').first(),
);
// Behavioral invariant: Zulu has crossed sections. Exact slot is
// CDK-cursor dependent, so we don't pin it.
@ -456,13 +455,7 @@ test.describe('Sections', () => {
await expect(right.locator('task')).toHaveCount(3);
});
// FIXME: round-trip drag (section → no-section) is flaky in headless CDK.
// The forward "into section" drag passes; the reverse fails to register the
// drop on the empty `.no-section` task-list whose bounding box collapses
// to its hint-message. Driving CDK pointer events deterministically here
// is non-trivial — track as a follow-up and exercise reverse moves via
// unit tests in section.reducer.spec.ts (`removeTaskFromSection`).
test.fixme('drags a task back out of a section into the main list', async ({
test('drags a task back out of a section into the main list', async ({
page,
workViewPage,
projectPage,
@ -478,9 +471,6 @@ test.describe('Sections', () => {
const section = sectionByTitle(page, 'Holding');
const sectionTaskList = section.locator('task-list').first();
// Target the wrapper `.no-section` div, not the inner task-list — when
// the no-section bucket is empty the task-list collapses to its hint
// text and may have a too-small bounding box for a stable drop.
const noSection = page.locator('.no-section').first();
const task = page.locator('task').filter({ hasText: 'Roundtrip' }).first();
@ -494,9 +484,16 @@ test.describe('Sections', () => {
.first();
await expect(taskInSection).toBeVisible();
// Give the no-section CDK list a stable row target. Its empty-state text
// is a sibling that overlaps the otherwise-empty drop rect, so a row is
// the deterministic equivalent of a user dropping beside another task.
await workViewPage.addTask('Main anchor');
const mainAnchor = noSection.locator('task').filter({ hasText: 'Main anchor' });
await expect(mainAnchor).toBeVisible();
// Move back out — re-acquire handle from the new DOM location.
dragHandle = taskInSection.locator('done-toggle').first();
await cdkDragTo(page, dragHandle, noSection);
await cdkDragTo(page, dragHandle, mainAnchor);
await expect(noSection.locator('task').filter({ hasText: 'Roundtrip' })).toBeVisible({
timeout: 5000,