mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
Merge remote-tracking branch 'origin/master' into task-settings
This commit is contained in:
commit
f51edc4e9b
16 changed files with 337 additions and 61 deletions
11
.github/workflows/build-ios.yml
vendored
11
.github/workflows/build-ios.yml
vendored
|
|
@ -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: >
|
||||
|
|
|
|||
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
108
.github/workflows/e2e-scheduled.yml
vendored
Normal file
108
.github/workflows/e2e-scheduled.yml
vendored
Normal file
|
|
@ -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
|
||||
14
.github/workflows/lint-and-test-pr.yml
vendored
14
.github/workflows/lint-and-test-pr.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
18
CHANGELOG.md
18
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -54,9 +54,14 @@ export class TaskPage extends BasePage {
|
|||
async markTaskAsDone(task: Locator): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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/*"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<OperationLogStoreService>;
|
||||
let mockLockService: jasmine.SpyObj<LockService>;
|
||||
let mockSnackService: jasmine.SpyObj<SnackService>;
|
||||
let mockEncryptionService: jasmine.SpyObj<OperationEncryptionService>;
|
||||
let mockSuperSyncStatusService: jasmine.SpyObj<SuperSyncStatusService>;
|
||||
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<void>) => {
|
||||
await fn();
|
||||
},
|
||||
);
|
||||
// Note: Don't use async/await here as it breaks fakeAsync zone context
|
||||
mockLockService.request.and.callFake((_name: string, fn: () => Promise<void>) => {
|
||||
return fn();
|
||||
});
|
||||
mockOpLogStore.getAppliedOpIds.and.returnValue(Promise.resolve(new Set<string>()));
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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}, ` +
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue