mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
test(e2e): incoming SYNC_IMPORT must prompt on pending non-task work
Regression test for the widened incoming-full-state conflict gate and the piggyback pre-upload pending-snapshot fix: a pending SIMPLE_COUNTER change (non-TASK/PROJECT/TAG/NOTE) must trigger the conflict dialog instead of being silently discarded by an incoming SYNC_IMPORT from another client. Not yet run (SuperSync docker stack is unavailable in this sandbox); run via npm run e2e:supersync:file or the E2E Tests (Scheduled) workflow.
This commit is contained in:
parent
79f91e36fe
commit
6e6ce34c15
1 changed files with 146 additions and 0 deletions
146
e2e/tests/sync/supersync-conflict-gate-non-task-work.spec.ts
Normal file
146
e2e/tests/sync/supersync-conflict-gate-non-task-work.spec.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import { ImportPage } from '../../pages/import.page';
|
||||
|
||||
/**
|
||||
* SuperSync: incoming SYNC_IMPORT must not silently discard NON-task local work.
|
||||
*
|
||||
* Regression test for the widened incoming-full-state conflict gate + the
|
||||
* piggyback pre-upload pending-snapshot fix (sync-import-conflict-gate.service).
|
||||
*
|
||||
* Before the fix the gate only treated CRUD ops on TASK/PROJECT/TAG/NOTE as
|
||||
* "meaningful", so a pending SIMPLE_COUNTER change (and MOV/BATCH, time
|
||||
* tracking, planner, boards, ...) was silently overwritten when an incoming
|
||||
* SYNC_IMPORT from another client arrived — no conflict dialog, no choice.
|
||||
* The gate now treats every synced entity as user work, and the piggyback path
|
||||
* judges against the pending set captured BEFORE the upload round (ops accepted
|
||||
* mid-round are marked synced and would otherwise vanish from a live re-read).
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Client A + B set up sync; A creates a click counter, increments it, syncs.
|
||||
* 2. Client B syncs and receives the counter (B now has synced history).
|
||||
* 3. Client B increments the counter locally — a pending SIMPLE_COUNTER op it
|
||||
* does NOT sync.
|
||||
* 4. Client A imports a backup (creates a SYNC_IMPORT) and syncs it.
|
||||
* 5. Client B triggers a sync. The SYNC_IMPORT conflict dialog MUST appear so
|
||||
* the user can choose, instead of the counter change being discarded.
|
||||
*
|
||||
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-conflict-gate-non-task-work.spec.ts
|
||||
*/
|
||||
|
||||
const createClickCounter = async (
|
||||
client: SimulatedE2EClient,
|
||||
title: string,
|
||||
): Promise<void> => {
|
||||
await client.page.goto('/#/habits');
|
||||
await client.page.waitForURL(/habits/);
|
||||
await client.page.waitForTimeout(500);
|
||||
|
||||
const addBtn = client.page.locator('.add-habit-btn');
|
||||
await addBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await addBtn.click();
|
||||
|
||||
const dialog = client.page.locator('dialog-simple-counter-edit-settings');
|
||||
await dialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
const titleInput = dialog.locator('formly-form input').first();
|
||||
await titleInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await titleInput.fill(title);
|
||||
|
||||
const typeSelect = dialog.locator('mat-select').first();
|
||||
await typeSelect.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await client.page.waitForTimeout(500);
|
||||
await typeSelect.click();
|
||||
await client.page.waitForTimeout(300);
|
||||
await client.page.locator('mat-option:has-text("Click Counter")').click();
|
||||
await client.page.waitForTimeout(300);
|
||||
|
||||
await dialog.locator('button[type="submit"]').click();
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
await client.page.waitForTimeout(500);
|
||||
|
||||
await client.page.goto('/#/tag/TODAY/tasks');
|
||||
await client.page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/);
|
||||
await client.page.waitForTimeout(500);
|
||||
};
|
||||
|
||||
const incrementClickCounter = async (client: SimulatedE2EClient): Promise<void> => {
|
||||
const counter = client.page
|
||||
.locator('.counters-action-group simple-counter-button')
|
||||
.last();
|
||||
await counter.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await counter.locator('.main-btn').click();
|
||||
await client.page.waitForTimeout(200);
|
||||
};
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('@supersync incoming SYNC_IMPORT preserves non-task local work', () => {
|
||||
test('a pending simple-counter change prompts the conflict dialog instead of being discarded', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
test.slow();
|
||||
|
||||
const counterTitle = `GateCounter-${testRunId}-${Date.now()}`;
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ===== PHASE 1: A creates + increments a counter, syncs =====
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await createClickCounter(clientA, counterTitle);
|
||||
await incrementClickCounter(clientA);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Gate] Client A created + synced a simple counter');
|
||||
|
||||
// ===== PHASE 2: B joins, receives the counter (now non-fresh) =====
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientB.page.goto('/#/tag/TODAY/tasks');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
console.log('[Gate] Client B synced and received the counter');
|
||||
|
||||
// ===== PHASE 3: B makes a pending NON-task change (does NOT sync) =====
|
||||
await incrementClickCounter(clientB);
|
||||
console.log('[Gate] Client B incremented the counter locally (pending, unsynced)');
|
||||
|
||||
// ===== PHASE 4: A imports a backup (SYNC_IMPORT) and syncs it =====
|
||||
const importPageA = new ImportPage(clientA.page);
|
||||
await importPageA.navigateToImportPage();
|
||||
await importPageA.importBackupFile(ImportPage.getFixturePath('test-backup.json'));
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Gate] Client A imported a backup and synced the SYNC_IMPORT');
|
||||
|
||||
// ===== PHASE 5: B syncs — the conflict dialog MUST appear =====
|
||||
// triggerSync() does NOT auto-resolve dialogs, so we can assert on it.
|
||||
await clientB.sync.triggerSync();
|
||||
|
||||
await expect(clientB.sync.syncImportConflictDialog).toBeVisible({ timeout: 30000 });
|
||||
console.log('[Gate] ✓ Conflict dialog shown for pending non-task work');
|
||||
|
||||
// Resolving with "Use My Data" keeps B's local work; the point of the test
|
||||
// is that B was given the choice at all.
|
||||
await clientB.sync.syncImportUseLocalBtn.click();
|
||||
await clientB.sync.syncImportConflictDialog.waitFor({
|
||||
state: 'hidden',
|
||||
timeout: 10000,
|
||||
});
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue