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

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

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

* docs(ios): plan internal testflight builds

* fix(sync): isolate provider encryption settings

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

View file

@ -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() }}

View file

@ -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

View file

@ -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()

View file

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

View file

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

View file

@ -39,20 +39,23 @@ export class SyncPage extends BasePage {
this.disableEncryptionBtn = page.locator('.e2e-disable-encryption-btn button');
}
async setupWebdavSync(config: {
baseUrl: string;
username: string;
password: string;
syncFolderPath: string;
isEncryptionEnabled?: boolean;
encryptionPassword?: string;
/**
* Set the encryption password in the setup-time "Encrypt before first
* upload?" dialog (instead of the post-setup Enable Encryption button), so
* the very first sync is encrypted. Requires `encryptionPassword`.
*/
encryptAtSetup?: boolean;
}): Promise<void> {
async setupWebdavSync(
config: {
baseUrl: string;
username: string;
password: string;
syncFolderPath: string;
isEncryptionEnabled?: boolean;
encryptionPassword?: string;
/**
* Set the encryption password in the setup-time "Encrypt before first
* upload?" dialog (instead of the post-setup Enable Encryption button), so
* the very first sync is encrypted. Requires `encryptionPassword`.
*/
encryptAtSetup?: boolean;
},
options: { isReconfigure?: boolean } = {},
): Promise<void> {
// Try entire setup flow up to 2 times (dialog-level retry)
for (let dialogAttempt = 0; dialogAttempt < 2; dialogAttempt++) {
if (dialogAttempt > 0) {
@ -83,7 +86,11 @@ export class SyncPage extends BasePage {
// Click sync button to open settings dialog
// Use noWaitAfter to prevent blocking on Angular hash navigation
await this.syncBtn.click({ timeout: 5000, noWaitAfter: true });
await this.syncBtn.click({
button: options.isReconfigure ? 'right' : 'left',
timeout: 5000,
noWaitAfter: true,
});
// Wait for dialog to appear
const dialog = this.page.locator('mat-dialog-container, .mat-mdc-dialog-container');
@ -95,7 +102,11 @@ export class SyncPage extends BasePage {
// If dialog didn't open, try clicking again
if (!dialogVisible) {
await this.page.waitForTimeout(500);
await this.syncBtn.click({ force: true, noWaitAfter: true });
await this.syncBtn.click({
button: options.isReconfigure ? 'right' : 'left',
force: true,
noWaitAfter: true,
});
await dialog.waitFor({ state: 'visible', timeout: 5000 });
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<SyncProviderId | null>(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<null>((resolve) => {
resolvePrivateCfg = resolve;
});
const syncProviderControl = new FormControl<SyncProviderId | null>(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<SyncProviderId | null>(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

View file

@ -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<DialogSyncCfgComponent>>(MatDialogRef);
private _initialProviderId: SyncProviderId | null = null;
private _providerConfigLoad: Promise<void> = 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<SyncConfig> = {};
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<void> {
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<SyncConfig> = {
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<void> {
let pendingLoad: Promise<void>;
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<ReturnType<SyncProviderManager['getProviderById']>> =
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();
}

View file

@ -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();
});

View file

@ -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,