diff --git a/e2e/tests/sync/supersync-lww-singleton.spec.ts b/e2e/tests/sync/supersync-lww-singleton.spec.ts new file mode 100644 index 0000000000..401f38f071 --- /dev/null +++ b/e2e/tests/sync/supersync-lww-singleton.spec.ts @@ -0,0 +1,241 @@ +import { test, expect } from '../../fixtures/supersync.fixture'; +import type { Page } from '@playwright/test'; +import { + createTestUser, + getSuperSyncConfig, + createSimulatedClient, + closeClient, + type SimulatedE2EClient, +} from '../../utils/supersync-helpers'; + +/** + * SuperSync LWW Singleton Entity Conflict Resolution E2E Tests + * + * Tests that singleton entities (GLOBAL_CONFIG) correctly resolve conflicts + * via LWW when the lwwUpdateMetaReducer handles them. + * + * Before the fix: singleton LWW Update actions were silently dropped because + * lwwUpdateMetaReducer only handled adapter entities. + * + * After the fix: singleton entities replace the entire feature state with the + * winning LWW data. + */ + +/** + * Navigate to Misc Settings section in the config page (General tab) + * and expand it if collapsed. + * + * When `forceReload` is true, navigates away first then back to ensure the + * OnPush config component is fully re-created with fresh store data. + */ +const navigateToMiscSettings = async (page: Page, forceReload = false): Promise => { + if (forceReload) { + // Navigate away first to destroy the config component, then back + await page.goto('/#/tag/TODAY/tasks'); + await page.waitForTimeout(500); + } + await page.goto('/#/config'); + await page.waitForURL(/config/); + await page.waitForTimeout(1000); + + // "Misc Settings" is a collapsible section in the General tab (default tab). + const miscCollapsible = page.locator( + 'collapsible:has(.collapsible-title:has-text("Misc"))', + ); + await miscCollapsible.scrollIntoViewIfNeeded(); + await miscCollapsible.waitFor({ state: 'visible', timeout: 10000 }); + + // Expand if collapsed (host element gets .isExpanded class when expanded) + const isExpanded = await miscCollapsible.evaluate((el: Element) => + el.classList.contains('isExpanded'), + ); + if (!isExpanded) { + await miscCollapsible.locator('.collapsible-header').click(); + await page.waitForTimeout(500); + } +}; + +/** + * Toggle a slide-toggle setting by its label text. + */ +const toggleSetting = async (page: Page, labelText: string): Promise => { + const toggle = page + .locator('mat-slide-toggle, mat-checkbox') + .filter({ hasText: labelText }) + .first(); + await toggle.scrollIntoViewIfNeeded(); + await toggle.click(); + await page.waitForTimeout(300); +}; + +/** + * Check whether a slide-toggle is currently checked (ON). + */ +const isSettingChecked = async (page: Page, labelText: string): Promise => { + const toggle = page + .locator('mat-slide-toggle, mat-checkbox') + .filter({ hasText: labelText }) + .first(); + await toggle.scrollIntoViewIfNeeded(); + // mat-slide-toggle adds 'mat-mdc-slide-toggle-checked' when checked + // mat-checkbox adds 'mat-mdc-checkbox-checked' when checked + const classes = (await toggle.getAttribute('class')) ?? ''; + return classes.includes('checked'); +}; + +test.describe('@supersync SuperSync LWW Singleton Conflict Resolution', () => { + /** + * Scenario: LWW Singleton Global Config Conflict Resolution + * + * Tests that when two clients make concurrent changes to globalConfig, + * LWW correctly resolves the conflict and all clients converge to the + * winning state via the lwwUpdateMetaReducer singleton fix. + * + * Key insight: For LOCAL to win the LWW conflict (and trigger creation of + * a `[GLOBAL_CONFIG] LWW Update` operation), the client with the later + * timestamp must sync AFTER the earlier client's op is already on the server. + * + * Flow: + * 1. Client A + Client B set up with SuperSync + * 2. Client A toggles both settings ON, syncs + * 3. Client B syncs (downloads config), verifies both toggles are ON + * 4. Client B toggles "Disable all animations" OFF (earlier timestamp) + * 5. Client B syncs first → uploads B's change to server + * 6. Wait for timestamp gap + * 7. Client A toggles "Disable celebration" OFF (later timestamp) + * 8. Client A syncs → upload conflicts with B's server op + * → LOCAL A wins (later timestamp) → creates [GLOBAL_CONFIG] LWW Update + * 9. Client A syncs again → uploads LWW Update to server + * 10. Client B syncs → downloads LWW Update → lwwUpdateMetaReducer applies it + * 11. Verify both clients converge to A's winning state + */ + test('LWW: Global config singleton conflict resolves correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + const appUrl = baseURL || 'http://localhost:4242'; + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // Setup clients + clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + + // Converge after setup: both setupSuperSync calls create GLOBAL_CONFIG:sync + // operations that may conflict. Resolve them now so the globalConfig is stable + // before we test the misc settings conflict. + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + console.log('[LWW-Singleton] Setup convergence complete'); + + // 2. Client A navigates to Misc settings and toggles both settings ON + await navigateToMiscSettings(clientA.page); + await toggleSetting(clientA.page, 'Disable all animations'); + await toggleSetting(clientA.page, 'Disable celebration'); + console.log('[LWW-Singleton] Client A toggled both settings ON'); + + // Wait for state to settle then sync + await clientA.page.waitForTimeout(500); + await clientA.sync.syncAndWait(); + console.log('[LWW-Singleton] Client A synced (initial)'); + + // 3. Client B syncs to get the config, then verify UI + await clientB.sync.syncAndWait(); + console.log('[LWW-Singleton] Client B synced (download)'); + + await navigateToMiscSettings(clientB.page, true); + const bAnimAfterSync = await isSettingChecked( + clientB.page, + 'Disable all animations', + ); + const bCelebAfterSync = await isSettingChecked(clientB.page, 'Disable celebration'); + expect(bAnimAfterSync).toBe(true); + expect(bCelebAfterSync).toBe(true); + console.log('[LWW-Singleton] Client B confirmed both settings ON'); + + // 4. Client B toggles "Disable all animations" OFF (earlier timestamp) + await toggleSetting(clientB.page, 'Disable all animations'); + await clientB.page.waitForTimeout(500); + console.log( + '[LWW-Singleton] Client B toggled animations OFF (B: anim=OFF, celeb=ON)', + ); + + // 5. Client B syncs FIRST → uploads B's change to server + await clientB.sync.syncAndWait(); + console.log('[LWW-Singleton] Client B synced (uploaded change)'); + + // 6. Wait for timestamp gap to ensure A's change has a later timestamp + await clientA.page.waitForTimeout(2000); + + // 7. Client A toggles "Disable celebration" OFF (later timestamp) + await toggleSetting(clientA.page, 'Disable celebration'); + await clientA.page.waitForTimeout(500); + console.log( + '[LWW-Singleton] Client A toggled celebration OFF (A: anim=ON, celeb=OFF)', + ); + + // 8. Client A syncs → upload conflicts with B's op on server + // LOCAL A wins (later timestamp) → creates [GLOBAL_CONFIG] LWW Update + await clientA.sync.syncAndWait(); + console.log( + '[LWW-Singleton] Client A synced (conflict → LOCAL wins → LWW Update created)', + ); + + // 9. Client A syncs again → uploads LWW Update to server + await clientA.sync.syncAndWait(); + console.log('[LWW-Singleton] Client A synced (LWW Update uploaded)'); + + // 10. Client B syncs → downloads LWW Update → meta-reducer applies it + await clientB.sync.syncAndWait(); + console.log('[LWW-Singleton] Client B synced (LWW Update applied)'); + + // Extra convergence round + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + console.log('[LWW-Singleton] Final convergence sync complete'); + + // 11. Verify both clients show the same winning state. + // A's state should win (later timestamp): anim=ON, celeb=OFF + // Force reload to re-create the OnPush config component with fresh store data. + await navigateToMiscSettings(clientA.page, true); + const aAnimFinal = await isSettingChecked(clientA.page, 'Disable all animations'); + const aCelebFinal = await isSettingChecked(clientA.page, 'Disable celebration'); + + await navigateToMiscSettings(clientB.page, true); + const bAnimFinal = await isSettingChecked(clientB.page, 'Disable all animations'); + const bCelebFinal = await isSettingChecked(clientB.page, 'Disable celebration'); + + console.log( + `[LWW-Singleton] A: anim=${aAnimFinal}, celeb=${aCelebFinal}` + + ` | B: anim=${bAnimFinal}, celeb=${bCelebFinal}`, + ); + + // Both clients should have converged to the same state + expect(aAnimFinal).toBe(bAnimFinal); + expect(aCelebFinal).toBe(bCelebFinal); + + // The winning state should be A's state: animations ON, celebration OFF + expect(aAnimFinal).toBe(true); + expect(aCelebFinal).toBe(false); + expect(bAnimFinal).toBe(true); + expect(bCelebFinal).toBe(false); + + console.log( + '[LWW-Singleton] Global config singleton conflict resolved correctly via LWW', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); +}); diff --git a/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.spec.ts index fc8e4006d8..6fba0db15e 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.spec.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.spec.ts @@ -313,12 +313,131 @@ describe('lwwUpdateMetaReducer', () => { }; spyOn(OpLog, 'warn'); - reducer(state, action); + // devError throws in non-production; catch it so we can verify the warning + try { + reducer(state, action); + } catch { + // Expected: devError throws in test environment + } expect(OpLog.warn).toHaveBeenCalledWith( - jasmine.stringMatching(/Unknown or non-adapter entity type: UNKNOWN_ENTITY/), + jasmine.stringMatching(/Unknown entity type: UNKNOWN_ENTITY/), ); - expect(mockReducer).toHaveBeenCalledWith(state, action); + }); + }); + + describe('singleton entity LWW Updates', () => { + const createMockStateWithSingletons = (): Partial => + ({ + ...createMockState(), + globalConfig: { + misc: { + isDisableAnimations: false, + isDisableCelebration: false, + }, + sound: { isEnabled: true }, + }, + timeTracking: { + currentSessionTime: 0, + isTrackingReminder: true, + }, + }) as unknown as Partial; + + it('should replace globalConfig state with LWW winning data', () => { + const state = createMockStateWithSingletons(); + const action = { + type: '[GLOBAL_CONFIG] LWW Update', + misc: { + isDisableAnimations: true, + isDisableCelebration: true, + }, + sound: { isEnabled: false }, + meta: { + isPersistent: true, + entityType: 'GLOBAL_CONFIG', + isRemote: true, + }, + }; + + reducer(state, action); + + expect(mockReducer).toHaveBeenCalled(); + const updatedState = mockReducer.calls.mostRecent().args[0] as Record< + string, + unknown + >; + const globalConfig = updatedState['globalConfig'] as Record; + expect(globalConfig).toBeDefined(); + expect( + (globalConfig['misc'] as Record)['isDisableAnimations'], + ).toBe(true); + expect( + (globalConfig['misc'] as Record)['isDisableCelebration'], + ).toBe(true); + expect((globalConfig['sound'] as Record)['isEnabled']).toBe(false); + }); + + it('should replace timeTracking state with LWW winning data', () => { + const state = createMockStateWithSingletons(); + const action = { + type: '[TIME_TRACKING] LWW Update', + currentSessionTime: 5000, + isTrackingReminder: false, + meta: { + isPersistent: true, + entityType: 'TIME_TRACKING', + isRemote: true, + }, + }; + + reducer(state, action); + + expect(mockReducer).toHaveBeenCalled(); + const updatedState = mockReducer.calls.mostRecent().args[0] as Record< + string, + unknown + >; + const timeTracking = updatedState['timeTracking'] as Record; + expect(timeTracking).toBeDefined(); + expect(timeTracking['currentSessionTime']).toBe(5000); + expect(timeTracking['isTrackingReminder']).toBe(false); + }); + + it('should not require id field for singleton entities', () => { + const state = createMockStateWithSingletons(); + const action = { + type: '[GLOBAL_CONFIG] LWW Update', + misc: { isDisableAnimations: true }, + meta: { isPersistent: true, entityType: 'GLOBAL_CONFIG', isRemote: true }, + }; + + spyOn(OpLog, 'warn'); + reducer(state, action); + + expect(OpLog.warn).not.toHaveBeenCalledWith( + jasmine.stringMatching(/Entity data has no id/), + ); + expect(mockReducer).toHaveBeenCalled(); + }); + + it('should strip type and meta from applied singleton state', () => { + const state = createMockStateWithSingletons(); + const action = { + type: '[GLOBAL_CONFIG] LWW Update', + misc: { isDisableAnimations: true }, + meta: { isPersistent: true, entityType: 'GLOBAL_CONFIG', isRemote: true }, + }; + + reducer(state, action); + + expect(mockReducer).toHaveBeenCalled(); + const updatedState = mockReducer.calls.mostRecent().args[0] as Record< + string, + unknown + >; + const globalConfig = updatedState['globalConfig'] as Record; + expect(globalConfig['type']).toBeUndefined(); + expect(globalConfig['meta']).toBeUndefined(); }); }); diff --git a/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts b/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts index f7ae824aca..1f3e2071b5 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts @@ -2,7 +2,12 @@ import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; import { EntityAdapter } from '@ngrx/entity'; import { RootState } from '../../root-state'; import { EntityType } from '../../../op-log/core/operation.types'; -import { getEntityConfig, isAdapterEntity } from '../../../op-log/core/entity-registry'; +import { + getEntityConfig, + isAdapterEntity, + isSingletonEntity, +} from '../../../op-log/core/entity-registry'; +import { devError } from '../../../util/dev-error'; import { PROJECT_FEATURE_NAME, projectAdapter, @@ -377,18 +382,16 @@ export const lwwUpdateMetaReducer: MetaReducer = ( const entityType = match[1] as EntityType; const config = getEntityConfig(entityType); - if (!config || !isAdapterEntity(config)) { - OpLog.warn( - `lwwUpdateMetaReducer: Unknown or non-adapter entity type: ${entityType}`, - ); + if (!config) { + OpLog.warn(`lwwUpdateMetaReducer: Unknown entity type: ${entityType}`); + devError(`lwwUpdateMetaReducer: Unknown entity type: ${entityType}`); return reducer(state, action); } - const { featureName, adapter } = config; - if (!featureName || !adapter) { - OpLog.warn( - `lwwUpdateMetaReducer: Missing featureName or adapter for: ${entityType}`, - ); + const { featureName } = config; + if (!featureName) { + OpLog.warn(`lwwUpdateMetaReducer: Missing featureName for: ${entityType}`); + devError(`lwwUpdateMetaReducer: Missing featureName for: ${entityType}`); return reducer(state, action); } @@ -397,6 +400,7 @@ export const lwwUpdateMetaReducer: MetaReducer = ( if (!featureState) { OpLog.warn(`lwwUpdateMetaReducer: Feature state not found: ${featureName}`); + devError(`lwwUpdateMetaReducer: Feature state not found: ${featureName}`); return reducer(state, action); } @@ -409,6 +413,28 @@ export const lwwUpdateMetaReducer: MetaReducer = ( } } + // Singleton entities: replace entire feature state with the winning data + if (isSingletonEntity(config)) { + const updatedState: RootState = { + ...rootState, + [featureName]: { ...entityData }, + }; + return reducer(updatedState, action); + } + + if (!isAdapterEntity(config)) { + OpLog.warn(`lwwUpdateMetaReducer: Unsupported storage pattern for: ${entityType}`); + devError(`lwwUpdateMetaReducer: Unsupported storage pattern for: ${entityType}`); + return reducer(state, action); + } + + const { adapter } = config; + if (!adapter) { + OpLog.warn(`lwwUpdateMetaReducer: Missing adapter for: ${entityType}`); + devError(`lwwUpdateMetaReducer: Missing adapter for: ${entityType}`); + return reducer(state, action); + } + if (!entityData['id']) { OpLog.warn('lwwUpdateMetaReducer: Entity data has no id'); return reducer(state, action);