diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 9695fa9b0..d6dd65f7a 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -7,7 +7,7 @@ on: jobs: ios-app-store-release: - runs-on: macos-latest + runs-on: macos-26 env: UNSPLASH_KEY: ${{ secrets.UNSPLASH_KEY }} UNSPLASH_CLIENT_ID: ${{ secrets.UNSPLASH_CLIENT_ID }} @@ -25,6 +25,15 @@ jobs: with: persist-credentials: false + - name: Verify Xcode and SDK version + run: | + echo "=== Xcode Version ===" + xcodebuild -version + echo "" + echo "=== SDK Information ===" + xcrun --show-sdk-version + xcodebuild -showsdks | grep -E "(iOS|iphoneos)" + # work around for npm installs from git+https://github.com/johannesjo/J2M.git - name: Reconfigure git to use HTTP authentication run: > diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7ebf3d50..5b907cf17 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,21 +59,9 @@ jobs: - name: Test Unit run: npm run test - - name: Start WebDAV Server - run: | - docker compose up -d webdav - - # Wait for WebDAV server to be ready - chmod +x ./scripts/wait-for-webdav.sh - ./scripts/wait-for-webdav.sh - - name: Test E2E run: npm run e2e - - name: Print WebDAV logs on failure - if: ${{ failure() }} - run: docker compose logs webdav - - name: 'Upload E2E results on failure' if: ${{ failure() }} uses: actions/upload-artifact@v6 diff --git a/.github/workflows/e2e-scheduled.yml b/.github/workflows/e2e-scheduled.yml new file mode 100644 index 000000000..67b1f83c7 --- /dev/null +++ b/.github/workflows/e2e-scheduled.yml @@ -0,0 +1,108 @@ +name: 'E2E Tests (Scheduled)' + +on: + schedule: + - cron: '0 2 * * *' # 2 AM UTC daily + + workflow_dispatch: + + push: + branches: [master, release/*] + paths: + - 'src/app/imex/sync/**' + - 'src/app/op-log/**' + - 'e2e/tests/**' + - 'docker-compose.yaml' + - 'docker-compose.supersync.yaml' + +permissions: + contents: read + +jobs: + e2e-tests: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + UNSPLASH_KEY: ${{ secrets.UNSPLASH_KEY }} + UNSPLASH_CLIENT_ID: ${{ secrets.UNSPLASH_CLIENT_ID }} + + steps: + - uses: actions/setup-node@v6 + with: + node-version: 20 + + - name: Check out Git repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Reconfigure git to use HTTP authentication + run: > + git config --global url."https://github.com/".insteadOf + ssh://git@github.com/ + + - name: Get npm cache directory + id: npm-cache-dir + run: | + echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v5 + id: npm-cache + with: + path: ${{ steps.npm-cache-dir.outputs.dir }} + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install npm Packages + run: npm i + + - name: Install Playwright Browsers + run: | + npx playwright install --with-deps chromium + npx playwright install-deps chromium + + - run: npm run env + + - name: Create .env file for Docker Compose + run: | + cp .env.example .env + + - name: Start Docker Services (WebDAV + SuperSync) + run: | + docker compose up -d webdav + docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml up -d --build supersync + + # Wait for WebDAV + chmod +x ./scripts/wait-for-webdav.sh + ./scripts/wait-for-webdav.sh + + # Wait for SuperSync + echo "⏳ Waiting for SuperSync server..." + until curl -s http://localhost:1901/health > /dev/null 2>&1; do sleep 1; done + echo "✓ SuperSync server ready!" + + - name: Run E2E Tests + run: npm run e2e + + - name: Stop Docker Services + if: always() + run: | + docker compose down + docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml down supersync + + - name: Print Docker Logs on Failure + if: ${{ failure() }} + run: | + echo "=== WebDAV Logs ===" + docker compose logs webdav + echo "=== SuperSync Logs ===" + docker compose -f docker-compose.yaml -f docker-compose.supersync.yaml logs supersync + + - name: Upload E2E Results on Failure + if: ${{ failure() }} + uses: actions/upload-artifact@v6 + with: + name: e2e-results-${{ github.run_id }} + path: .tmp/e2e-test-results/**/*.* + retention-days: 30 diff --git a/.github/workflows/lint-and-test-pr.yml b/.github/workflows/lint-and-test-pr.yml index 8328eff70..867a2f73e 100644 --- a/.github/workflows/lint-and-test-pr.yml +++ b/.github/workflows/lint-and-test-pr.yml @@ -50,18 +50,8 @@ jobs: - run: npm run lint - run: npm run int:test # Validate i18n JSON files - run: npm run test - - name: Start WebDAV Server - run: | - docker compose up -d webdav - - # Wait for WebDAV server to be ready - chmod +x ./scripts/wait-for-webdav.sh - ./scripts/wait-for-webdav.sh - - run: npm run e2e - - name: Print WebDAV logs on failure - if: ${{ failure() }} - run: docker compose logs webdav - + - name: Test E2E + run: npm run e2e - name: 'Upload E2E results on failure' if: ${{ failure() }} uses: actions/upload-artifact@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index da2e2b45a..a41dc1fa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +# [17.0.0-RC.11](https://github.com/super-productivity/super-productivity/compare/v17.0.0-RC.10...v17.0.0-RC.11) (2026-01-18) + +### Bug Fixes + +- **e2e:** create missing .env file and update GitHub Actions syntax ([1f4eecb](https://github.com/super-productivity/super-productivity/commit/1f4eecbe70130dc005b5d99885b7eaf35397ec0d)) + +# [17.0.0-RC.10](https://github.com/super-productivity/super-productivity/compare/v17.0.0-RC.9...v17.0.0-RC.10) (2026-01-18) + +# [17.0.0-RC.9](https://github.com/super-productivity/super-productivity/compare/v17.0.0-RC.8...v17.0.0-RC.9) (2026-01-17) + +# [17.0.0-RC.8](https://github.com/super-productivity/super-productivity/compare/v17.0.0-RC.7...v17.0.0-RC.8) (2026-01-17) + +### Bug Fixes + +- **ci:** add E2E tests to PR workflow ([4d78d7b](https://github.com/super-productivity/super-productivity/commit/4d78d7b9fc9363c5b545f95143c52a003613078e)) +- **snap:** remove duplicate plugs from configuration ([dbaaab8](https://github.com/super-productivity/super-productivity/commit/dbaaab8faace09434c58ed6d9054e281df55ba72)) +- **sync:** pass clientId to downloadOps for accurate snapshot detection ([978d71f](https://github.com/super-productivity/super-productivity/commit/978d71f40d2ac60f0d7bd33ae8581de29f6cd791)) + # [17.0.0-RC.7](https://github.com/super-productivity/super-productivity/compare/v17.0.0-RC.5...v17.0.0-RC.7) (2026-01-17) ### Bug Fixes diff --git a/android/app/build.gradle b/android/app/build.gradle index 4d8839ce1..de89830f5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -20,8 +20,8 @@ android { minSdkVersion 24 targetSdkVersion 35 compileSdk 35 - versionCode 17_00_00_0007 - versionName "17.0.0-RC.7" + versionCode 17_00_00_0011 + versionName "17.0.0-RC.11" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" manifestPlaceholders = [ hostName : "app.super-productivity.com", diff --git a/e2e/pages/task.page.ts b/e2e/pages/task.page.ts index 64a5933ae..c33372f97 100644 --- a/e2e/pages/task.page.ts +++ b/e2e/pages/task.page.ts @@ -54,9 +54,14 @@ export class TaskPage extends BasePage { async markTaskAsDone(task: Locator): Promise { await task.waitFor({ state: 'visible' }); await task.hover(); + + // Give hover effects time to settle + await this.page.waitForTimeout(100); + const doneBtn = task.locator(TASK_DONE_BTN); await doneBtn.waitFor({ state: 'visible', timeout: 5000 }); await doneBtn.click(); + await waitForAngularStability(this.page); } diff --git a/e2e/tests/focus-mode/pomodoro-timer-sync-bug-5954.spec.ts b/e2e/tests/focus-mode/pomodoro-timer-sync-bug-5954.spec.ts index 83940409d..d6a3294ab 100644 --- a/e2e/tests/focus-mode/pomodoro-timer-sync-bug-5954.spec.ts +++ b/e2e/tests/focus-mode/pomodoro-timer-sync-bug-5954.spec.ts @@ -15,6 +15,7 @@ import { test, expect } from '../../fixtures/test.fixture'; import { Page, Locator } from '@playwright/test'; import { WorkViewPage } from '../../pages/work-view.page'; +import { waitForAngularStability } from '../../utils/waits'; // Helper to open focus mode and select a task const openFocusModeWithTask = async ( @@ -336,8 +337,15 @@ test.describe('Bug #5954: Pomodoro timer sync issues', () => { await playButton.click(); await expect(task).toHaveClass(/isCurrent/, { timeout: 5000 }); - // Step 2: Mark task as done (this stops tracking) - await taskPage.markTaskAsDone(task); + // Wait for Angular to finish re-rendering the task hover controls + // When isCurrent changes, the hover controls switch from play to pause button + await waitForAngularStability(page); + + // Step 2: Mark task as done using keyboard shortcut + // This bypasses the button click issue caused by continuous re-renders + // from the progress bar while tracking is active + await task.focus(); + await page.keyboard.press('d'); // Keyboard shortcut for toggle done await expect(task).toHaveClass(/isDone/, { timeout: 5000 }); await expect(task).not.toHaveClass(/isCurrent/, { timeout: 5000 }); diff --git a/electron-builder.yaml b/electron-builder.yaml index 5a1abc65f..b22df39d1 100644 --- a/electron-builder.yaml +++ b/electron-builder.yaml @@ -76,17 +76,13 @@ snap: # https://github.com/super-productivity/super-productivity/issues/4920 FC_CACHEDIR: $SNAP_USER_DATA/.cache/fontconfig plugs: - - default + - default # Includes: home, desktop, desktop-legacy, x11, wayland, etc. - password-manager-service - system-observe - login-session-observe # Fix for issue #6031: Add filesystem access for local file sync # https://github.com/super-productivity/super-productivity/issues/6031 - - home # Allows file picker to access user's home directory for sync - removable-media # Allows sync to external drives/USB storage - # Fix for issue #6031: Add desktop integration for taskbar pinning in Cinnamon - - desktop # Provides D-Bus interfaces for window manager integration - - desktop-legacy # Fallback for older desktop environments flatpak: runtimeVersion: '23.08' diff --git a/package-lock.json b/package-lock.json index 5558ace63..ac1f4db14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "superProductivity", - "version": "17.0.0-RC.7", + "version": "17.0.0-RC.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "superProductivity", - "version": "17.0.0-RC.7", + "version": "17.0.0-RC.11", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 195718299..254b3afe8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "superProductivity", - "version": "17.0.0-RC.7", + "version": "17.0.0-RC.11", "description": "ToDo list and Time Tracking", "keywords": [ "ToDo", diff --git a/src/app/features/focus-mode/store/focus-mode.effects.spec.ts b/src/app/features/focus-mode/store/focus-mode.effects.spec.ts index 392078d29..5a1a8264e 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.spec.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.spec.ts @@ -3364,8 +3364,8 @@ describe('FocusModeEffects', () => { store.overrideSelector(selectIsFocusModeEnabled, true); store.refreshState(); - // Re-inject effects after setting up selectors - effects = TestBed.inject(FocusModeEffects); + // Dispatch a tick action to trigger the effect + actions$ = of(actions.tick()); // Subscribe to the effect to trigger it effects.updateBanner$.pipe(take(1)).subscribe(() => { @@ -3396,7 +3396,8 @@ describe('FocusModeEffects', () => { store.overrideSelector(selectIsFocusModeEnabled, true); store.refreshState(); - effects = TestBed.inject(FocusModeEffects); + // Dispatch a tick action to trigger the effect + actions$ = of(actions.tick()); effects.updateBanner$.pipe(take(1)).subscribe(() => { expect(bannerServiceMock.open).toHaveBeenCalled(); @@ -3425,7 +3426,8 @@ describe('FocusModeEffects', () => { store.overrideSelector(selectIsFocusModeEnabled, true); store.refreshState(); - effects = TestBed.inject(FocusModeEffects); + // Dispatch a tick action to trigger the effect + actions$ = of(actions.tick()); effects.updateBanner$.pipe(take(1)).subscribe(() => { expect(bannerServiceMock.open).toHaveBeenCalled(); @@ -3456,7 +3458,8 @@ describe('FocusModeEffects', () => { store.overrideSelector(selectIsFocusModeEnabled, true); store.refreshState(); - effects = TestBed.inject(FocusModeEffects); + // Dispatch a tick action to trigger the effect + actions$ = of(actions.tick()); effects.updateBanner$.pipe(take(1)).subscribe(() => { expect(bannerServiceMock.open).toHaveBeenCalled(); diff --git a/src/app/op-log/sync-providers/file-based/file-based-sync-adapter.service.spec.ts b/src/app/op-log/sync-providers/file-based/file-based-sync-adapter.service.spec.ts index 4a6aa44a3..b89837aa8 100644 --- a/src/app/op-log/sync-providers/file-based/file-based-sync-adapter.service.spec.ts +++ b/src/app/op-log/sync-providers/file-based/file-based-sync-adapter.service.spec.ts @@ -1133,5 +1133,111 @@ describe('FileBasedSyncAdapterService', () => { expect(result.snapshotState).toEqual({ tasks: [{ id: 't1' }] }); }); + + it('should detect snapshot replacement when syncVersion remains at 1 (with excludeClient)', async () => { + // This test verifies the false negative fix using the clientId-based detection. + // Scenario: + // 1. Client A has synced before (lastServerSeq=1, syncVersion=1) + // 2. Client B uploads a snapshot: syncVersion=1, recentOps=[], clientId=client-b + // 3. Client A downloads with excludeClient='client-a' + // Expected: Should detect gap because snapshot.clientId !== excludeClient + + // Step 1: Simulate initial download for client-a (has ops) + const initialData = createMockSyncData({ + syncVersion: 1, + clientId: 'client-a', + recentOps: [ + { + id: 'op1', + c: 'client-a', + a: 'HA', + o: 'CRT', + e: 'TASK', + d: 't1', + p: { title: 'Task 1' }, + v: { client_a: 1 }, + t: Date.now(), + s: 1, + }, + ], + }); + + mockProvider.downloadFile.and.returnValue( + Promise.resolve({ dataStr: addPrefix(initialData), rev: 'rev-1' }), + ); + + // Download with excludeClient='client-a' to establish baseline + const initialResult = await adapter.downloadOps(0, 'client-a'); + expect(initialResult.ops.length).toBe(0); // Own op filtered out + expect(initialResult.latestSeq).toBe(1); + + // Update lastServerSeq to 1 (simulates that client-a has synced) + await adapter.setLastServerSeq(1); + + // Step 2: Another client (client-b) uploads a snapshot + // syncVersion STAYS at 1 (doesn't increment), recentOps cleared, clientId changes + const snapshotData = createMockSyncData({ + syncVersion: 1, // SAME VERSION as before + clientId: 'client-b', // DIFFERENT CLIENT + recentOps: [], // Snapshot cleared ops + state: { tasks: [{ id: 't2', title: 'Task 2' }] }, + }); + + mockProvider.downloadFile.and.returnValue( + Promise.resolve({ dataStr: addPrefix(snapshotData), rev: 'rev-2' }), + ); + + // Step 3: Client A downloads again with sinceSeq=1 and excludeClient='client-a' + const result = await adapter.downloadOps(1, 'client-a'); + + // Should detect gap because: + // - syncData.clientId ('client-b') !== excludeClient ('client-a') + // - This means another client uploaded, so gap should be detected + expect(result.gapDetected).toBe(true); + expect(result.ops.length).toBe(0); // Gap detected, no ops returned + }); + + it('should NOT detect gap when own client uploads snapshot (with excludeClient)', async () => { + // This test verifies false positive prevention using clientId-based detection. + // Scenario: + // 1. Client A uploads a snapshot: syncVersion=1, recentOps=[], clientId=client-a + // 2. Client A immediately downloads with excludeClient='client-a' + // Expected: Should NOT detect gap because snapshot.clientId === excludeClient + + // Step 1: Upload snapshot as client-a + const snapshotData = createMockSyncData({ + syncVersion: 1, + clientId: 'client-a', + recentOps: [], + state: { tasks: [] }, + }); + + mockProvider.downloadFile.and.returnValue( + Promise.resolve({ dataStr: addPrefix(snapshotData), rev: 'rev-1' }), + ); + mockProvider.uploadFile.and.returnValue(Promise.resolve({ rev: 'rev-2' })); + + await adapter.uploadSnapshot( + {}, + 'client-a', + 'initial', + { client_a: 1 }, + 1, + undefined, + 'test-op-id-snapshot', + ); + + const seqAfterUpload = await adapter.getLastServerSeq(); + expect(seqAfterUpload).toBe(1); + + // Step 2: Download with excludeClient='client-a' (same client that uploaded) + const result = await adapter.downloadOps(1, 'client-a'); + + // Should NOT detect gap because: + // - syncData.clientId ('client-a') === excludeClient ('client-a') + // - This means we just uploaded, so no gap + expect(result.gapDetected).toBe(false); + expect(result.snapshotState).toBeUndefined(); // No snapshot state when sinceSeq > 0 + }); }); }); diff --git a/src/app/op-log/sync/operation-log-download.service.spec.ts b/src/app/op-log/sync/operation-log-download.service.spec.ts index f83791748..ea08b64d7 100644 --- a/src/app/op-log/sync/operation-log-download.service.spec.ts +++ b/src/app/op-log/sync/operation-log-download.service.spec.ts @@ -1,4 +1,4 @@ -import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; import { OperationLogDownloadService } from './operation-log-download.service'; import { OperationLogStoreService } from '../persistence/operation-log-store.service'; import { LockService } from './lock.service'; @@ -12,12 +12,18 @@ import { ActionType, OpType } from '../core/operation.types'; import { CLOCK_DRIFT_THRESHOLD_MS } from '../core/operation-log.const'; import { OpLog } from '../../core/log'; import { T } from '../../t.const'; +import { OperationEncryptionService } from './operation-encryption.service'; +import { SuperSyncStatusService } from './super-sync-status.service'; +import { CLIENT_ID_PROVIDER } from '../util/client-id.provider'; describe('OperationLogDownloadService', () => { let service: OperationLogDownloadService; let mockOpLogStore: jasmine.SpyObj; let mockLockService: jasmine.SpyObj; let mockSnackService: jasmine.SpyObj; + let mockEncryptionService: jasmine.SpyObj; + let mockSuperSyncStatusService: jasmine.SpyObj; + let mockClientIdProvider: { loadClientId: jasmine.Spy }; beforeEach(() => { mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', [ @@ -26,17 +32,27 @@ describe('OperationLogDownloadService', () => { ]); mockLockService = jasmine.createSpyObj('LockService', ['request']); mockSnackService = jasmine.createSpyObj('SnackService', ['open']); + mockEncryptionService = jasmine.createSpyObj('OperationEncryptionService', [ + 'decryptOperations', + ]); + mockSuperSyncStatusService = jasmine.createSpyObj('SuperSyncStatusService', [ + 'markRemoteChecked', + ]); + mockClientIdProvider = { + loadClientId: jasmine + .createSpy('loadClientId') + .and.returnValue(Promise.resolve('test-client-id')), + }; // Mock OpLog spyOn(OpLog, 'warn'); spyOn(OpLog, 'normal'); // Default mock implementations - mockLockService.request.and.callFake( - async (_name: string, fn: () => Promise) => { - await fn(); - }, - ); + // Note: Don't use async/await here as it breaks fakeAsync zone context + mockLockService.request.and.callFake((_name: string, fn: () => Promise) => { + return fn(); + }); mockOpLogStore.getAppliedOpIds.and.returnValue(Promise.resolve(new Set())); mockOpLogStore.hasSyncedOps.and.returnValue(Promise.resolve(false)); @@ -46,10 +62,15 @@ describe('OperationLogDownloadService', () => { { provide: OperationLogStoreService, useValue: mockOpLogStore }, { provide: LockService, useValue: mockLockService }, { provide: SnackService, useValue: mockSnackService }, + { provide: OperationEncryptionService, useValue: mockEncryptionService }, + { provide: SuperSyncStatusService, useValue: mockSuperSyncStatusService }, + { provide: CLIENT_ID_PROVIDER, useValue: mockClientIdProvider }, ], }); service = TestBed.inject(OperationLogDownloadService); + // Reset the clock drift warning flag for each test + (service as any).hasWarnedClockDrift = false; }); describe('downloadRemoteOps', () => { @@ -92,7 +113,11 @@ describe('OperationLogDownloadService', () => { it('should detect and warn about clock drift after retry', fakeAsync(() => { const driftMs = CLOCK_DRIFT_THRESHOLD_MS + 60000; // Threshold + 1 min - const serverCurrentTime = Date.now() - driftMs; // Server clock is behind + const initialTime = Date.now(); + const serverCurrentTime = initialTime - driftMs; // Server clock is behind + + // Spy on Date.now() to return consistent time during the test + spyOn(Date, 'now').and.returnValue(initialTime); mockApiProvider.downloadOps.and.returnValue( Promise.resolve({ @@ -108,7 +133,7 @@ describe('OperationLogDownloadService', () => { entityType: 'TASK', payload: {}, vectorClock: {}, - timestamp: Date.now(), + timestamp: initialTime, schemaVersion: 1, }, }, @@ -127,7 +152,7 @@ describe('OperationLogDownloadService', () => { expect(OpLog.warn).not.toHaveBeenCalled(); // After 1 second retry, warning should appear - tick(1000); + flush(); // Flush all pending timers expect(OpLog.warn).toHaveBeenCalledWith( 'OperationLogDownloadService: Clock drift detected', @@ -232,7 +257,11 @@ describe('OperationLogDownloadService', () => { it('should only warn about clock drift once per session', fakeAsync(() => { const driftMs = CLOCK_DRIFT_THRESHOLD_MS + 60000; // Threshold + 1 min - const serverCurrentTime = Date.now() - driftMs; + const initialTime = Date.now(); + const serverCurrentTime = initialTime - driftMs; + + // Spy on Date.now() to return consistent time during the test + spyOn(Date, 'now').and.returnValue(initialTime); mockApiProvider.downloadOps.and.returnValue( Promise.resolve({ @@ -248,7 +277,7 @@ describe('OperationLogDownloadService', () => { entityType: 'TASK', payload: {}, vectorClock: {}, - timestamp: Date.now(), + timestamp: initialTime, schemaVersion: 1, }, }, @@ -262,13 +291,13 @@ describe('OperationLogDownloadService', () => { // First call - should warn after retry service.downloadRemoteOps(mockApiProvider); tick(); // Resolve promises - tick(1000); // Wait for retry + flush(); // Flush all pending timers expect(OpLog.warn).toHaveBeenCalledTimes(1); // Second call - should NOT warn again service.downloadRemoteOps(mockApiProvider); tick(); // Resolve promises - tick(1000); // Wait for retry (if any) + flush(); // Flush all pending timers (if any) expect(OpLog.warn).toHaveBeenCalledTimes(1); })); @@ -321,7 +350,11 @@ describe('OperationLogDownloadService', () => { // When server sends its current time and it differs significantly from // client time, we should warn about clock drift const driftMs = CLOCK_DRIFT_THRESHOLD_MS + 60000; // 6 minutes drift - const serverCurrentTime = Date.now() - driftMs; // Server clock is 6 min behind + const initialTime = Date.now(); + const serverCurrentTime = initialTime - driftMs; // Server clock is 6 min behind + + // Spy on Date.now() to return consistent time during the test + spyOn(Date, 'now').and.returnValue(initialTime); mockApiProvider.downloadOps.and.returnValue( Promise.resolve({ @@ -350,7 +383,7 @@ describe('OperationLogDownloadService', () => { service.downloadRemoteOps(mockApiProvider); tick(); // Resolve promises - tick(1000); // Wait for retry + flush(); // Flush all pending timers // Should warn because serverTime differs from client time expect(OpLog.warn).toHaveBeenCalledWith( @@ -550,7 +583,11 @@ describe('OperationLogDownloadService', () => { await service.downloadRemoteOps(mockApiProvider, { forceFromSeq0: true }); // Should start from 0, not from lastServerSeq (100) - expect(mockApiProvider.downloadOps).toHaveBeenCalledWith(0, undefined, 500); + expect(mockApiProvider.downloadOps).toHaveBeenCalledWith( + 0, + 'test-client-id', + 500, + ); }); it('should collect all op clocks when forceFromSeq0 is true', async () => { diff --git a/src/app/op-log/sync/operation-log-download.service.ts b/src/app/op-log/sync/operation-log-download.service.ts index f96193ecf..3518ebb20 100644 --- a/src/app/op-log/sync/operation-log-download.service.ts +++ b/src/app/op-log/sync/operation-log-download.service.ts @@ -20,6 +20,7 @@ import { OperationEncryptionService } from './operation-encryption.service'; import { DecryptError } from '../core/errors/sync-errors'; import { SuperSyncStatusService } from './super-sync-status.service'; import { DownloadResult } from '../core/types/sync-results.types'; +import { CLIENT_ID_PROVIDER } from '../util/client-id.provider'; // Re-export for consumers that import from this service export type { DownloadResult } from '../core/types/sync-results.types'; @@ -45,6 +46,7 @@ export class OperationLogDownloadService { private snackService = inject(SnackService); private encryptionService = inject(OperationEncryptionService); private superSyncStatusService = inject(SuperSyncStatusService); + private clientIdProvider = inject(CLIENT_ID_PROVIDER); /** Track if we've already warned about clock drift this session */ private hasWarnedClockDrift = false; @@ -88,8 +90,10 @@ export class OperationLogDownloadService { await this.lockService.request(LOCK_NAMES.DOWNLOAD, async () => { const lastServerSeq = forceFromSeq0 ? 0 : await syncProvider.getLastServerSeq(); const appliedOpIds = await this.opLogStore.getAppliedOpIds(); + const clientId = await this.clientIdProvider.loadClientId(); OpLog.verbose( - `OperationLogDownloadService: [DEBUG] Starting download. lastServerSeq=${lastServerSeq}, appliedOpIds.size=${appliedOpIds.size}`, + `OperationLogDownloadService: [DEBUG] Starting download. ` + + `lastServerSeq=${lastServerSeq}, appliedOpIds.size=${appliedOpIds.size}, clientId=${clientId}`, ); if (forceFromSeq0) { @@ -115,7 +119,11 @@ export class OperationLogDownloadService { break; } - const response = await syncProvider.downloadOps(sinceSeq, undefined, 500); + const response = await syncProvider.downloadOps( + sinceSeq, + clientId ?? undefined, + 500, + ); finalLatestSeq = response.latestSeq; OpLog.verbose( `OperationLogDownloadService: [DEBUG] Download response: ops=${response.ops.length}, ` + diff --git a/src/environments/versions.ts b/src/environments/versions.ts index f421fad0d..5d6cb369a 100644 --- a/src/environments/versions.ts +++ b/src/environments/versions.ts @@ -1,6 +1,6 @@ // this file is automatically generated by git.version.ts script export const versions = { - version: '17.0.0-RC.7', + version: '17.0.0-RC.11', revision: 'NO_REV', branch: 'NO_BRANCH', };