mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
000d4726ac
commit
a4dee9d5f7
23 changed files with 864 additions and 1250 deletions
177
e2e/electron/packaged-app-smoke.cjs
Normal file
177
e2e/electron/packaged-app-smoke.cjs
Normal 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 });
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
51
e2e/tests/onboarding/onboarding-preset.spec.ts
Normal file
51
e2e/tests/onboarding/onboarding-preset.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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`');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue