super-productivity/e2e/tests/sync/supersync-example-task-fresh-client.spec.ts
Johannes Millan dc0378edd6
[codex] Fix sync import conflict from startup example tasks (#7976)
* fix(sync): ignore startup example tasks during import

* fix(sync): defer startup example tasks until initial sync completes

Switch ExampleTasksService to afterInitialSyncDoneStrict$ so example tasks
are not created before the first remote import lands. On a fresh synced
client the imported tasks are then already in the store and the length===0
guard skips creation entirely — closing the same conflict for file-based
providers (Dropbox/WebDAV), which the op-log marker gate does not cover.

The isExampleTask marker + gate exclusion stay as a safety net for the
narrow case where example tasks are created on a still-empty server and an
import arrives before they are uploaded.

Also dedupe the two markRejected call sites into _discardExampleTaskOps and
document the local-only read (a remote isExampleTask flag can never bypass
the conflict dialog) and the intentional mixed-case behavior.

* test(sync): cover mixed-pending and empty-discard import gate cases

Add a SyncImportConflictGateService test for the mixed case (real pending
work + startup example tasks): the conflict dialog is still shown, but the
example-task id is reported in discardablePendingOpIds and intentionally
left for the caller to keep on USE_LOCAL — locking the documented invariant.

Also assert markRejected is not called on the config-only silent-accept path,
pinning the empty-array guard in _discardExampleTaskOps.

* test(sync): integration coverage for example-task import gate

Run the real OperationLogStoreService + SyncImportConflictGateService against
IndexedDB (no mocks) to cover the seam the unit specs stub:
- example-task creates persisted with their real multi-entity payload are
  recognized as non-meaningful and reported as discardable
- after markRejected they are actually excluded from getUnsynced() (never
  uploaded), while a pending config op is preserved
- real user work alongside example tasks still triggers the dialog
- a remote-sourced example-task op is never discardable (gate reads local
  pending only)

Verified load-bearing: removing the gate exclusion turns the first case red.

* fix(sync): mark onboarding done when example tasks are skipped

EXAMPLE_TASKS_CREATED was only written after creating example tasks, so a
synced client that skipped creation because real tasks already existed never
set the flag — and could recreate onboarding tasks on a later startup when
the task list happened to be empty.

Set the flag whenever tasks already exist at the initial-sync gate, so
onboarding runs at most once per client.

* docs(sync): note known limits of the example-task import gate

Document two accepted, narrow residuals of syncing onboarding example tasks:
- upload→piggyback path: example ops accepted in the same upload round are
  already synced and not in the discard list; state stays correct because the
  SYNC_IMPORT filter drops them as CONCURRENT on receivers.
- file-based snapshot path: hasMeaningfulStoreData() counts example tasks (no
  in-state marker), so a fresh Dropbox/WebDAV client that made example tasks
  while sync was disabled can still see the conflict dialog.

* test(sync): cover fresh-client example-task import gate (e2e)

Add a SuperSync e2e proving a fresh client with first-run example tasks accepts
an incoming SYNC_IMPORT without a conflict dialog, plus a backward-compatible
{ allowExampleTasks } opt-in to createSimulatedClient (the harness otherwise
pre-sets SUP_EXAMPLE_TASKS_CREATED).

Uses waitForInitialSync:false + a race between the conflict dialog and sync
completion so the test actually fails when the dialog appears (pre-fix), and
asserts all four onboarding titles are absent. Guards the op-log isExampleTask
marker/gate path (not the afterInitialSyncDoneStrict$ timing, which a fresh
e2e client cannot exercise since sync is disabled at boot).

* docs(sync): link example-task import-gate limitations to #7985
2026-06-03 15:57:53 +02:00

116 lines
4.7 KiB
TypeScript

import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
getTaskTitles,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
/**
* Regression test for #7976 — a fresh client's first-run example tasks must not trip the
* SYNC_IMPORT conflict gate when pulling an account that already has data.
*
* THE BUG: ExampleTasksService creates onboarding task-create ops on first run. When a
* fresh client then syncs an account that already has remote data, those pending ops used
* to make the conflict gate treat the client as having meaningful local work, so it showed
* a `dialog-sync-import-conflict` instead of silently accepting the import.
*
* WHAT THIS GUARDS: the op-log `isExampleTask` marker + gate exclusion. It does NOT
* exercise the afterInitialSyncDoneStrict$ timing — on a fresh e2e client sync is disabled
* at boot, so example tasks are created before sync is configured regardless of the strict
* gate; the marker is what suppresses the dialog here. Covering the strict-wait timing
* needs a client with sync pre-enabled at boot (a separate test).
*
* METHOD: configure sync with `waitForInitialSync: false` so setupSuperSync does NOT
* auto-resolve the conflict dialog (it otherwise clicks "Use Server Data"), then race
* "conflict dialog visible" against "sync complete". The fix means sync completes WITHOUT
* the dialog; pre-fix the dialog wins the race.
*
* REPRODUCE-FIRST: expected to FAIL on `bb4b625645^` (race resolves to 'dialog') and PASS
* on the PR head. Run on both before trusting it.
*
* Run: npm run e2e:supersync:file e2e/tests/sync/supersync-example-task-fresh-client.spec.ts -- --retries=0
*/
const EXAMPLE_TASK_TITLES = [
'Create your first project',
'Set up Sync',
'Learn the keyboard shortcuts',
'Go further',
];
test.describe('@supersync Fresh-client example tasks vs incoming import (#7976)', () => {
test('import is accepted without an example-task conflict dialog', async ({
browser,
baseURL,
testRunId,
}) => {
const appUrl = baseURL || 'http://localhost:4242';
const uniqueId = Date.now();
let seeder: SimulatedE2EClient | null = null;
let freshClient: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Seed the account with real data (example tasks suppressed for the seeder).
seeder = await createSimulatedClient(browser, appUrl, 'Seeder', testRunId);
await seeder.sync.setupSuperSync(syncConfig);
const realTask = `Real-Task-${uniqueId}`;
await seeder.workView.addTask(realTask);
await seeder.sync.syncAndWait();
// Fresh client WITH onboarding example tasks (created at boot, before sync config).
freshClient = await createSimulatedClient(browser, appUrl, 'Fresh', testRunId, {
allowExampleTasks: true,
});
// Configure sync but do NOT let setup auto-resolve the conflict dialog
// (waitForInitialSync:true would click "Use Server Data" and hide the bug).
await freshClient.sync.setupSuperSync({
...syncConfig,
waitForInitialSync: false,
});
// Race: does the example-task conflict dialog appear, or does the initial sync
// complete cleanly? (Pattern from supersync-import-clean-slate.spec.ts.)
const syncResult = await Promise.race([
freshClient.sync.syncImportConflictDialog
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'dialog' as const),
freshClient.sync.syncCheckIcon
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'complete' as const),
]);
// CORE REGRESSION: the import is accepted silently — no example-task conflict dialog.
// Pre-fix this resolves to 'dialog'.
expect(syncResult).toBe('complete');
// The import replaced local state: the real remote task is present and NONE of the
// onboarding example tasks survive. Example tasks live in the INBOX project.
// (If the seeder's task lands elsewhere on your setup, adjust this navigation.)
await freshClient.page.goto('/#/project/INBOX_PROJECT/tasks', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await freshClient.page.waitForLoadState('networkidle');
await waitForTask(freshClient.page, realTask);
const titles = await getTaskTitles(freshClient);
for (const exampleTitle of EXAMPLE_TASK_TITLES) {
expect(titles).not.toContain(exampleTitle);
}
} finally {
if (freshClient) {
await closeClient(freshClient);
}
if (seeder) {
await closeClient(seeder);
}
}
});
});