diff --git a/.github/workflows/e2e-scheduled.yml b/.github/workflows/e2e-scheduled.yml index ef288542d0..ebb72835d8 100644 --- a/.github/workflows/e2e-scheduled.yml +++ b/.github/workflows/e2e-scheduled.yml @@ -151,17 +151,17 @@ jobs: E2E_REQUIRE_WEBDAV: 'true' run: npm run e2e:all -- --grep "${{ inputs.webdav_grep || '@webdav' }}" - - name: Stop Docker Services - if: always() - run: | - docker compose down - - name: Print Docker Logs on Failure if: ${{ failure() }} run: | echo "=== WebDAV Logs ===" docker compose logs webdav + - name: Stop Docker Services + if: always() + run: | + docker compose down + - name: Upload E2E Results on Failure if: ${{ failure() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -170,7 +170,8 @@ jobs: path: .tmp/e2e-test-results/**/*.* retention-days: 30 - # Job 3: SuperSync E2E tests (sharded) + # Job 3: SuperSync E2E tests (sharded). WebDAV runs in every shard as well so + # the provider-switch scenarios are part of the suite rather than skipped. e2e-supersync: name: E2E Tests (SuperSync ${{ matrix.shard }}/6) needs: [build] @@ -203,7 +204,7 @@ jobs: run: | cp .env.example .env - - name: Start SuperSync Docker Service + - name: Start Sync Docker Services # Retry to survive transient Docker Hub pull timeouts / rate limits; # timeout_minutes also bounds the otherwise-unbounded health-wait loop uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 @@ -212,26 +213,29 @@ jobs: max_attempts: 3 retry_wait_seconds: 20 command: | - docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml up -d --build supersync + docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml up -d --build supersync webdav echo "⏳ Waiting for SuperSync server..." until curl -s http://localhost:1901/health > /dev/null 2>&1; do sleep 1; done echo "✓ SuperSync server ready!" + chmod +x ./scripts/wait-for-webdav.sh + ./scripts/wait-for-webdav.sh - name: Run SuperSync E2E Tests env: E2E_REQUIRE_SUPERSYNC: 'true' + E2E_REQUIRE_WEBDAV: 'true' run: npm run e2e:all -- --grep "${{ inputs.grep || '@supersync' }}" --shard=${{ matrix.shard }}/6 - - name: Stop Docker Services - if: always() - run: | - docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml down supersync - - name: Print Docker Logs on Failure if: ${{ failure() }} run: | echo "=== SuperSync Logs ===" - docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml logs supersync + docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml logs supersync webdav + + - name: Stop Docker Services + if: always() + run: | + docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml down - name: Upload E2E Results on Failure if: ${{ failure() }} diff --git a/.github/workflows/e2e-sync-pr.yml b/.github/workflows/e2e-sync-pr.yml index 927487593d..36e6e042dc 100644 --- a/.github/workflows/e2e-sync-pr.yml +++ b/.github/workflows/e2e-sync-pr.yml @@ -130,7 +130,8 @@ jobs: path: .tmp/angular-dist retention-days: 1 - # Job 2: SuperSync E2E tests (sharded), each shard spins up its own server. + # Job 2: SuperSync E2E tests (sharded), each shard spins up SuperSync and + # WebDAV so the provider-switch scenarios are exercised instead of skipped. e2e-supersync: name: SuperSync E2E ${{ matrix.shard }}/6 needs: [detect, build] @@ -162,7 +163,7 @@ jobs: - name: Create .env file for Docker Compose run: cp .env.example .env - - name: Start SuperSync Docker Service + - name: Start Sync Docker Services # Retry to survive transient Docker Hub pull timeouts / rate limits; # timeout_minutes also bounds the otherwise-unbounded health-wait loop. uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 @@ -171,25 +172,28 @@ jobs: max_attempts: 3 retry_wait_seconds: 20 command: | - docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml up -d --build supersync + docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml up -d --build supersync webdav echo "⏳ Waiting for SuperSync server..." until curl -s http://localhost:1901/health > /dev/null 2>&1; do sleep 1; done echo "✓ SuperSync server ready!" + chmod +x ./scripts/wait-for-webdav.sh + ./scripts/wait-for-webdav.sh - name: Run SuperSync E2E Tests env: E2E_REQUIRE_SUPERSYNC: 'true' + E2E_REQUIRE_WEBDAV: 'true' run: npm run e2e:all -- --grep "@supersync" --shard=${{ matrix.shard }}/6 - - name: Stop Docker Services - if: always() - run: docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml down supersync - - name: Print Docker Logs on Failure if: ${{ failure() }} run: | echo "=== SuperSync Logs ===" - docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml logs supersync + docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml logs supersync webdav + + - name: Stop Docker Services + if: always() + run: docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml down - name: Upload E2E Results on Failure if: ${{ failure() }} @@ -244,16 +248,16 @@ jobs: E2E_REQUIRE_WEBDAV: 'true' run: npm run e2e:all -- --grep "@webdav" - - name: Stop Docker Services - if: always() - run: docker compose down - - name: Print Docker Logs on Failure if: ${{ failure() }} run: | echo "=== WebDAV Logs ===" docker compose logs webdav + - name: Stop Docker Services + if: always() + run: docker compose down + - name: Upload E2E Results on Failure if: ${{ failure() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/electron-smoke.yml b/.github/workflows/electron-smoke.yml index 4ec0e5d3b5..507bc0d4a8 100644 --- a/.github/workflows/electron-smoke.yml +++ b/.github/workflows/electron-smoke.yml @@ -9,11 +9,9 @@ name: Electron Packaging Smoke Test # and the compiled dep was omitted from the electron-builder files glob, # so every 18.2.7 binary crashed on launch. # -# PRs run the cheap static check (~10s after the build) — paths filter -# excludes docs, translations, and platform-specific trees but still covers -# src/**, because #7320's root cause lived under src/app/util/. The full -# launch-under-xvfb smoke test runs on merge to master, release tags, and -# manual dispatch — stronger signal but needs xvfb setup + ~30s of wait. +# The static check catches missing require() targets. The packaged renderer +# also runs a task-create-and-reload flow under xvfb on every invocation, so +# pull requests prove the native shell can perform and persist a core action. on: pull_request: @@ -96,99 +94,18 @@ jobs: node tools/verify-electron-requires.js "$ASAR" - name: Install xvfb - if: github.event_name != 'pull_request' run: sudo apt-get update && sudo apt-get install -y xvfb - - name: Smoke test — launch packaged app - if: github.event_name != 'pull_request' + - name: Smoke test packaged app task flow run: | - set -uo pipefail - - UNPACKED=.tmp/app-builds/linux-unpacked - # afterPack.js renames the real binary to superproductivity-bin and - # installs a shell wrapper at superproductivity. Launch the wrapper - # so the smoke test exercises the exact entry path users hit. - BIN="$UNPACKED/superproductivity" - if [ ! -x "$BIN" ]; then - echo "::error::Expected launcher at $BIN not found or not executable" - ls -la "$UNPACKED" || true - exit 1 - fi - - # Keep the log inside the workspace so upload-artifact can find it - # when a step fails. /tmp is accessible but fragile across runner - # images and upload-artifact glob evaluation. + set -o pipefail mkdir -p .tmp/smoke - LOG=.tmp/smoke/launch.log - : > "$LOG" - - echo "Launching $BIN under xvfb (log: $LOG)" - # Electron flags: - # --no-sandbox: chrome-sandbox needs setuid root, not set on runners - # --disable-gpu: no GPU on the runner - # --disable-dev-shm-usage: /dev/shm is small in GH-hosted containers, - # and Chromium/Electron can die with shared-memory errors without it - # Xvfb flags: - # --server-args: explicit screen geometry; Xvfb's 640x480x8 default - # trips some Chromium color-depth assertions xvfb-run \ --auto-servernum \ --server-args="-screen 0 1280x720x24" \ - "$BIN" \ - --no-sandbox \ - --disable-gpu \ - --disable-dev-shm-usage \ - > "$LOG" 2>&1 & - WRAPPER_PID=$! - - # Wait up to 30s for the app to crash. The #7320 crash surfaces - # within ~100ms — 30s is generous headroom for Angular bootstrap - # in CI. xvfb-run exits when its child exits, so the wrapper PID - # going away is a reliable crash signal. We also scan stderr for - # known failure markers as a belt-and-suspenders backstop: some - # main-process exceptions log without killing the process. - CRASH_RE='(Uncaught Exception|Cannot find module|A JavaScript error occurred in the main process|Segmentation fault|SIGSEGV|SIGABRT|TypeError:|ReferenceError:|FATAL ERROR|Check failed)' - for i in $(seq 1 30); do - if ! kill -0 $WRAPPER_PID 2>/dev/null; then - echo "::error::xvfb-run/Electron exited after ${i}s — likely crash." - echo "----8<---- launch log" - cat "$LOG" || true - echo "---->8----" - exit 1 - fi - if grep -E -q "$CRASH_RE" "$LOG" 2>/dev/null; then - echo "::error::Crash marker detected in log after ${i}s:" - grep -E "$CRASH_RE" "$LOG" || true - echo "----8<---- launch log" - cat "$LOG" || true - echo "---->8----" - kill -9 $WRAPPER_PID 2>/dev/null || true - exit 1 - fi - sleep 1 - done - - echo "App still alive after 30s — startup OK. Terminating." - kill $WRAPPER_PID 2>/dev/null || true - # xvfb-run forwards SIGTERM to its child; give them 5s to exit - # cleanly before SIGKILL. Also sweep any stray electron workers. - for i in 1 2 3 4 5; do - if ! kill -0 $WRAPPER_PID 2>/dev/null; then break; fi - sleep 1 - done - kill -9 $WRAPPER_PID 2>/dev/null || true - pkill -9 -f "$UNPACKED/superproductivity" 2>/dev/null || true - wait $WRAPPER_PID 2>/dev/null || true - - # Final scan for errors that didn't crash the main process but - # would still indicate a broken build. - if grep -E -q "$CRASH_RE" "$LOG" 2>/dev/null; then - echo "::error::Crash marker detected in final log scan:" - grep -E "$CRASH_RE" "$LOG" || true - exit 1 - fi - - echo "Smoke test passed." + node e2e/electron/packaged-app-smoke.cjs \ + .tmp/app-builds/linux-unpacked/superproductivity \ + 2>&1 | tee .tmp/smoke/launch.log - name: Upload logs on failure if: failure() diff --git a/e2e/electron/packaged-app-smoke.cjs b/e2e/electron/packaged-app-smoke.cjs new file mode 100644 index 0000000000..1fb7994af6 --- /dev/null +++ b/e2e/electron/packaged-app-smoke.cjs @@ -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 || ''}`, + ); +} + +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 }); + }); diff --git a/e2e/helpers/plugin-test.helpers.ts b/e2e/helpers/plugin-test.helpers.ts index fb035d827a..dd3f73280c 100644 --- a/e2e/helpers/plugin-test.helpers.ts +++ b/e2e/helpers/plugin-test.helpers.ts @@ -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; }; diff --git a/e2e/pages/sync.page.ts b/e2e/pages/sync.page.ts index c35fe059b8..d77f7099a2 100644 --- a/e2e/pages/sync.page.ts +++ b/e2e/pages/sync.page.ts @@ -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 { + 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 { // 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 }); } diff --git a/e2e/tests/onboarding/onboarding-preset.spec.ts b/e2e/tests/onboarding/onboarding-preset.spec.ts new file mode 100644 index 0000000000..74e4347a9c --- /dev/null +++ b/e2e/tests/onboarding/onboarding-preset.spec.ts @@ -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(); + }); +}); diff --git a/e2e/tests/plugins/doc-mode-bundled.spec.ts b/e2e/tests/plugins/doc-mode-bundled.spec.ts index 90d5421087..69cb64d391 100644 --- a/e2e/tests/plugins/doc-mode-bundled.spec.ts +++ b/e2e/tests/plugins/doc-mode-bundled.spec.ts @@ -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`'); } diff --git a/e2e/tests/plugins/doc-mode-migration.spec.ts b/e2e/tests/plugins/doc-mode-migration.spec.ts index 40f98887a0..d295ec37a0 100644 --- a/e2e/tests/plugins/doc-mode-migration.spec.ts +++ b/e2e/tests/plugins/doc-mode-migration.spec.ts @@ -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`'); } diff --git a/e2e/tests/project/project-completion.spec.ts b/e2e/tests/project/project-completion.spec.ts index 0ec75e53f5..14f16b10b1 100644 --- a/e2e/tests/project/project-completion.spec.ts +++ b/e2e/tests/project/project-completion.spec.ts @@ -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); }); }); diff --git a/e2e/tests/search/global-search.spec.ts b/e2e/tests/search/global-search.spec.ts index 95797e563e..786a7cdd56 100644 --- a/e2e/tests/search/global-search.spec.ts +++ b/e2e/tests/search/global-search.spec.ts @@ -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 => { + 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(); }); }); diff --git a/e2e/tests/sync/import-sync.spec.ts b/e2e/tests/sync/import-sync.spec.ts index 1a9a84f0ef..60acf2e3bf 100644 --- a/e2e/tests/sync/import-sync.spec.ts +++ b/e2e/tests/sync/import-sync.spec.ts @@ -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); diff --git a/e2e/tests/sync/supersync-backup-import-id-mismatch.spec.ts b/e2e/tests/sync/supersync-backup-import-id-mismatch.spec.ts index 0654d9aef0..bf5b0157e0 100644 --- a/e2e/tests/sync/supersync-backup-import-id-mismatch.spec.ts +++ b/e2e/tests/sync/supersync-backup-import-id-mismatch.spec.ts @@ -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((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>( - (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 * diff --git a/e2e/tests/sync/supersync-import-clean-server-state.spec.ts b/e2e/tests/sync/supersync-import-clean-server-state.spec.ts index 69dd937bf9..c68d642087 100644 --- a/e2e/tests/sync/supersync-import-clean-server-state.spec.ts +++ b/e2e/tests/sync/supersync-import-clean-server-state.spec.ts @@ -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, diff --git a/e2e/tests/sync/supersync-import-encryption-change.spec.ts b/e2e/tests/sync/supersync-import-encryption-change.spec.ts index 5b1036f3fc..6a37e72f29 100644 --- a/e2e/tests/sync/supersync-import-encryption-change.spec.ts +++ b/e2e/tests/sync/supersync-import-encryption-change.spec.ts @@ -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); diff --git a/e2e/tests/sync/supersync-legacy-migration-sync.spec.ts b/e2e/tests/sync/supersync-legacy-migration-sync.spec.ts index 428d013773..c29f62e425 100644 --- a/e2e/tests/sync/supersync-legacy-migration-sync.spec.ts +++ b/e2e/tests/sync/supersync-legacy-migration-sync.spec.ts @@ -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>; page: Awaited>; } | null = null; - let clientB: { - context: Awaited>; - page: Awaited>; - } | 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(() => {}); } }); }); diff --git a/e2e/tests/sync/supersync-provider-switch-to-supersync.spec.ts b/e2e/tests/sync/supersync-provider-switch-to-supersync.spec.ts index 541f16d1b2..8dfa086716 100644 --- a/e2e/tests/sync/supersync-provider-switch-to-supersync.spec.ts +++ b/e2e/tests/sync/supersync-provider-switch-to-supersync.spec.ts @@ -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 => { - 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'; diff --git a/e2e/tests/sync/supersync-provider-switch.spec.ts b/e2e/tests/sync/supersync-provider-switch.spec.ts index 8354b270e0..c9026417b4 100644 --- a/e2e/tests/sync/supersync-provider-switch.spec.ts +++ b/e2e/tests/sync/supersync-provider-switch.spec.ts @@ -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 => { - 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); diff --git a/e2e/tests/work-view/sections.spec.ts b/e2e/tests/work-view/sections.spec.ts index faa125e9cc..32e07eafd3 100644 --- a/e2e/tests/work-view/sections.spec.ts +++ b/e2e/tests/work-view/sections.spec.ts @@ -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, diff --git a/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.spec.ts b/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.spec.ts index 4357597919..d342bb5216 100644 --- a/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.spec.ts +++ b/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.spec.ts @@ -1,4 +1,10 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + fakeAsync, + flushMicrotasks, + TestBed, + tick, +} from '@angular/core/testing'; import { FormControl, FormGroup } from '@angular/forms'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; @@ -245,6 +251,7 @@ describe('DialogSyncCfgComponent', () => { it('does NOT flag when re-saving an already-established SuperSync config', async () => { forceOnline(); mockProviderManager.getProviderById.and.resolveTo(superSyncProvider as any); + (component as any)._initialProviderId = SyncProviderId.SuperSync; (component as any)._tmpUpdatedCfg = { ...(component as any)._tmpUpdatedCfg, @@ -259,6 +266,44 @@ describe('DialogSyncCfgComponent', () => { mockSyncWrapperService.markPromptEncryptionAfterSetupSync, ).not.toHaveBeenCalled(); }); + + it('flags setup sync when switching to an unconfigured SuperSync provider', async () => { + forceOnline(); + mockProviderManager.getProviderById.and.resolveTo(superSyncProvider as any); + (component as any)._initialProviderId = SyncProviderId.WebDAV; + (component as any)._selectedProviderWasConfigured = false; + (component as any)._tmpUpdatedCfg = { + ...(component as any)._tmpUpdatedCfg, + syncProvider: SyncProviderId.SuperSync, + isEnabled: true, + _isInitialSetup: false, + }; + + await component.save(); + + expect( + mockSyncWrapperService.markPromptEncryptionAfterSetupSync, + ).toHaveBeenCalledTimes(1); + }); + + it('does NOT flag setup when returning to an already-configured SuperSync provider', async () => { + forceOnline(); + mockProviderManager.getProviderById.and.resolveTo(superSyncProvider as any); + (component as any)._initialProviderId = SyncProviderId.WebDAV; + (component as any)._selectedProviderWasConfigured = true; + (component as any)._tmpUpdatedCfg = { + ...(component as any)._tmpUpdatedCfg, + syncProvider: SyncProviderId.SuperSync, + isEnabled: true, + _isInitialSetup: false, + }; + + await component.save(); + + expect( + mockSyncWrapperService.markPromptEncryptionAfterSetupSync, + ).not.toHaveBeenCalled(); + }); }); describe('save() — file-based pre-upload encryption (collect password)', () => { @@ -327,6 +372,7 @@ describe('DialogSyncCfgComponent', () => { it('does NOT prompt when re-saving an already-configured provider (not a fresh setup)', async () => { setOnline(true); mockDialogResult({ success: true, password: 'unused' }); + (component as any)._initialProviderId = SyncProviderId.WebDAV; setFreshWebdavCfg({ _isInitialSetup: false } as any); await component.save(); @@ -335,14 +381,54 @@ describe('DialogSyncCfgComponent', () => { expect(savedConfig().isEncryptionEnabled).toBeFalse(); }); + it('offers encryption setup when switching to an unconfigured WebDAV provider', async () => { + setOnline(true); + mockDialogResult({ success: true, password: 'provider-switch-password' }); + (component as any)._initialProviderId = SyncProviderId.SuperSync; + (component as any)._selectedProviderWasConfigured = false; + setFreshWebdavCfg({ _isInitialSetup: false } as any); + + await component.save(); + + expect(mockMatDialog.open).toHaveBeenCalledTimes(1); + expect(savedConfig().encryptKey).toBe('provider-switch-password'); + expect(savedConfig().isEncryptionEnabled).toBeTrue(); + }); + + it('does NOT offer a new key when returning to a configured WebDAV provider', async () => { + setOnline(true); + mockDialogResult({ success: true, password: 'incompatible-new-password' }); + (component as any)._initialProviderId = SyncProviderId.SuperSync; + (component as any)._selectedProviderWasConfigured = true; + setFreshWebdavCfg({ _isInitialSetup: false } as any); + + await component.save(); + + expect(mockMatDialog.open).not.toHaveBeenCalled(); + expect(savedConfig().encryptKey).toBe(''); + expect(savedConfig().isEncryptionEnabled).toBeFalse(); + }); + it('does NOT prompt when encryption is already enabled', async () => { setOnline(true); mockDialogResult({ success: true, password: 'unused' }); + mockProviderManager.getProviderById.and.resolveTo({ + id: SyncProviderId.WebDAV, + privateCfg: { + load: jasmine.createSpy('load').and.resolveTo({ + encryptKey: 'stored-webdav-key', + isEncryptionEnabled: true, + }), + }, + isReady: jasmine.createSpy('isReady').and.resolveTo(true), + } as any); setFreshWebdavCfg({ isEncryptionEnabled: true }); await component.save(); expect(mockMatDialog.open).not.toHaveBeenCalled(); + expect(savedConfig().encryptKey).toBe('stored-webdav-key'); + expect(savedConfig().isEncryptionEnabled).toBeTrue(); }); it('does NOT prompt for a non-file-based provider (SuperSync)', async () => { @@ -404,6 +490,138 @@ describe('DialogSyncCfgComponent', () => { }); }); + describe('provider changes', () => { + it('clears the previous provider encryption when the new provider has none', fakeAsync(() => { + const syncProviderControl = new FormControl(null); + component.form = new FormGroup({ + syncProvider: syncProviderControl, + }) as unknown as typeof component.form; + mockProviderManager.getProviderById.and.resolveTo({ + privateCfg: { + load: jasmine.createSpy('load').and.resolveTo(null), + }, + } as any); + (component as any)._tmpUpdatedCfg = { + ...(component as any)._tmpUpdatedCfg, + syncProvider: SyncProviderId.SuperSync, + encryptKey: 'super-sync-secret', + isEncryptionEnabled: true, + }; + + component.ngAfterViewInit(); + tick(); + syncProviderControl.setValue(SyncProviderId.WebDAV); + flushMicrotasks(); + + expect((component as any)._tmpUpdatedCfg.encryptKey).toBe(''); + expect((component as any)._tmpUpdatedCfg.isEncryptionEnabled).toBeFalse(); + })); + + it('waits for the selected provider config before saving', fakeAsync(() => { + let resolvePrivateCfg: (value: null) => void = () => undefined; + const privateCfgPromise = new Promise((resolve) => { + resolvePrivateCfg = resolve; + }); + const syncProviderControl = new FormControl(null); + component.form = new FormGroup({ + syncProvider: syncProviderControl, + encryptKey: new FormControl('stale-super-sync-key'), + isEncryptionEnabled: new FormControl(true), + }) as unknown as typeof component.form; + mockProviderManager.getProviderById.and.resolveTo({ + privateCfg: { + load: jasmine.createSpy('load').and.returnValue(privateCfgPromise), + }, + } as any); + mockSyncWrapperService.configuredAuthForSyncProviderIfNecessary.and.resolveTo({ + wasConfigured: false, + }); + (component as any)._tmpUpdatedCfg = { + ...(component as any)._tmpUpdatedCfg, + encryptKey: 'stale-super-sync-key', + isEncryptionEnabled: true, + }; + + component.ngAfterViewInit(); + tick(); + syncProviderControl.setValue(SyncProviderId.WebDAV); + void component.save(); + flushMicrotasks(); + + expect(mockSyncConfigService.updateSettingsFromForm).not.toHaveBeenCalled(); + + resolvePrivateCfg(null); + flushMicrotasks(); + + expect(mockSyncConfigService.updateSettingsFromForm).toHaveBeenCalled(); + expect( + ( + mockSyncConfigService.updateSettingsFromForm.calls.mostRecent() + .args[0] as SyncConfig + ).encryptKey, + ).toBe(''); + expect( + ( + mockSyncConfigService.updateSettingsFromForm.calls.mostRecent() + .args[0] as SyncConfig + ).isEncryptionEnabled, + ).toBeFalse(); + })); + + it('ignores an older provider load that resolves after the current one', fakeAsync(() => { + let resolveWebDav: (value: { encryptKey: string }) => void = () => undefined; + let resolveSuperSync: (value: { + encryptKey: string; + isEncryptionEnabled: boolean; + }) => void = () => undefined; + const webDavCfg = new Promise<{ encryptKey: string }>((resolve) => { + resolveWebDav = resolve; + }); + const superSyncCfg = new Promise<{ + encryptKey: string; + isEncryptionEnabled: boolean; + }>((resolve) => { + resolveSuperSync = resolve; + }); + const syncProviderControl = new FormControl(null); + component.form = new FormGroup({ + syncProvider: syncProviderControl, + }) as unknown as typeof component.form; + mockProviderManager.getProviderById.and.callFake( + async (providerId) => + ({ + privateCfg: { + load: jasmine + .createSpy('load') + .and.returnValue( + providerId === SyncProviderId.WebDAV ? webDavCfg : superSyncCfg, + ), + }, + }) as any, + ); + + component.ngAfterViewInit(); + tick(); + syncProviderControl.setValue(SyncProviderId.WebDAV); + syncProviderControl.setValue(SyncProviderId.SuperSync); + flushMicrotasks(); + + resolveSuperSync({ + encryptKey: 'current-super-sync-key', + isEncryptionEnabled: true, + }); + flushMicrotasks(); + resolveWebDav({ encryptKey: 'stale-webdav-key' }); + flushMicrotasks(); + + expect((component as any)._tmpUpdatedCfg.syncProvider).toBe( + SyncProviderId.SuperSync, + ); + expect((component as any)._tmpUpdatedCfg.encryptKey).toBe('current-super-sync-key'); + expect((component as any)._tmpUpdatedCfg.isEncryptionEnabled).toBeTrue(); + })); + }); + describe('Nextcloud connection test', () => { it('uses loginName for auth while preserving file username in the DAV URL', async () => { const testWebDavConnection = jasmine diff --git a/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.ts b/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.ts index a92ff41486..2b7601cad8 100644 --- a/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.ts +++ b/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.ts @@ -33,7 +33,7 @@ import { import { SyncConfigService } from '../sync-config.service'; import { SyncWrapperService } from '../sync-wrapper.service'; import { firstValueFrom } from 'rxjs'; -import { first, skip } from 'rxjs/operators'; +import { first } from 'rxjs/operators'; import { toSyncProviderId } from '../../../op-log/sync-exports'; import { isFileBasedProviderId } from '../../../op-log/sync/operation-sync.util'; import { SyncLog } from '../../../core/log'; @@ -51,6 +51,8 @@ import { import { testWebdavConnection } from '../../../op-log/sync-providers/file-based/webdav/test-webdav-connection'; import { discoverNextcloudUserId } from '../../../op-log/sync-providers/file-based/webdav/discover-nextcloud-user-id'; import type { OneDrivePrivateCfg } from '../../../op-log/sync-providers/file-based/onedrive/onedrive.model'; +import type { LocalFileSyncPrivateCfg } from '@sp/sync-providers/local-file'; +import type { SuperSyncPrivateCfg } from '@sp/sync-providers/super-sync'; // `testWebdavConnection` reports a 404 (auth ok, wrong DAV path) via this // HTTP status; the package-side `WebDavHttpStatus` enum is not exported to @@ -411,11 +413,17 @@ export class DialogSyncCfgComponent implements AfterViewInit { }; private _matDialogRef = inject>(MatDialogRef); + private _initialProviderId: SyncProviderId | null = null; + private _providerConfigLoad: Promise = Promise.resolve(); + private _providerConfigLoadId = 0; + private _selectedProviderWasConfigured = false; constructor() { this.syncConfigService.syncSettingsForm$ .pipe(first(), takeUntilDestroyed(this._destroyRef)) .subscribe((v) => { + this._initialProviderId = toSyncProviderId(v.syncProvider); + this._selectedProviderWasConfigured = v.isEnabled; if (v.isEnabled) { this.isWasEnabled.set(true); } @@ -442,8 +450,8 @@ export class DialogSyncCfgComponent implements AfterViewInit { // Listen for provider changes and reload provider-specific configuration syncProviderControl.valueChanges - .pipe(skip(1), takeUntilDestroyed(this._destroyRef)) - .subscribe(async (newProvider: SyncProviderId | null) => { + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe((newProvider: SyncProviderId | null) => { if (!newProvider) { return; } @@ -454,79 +462,95 @@ export class DialogSyncCfgComponent implements AfterViewInit { return; } - // Load the provider's stored configuration - const provider = await this._providerManager.getProviderById(providerId); - if (!provider) { - // Provider not yet configured, keep current form state - return; - } - - const privateCfg = await provider.privateCfg.load(); - const globalCfg = await this._globalConfigService.sync$ - .pipe(first()) - .toPromise(); - - // Create provider-specific config based on provider type - let providerSpecificUpdate: Partial = {}; - - if (newProvider === SyncProviderId.SuperSync && privateCfg) { - providerSpecificUpdate = { - superSync: privateCfg as any, - encryptKey: privateCfg.encryptKey || '', - // SuperSync stores isEncryptionEnabled in privateCfg, not globalCfg - isEncryptionEnabled: (privateCfg as any).isEncryptionEnabled || false, - }; - } else if (newProvider === SyncProviderId.WebDAV && privateCfg) { - providerSpecificUpdate = { - webDav: privateCfg as any, - encryptKey: privateCfg.encryptKey || '', - }; - } else if (newProvider === SyncProviderId.LocalFile && privateCfg) { - providerSpecificUpdate = { - localFileSync: privateCfg as any, - encryptKey: privateCfg.encryptKey || '', - }; - } else if (newProvider === SyncProviderId.Nextcloud && privateCfg) { - providerSpecificUpdate = { - nextcloud: privateCfg as any, - encryptKey: privateCfg.encryptKey || '', - }; - } else if (newProvider === SyncProviderId.Dropbox && privateCfg) { - providerSpecificUpdate = { - encryptKey: privateCfg.encryptKey || '', - }; - } else if (newProvider === SyncProviderId.OneDrive && privateCfg) { - providerSpecificUpdate = { - oneDrive: privateCfg as any, - encryptKey: privateCfg.encryptKey || '', - }; - } - - // Update the model, preserving non-provider-specific fields - this._tmpUpdatedCfg = { - ...this._tmpUpdatedCfg, - ...providerSpecificUpdate, - syncProvider: newProvider, - // Preserve global settings (?? not || so explicit `false` is honoured) - isEnabled: this._tmpUpdatedCfg.isEnabled, - syncInterval: globalCfg?.syncInterval ?? this._tmpUpdatedCfg.syncInterval, - isManualSyncOnly: - globalCfg?.isManualSyncOnly ?? this._tmpUpdatedCfg.isManualSyncOnly, - isCompressionEnabled: - globalCfg?.isCompressionEnabled ?? this._tmpUpdatedCfg.isCompressionEnabled, - }; - - // For non-SuperSync providers, update encryption from global config - if (newProvider !== SyncProviderId.SuperSync) { - this._tmpUpdatedCfg = { - ...this._tmpUpdatedCfg, - isEncryptionEnabled: globalCfg?.isEncryptionEnabled ?? false, - }; - } + this._providerConfigLoad = this._loadProviderConfig(providerId); }); }, 0); } + private async _loadProviderConfig(providerId: SyncProviderId): Promise { + const loadId = ++this._providerConfigLoadId; + this._selectedProviderWasConfigured = false; + + // Clear provider-owned encryption synchronously. Save waits for this load, + // but the immediate reset also prevents the previous provider's key from + // remaining in the Formly model while the new private config is loading. + Object.assign(this._tmpUpdatedCfg, { + syncProvider: providerId, + encryptKey: '', + isEncryptionEnabled: false, + }); + + const provider = await this._providerManager.getProviderById(providerId); + const privateCfg = provider ? await provider.privateCfg.load() : null; + const globalCfg = await firstValueFrom(this._globalConfigService.sync$); + + // A later provider selection owns the form now. Never let an older async + // load restore stale credentials or encryption state. + if (loadId !== this._providerConfigLoadId) { + return; + } + + this._selectedProviderWasConfigured = privateCfg !== null; + const encryptionCfg = privateCfg as { + encryptKey?: string; + isEncryptionEnabled?: boolean; + } | null; + const encryptKey = encryptionCfg?.encryptKey ?? ''; + let providerSpecificUpdate: Partial = { + encryptKey, + isEncryptionEnabled: + providerId === SyncProviderId.SuperSync + ? (encryptionCfg?.isEncryptionEnabled ?? false) + : (encryptionCfg?.isEncryptionEnabled ?? !!encryptKey), + }; + + if (providerId === SyncProviderId.SuperSync && privateCfg) { + providerSpecificUpdate = { + ...providerSpecificUpdate, + superSync: privateCfg as SuperSyncPrivateCfg, + }; + } else if (providerId === SyncProviderId.WebDAV && privateCfg) { + providerSpecificUpdate = { + ...providerSpecificUpdate, + webDav: privateCfg as WebdavPrivateCfg, + }; + } else if (providerId === SyncProviderId.LocalFile && privateCfg) { + providerSpecificUpdate = { + ...providerSpecificUpdate, + localFileSync: privateCfg as LocalFileSyncPrivateCfg, + }; + } else if (providerId === SyncProviderId.Nextcloud && privateCfg) { + providerSpecificUpdate = { + ...providerSpecificUpdate, + nextcloud: privateCfg as NextcloudPrivateCfg, + }; + } else if (providerId === SyncProviderId.OneDrive && privateCfg) { + providerSpecificUpdate = { + ...providerSpecificUpdate, + oneDrive: privateCfg as OneDrivePrivateCfg, + }; + } + + Object.assign(this._tmpUpdatedCfg, providerSpecificUpdate, { + syncProvider: providerId, + // Preserve global settings (?? not || so explicit `false` is honoured) + isEnabled: this._tmpUpdatedCfg.isEnabled, + syncInterval: globalCfg.syncInterval ?? this._tmpUpdatedCfg.syncInterval, + isManualSyncOnly: + globalCfg.isManualSyncOnly ?? this._tmpUpdatedCfg.isManualSyncOnly, + isCompressionEnabled: + globalCfg.isCompressionEnabled ?? this._tmpUpdatedCfg.isCompressionEnabled, + }); + } + + private async _waitForCurrentProviderConfig(): Promise { + let pendingLoad: Promise; + do { + pendingLoad = this._providerConfigLoad; + await pendingLoad; + } while (pendingLoad !== this._providerConfigLoad); + } + close(): void { this._matDialogRef.close(); } @@ -599,6 +623,8 @@ export class DialogSyncCfgComponent implements AfterViewInit { return; } + await this._waitForCurrentProviderConfig(); + // Explicitly sync form values to _tmpUpdatedCfg in case modelChange didn't fire // This is especially important on Android WebView where change detection can be unreliable this._tmpUpdatedCfg = { @@ -615,6 +641,14 @@ export class DialogSyncCfgComponent implements AfterViewInit { }; const providerId = toSyncProviderId(this._tmpUpdatedCfg.syncProvider); + // Switching providers is a first setup only when the target has no stored + // private config. Returning providers must keep their existing encryption + // contract instead of being offered a new, incompatible key. + const isProviderSetup = + _isInitialSetup || + (providerId !== this._initialProviderId && !this._selectedProviderWasConfigured); + let selectedProvider: Awaited> = + undefined; if (providerId && this._tmpUpdatedCfg.isEnabled) { if (providerId === SyncProviderId.OneDrive) { await this._persistOneDriveFormCfgBeforeAuth(providerId); @@ -626,12 +660,33 @@ export class DialogSyncCfgComponent implements AfterViewInit { // the auth dialog was cancelled or failed. Keep the dialog open so the // user can retry, and do not persist isEnabled:true with missing credentials // (which would trigger the "Sync credentials are missing" snack loop — issue #7131). - const provider = await this._providerManager.getProviderById(providerId); - if (provider?.getAuthHelper && !(await provider.isReady())) { + selectedProvider = await this._providerManager.getProviderById(providerId); + if (selectedProvider?.getAuthHelper && !(await selectedProvider.isReady())) { return; } } + // The Formly value can briefly retain the previous provider's root-level + // encryption fields during a provider switch. The selected provider's + // private config is the authority, so re-derive both fields at the save + // boundary before any setup prompt or persistence occurs. + if (providerId) { + selectedProvider ??= await this._providerManager.getProviderById(providerId); + const privateCfg = selectedProvider?.privateCfg + ? await selectedProvider.privateCfg.load() + : null; + const encryptionCfg = privateCfg as { + encryptKey?: string; + isEncryptionEnabled?: boolean; + } | null; + const encryptKey = encryptionCfg?.encryptKey ?? ''; + configToSave.encryptKey = encryptKey; + configToSave.isEncryptionEnabled = + providerId === SyncProviderId.SuperSync + ? (encryptionCfg?.isEncryptionEnabled ?? false) + : (encryptionCfg?.isEncryptionEnabled ?? !!encryptKey); + } + // File-based providers support OPTIONAL E2EE but (unlike SuperSync) have no // mandatory-encryption upload guard. So instead of prompting AFTER the first // sync (which would already have shipped plaintext, and would race the auto @@ -642,7 +697,7 @@ export class DialogSyncCfgComponent implements AfterViewInit { // snapshot-overwrite, no plaintext-upload window. Skipping keeps today's // unencrypted behavior. No network needed, so this also covers offline setup. if ( - _isInitialSetup && + isProviderSetup && providerId && isFileBasedProviderId(providerId) && !configToSave.isEncryptionEnabled @@ -662,7 +717,7 @@ export class DialogSyncCfgComponent implements AfterViewInit { // an offline save still needs the flag armed for whenever the setup sync finally // runs (else the prompt is silently skipped and the account syncs unencrypted). // Established/returning accounts are nudged by the calm migration banner instead. - if (_isInitialSetup && providerId === SyncProviderId.SuperSync) { + if (isProviderSetup && providerId === SyncProviderId.SuperSync) { this.syncWrapperService.markPromptEncryptionAfterSetupSync(); } diff --git a/src/app/imex/sync/import-encryption-handler.service.spec.ts b/src/app/imex/sync/import-encryption-handler.service.spec.ts index 635c4d2a03..3b3d421727 100644 --- a/src/app/imex/sync/import-encryption-handler.service.spec.ts +++ b/src/app/imex/sync/import-encryption-handler.service.spec.ts @@ -106,7 +106,7 @@ describe('ImportEncryptionHandlerService', () => { expect(result.importedEnabled).toBeTrue(); }); - it('should detect change from encrypted to unencrypted', async () => { + it('should not report disabling encryption because SuperSync requires it', async () => { // Current: encrypted (mockSyncProvider.privateCfg.load as jasmine.Spy).and.resolveTo({ ...mockExistingCfg, @@ -117,7 +117,7 @@ describe('ImportEncryptionHandlerService', () => { const result = await service.checkEncryptionStateChange(importedData); - expect(result.willChange).toBeTrue(); + expect(result.willChange).toBeFalse(); expect(result.currentEnabled).toBeTrue(); expect(result.importedEnabled).toBeFalse(); }); diff --git a/src/app/imex/sync/import-encryption-handler.service.ts b/src/app/imex/sync/import-encryption-handler.service.ts index ff38a0bf32..1b2d68019d 100644 --- a/src/app/imex/sync/import-encryption-handler.service.ts +++ b/src/app/imex/sync/import-encryption-handler.service.ts @@ -92,8 +92,10 @@ export class ImportEncryptionHandlerService { const importedEnabled = importedSuperSync?.isEncryptionEnabled ?? false; const importedHasKey = !!importedSuperSync?.encryptKey; - // Encryption state changes if enabled state differs - const willChange = currentEnabled !== importedEnabled; + // SuperSync encryption is mandatory, so an import can enable encryption but + // can never disable it. Do not warn that encryption "will change" for a + // transition this service intentionally ignores below. + const willChange = !currentEnabled && importedEnabled; return { willChange,