diff --git a/apps/desktop/src/main/database/client.ts b/apps/desktop/src/main/database/client.ts index c9dfc0c39..9f2eac722 100644 --- a/apps/desktop/src/main/database/client.ts +++ b/apps/desktop/src/main/database/client.ts @@ -97,6 +97,10 @@ export function getIndexDatabase(): IndexDb { return indexDb } +export function isIndexDatabaseInitialized(): boolean { + return indexDb !== null +} + /** * Get the raw better-sqlite3 connection for the index database. * Used for direct sqlite-vec queries on vec_notes virtual table. diff --git a/apps/desktop/src/main/database/queries/notes/index.ts b/apps/desktop/src/main/database/queries/notes/index.ts index d9904b060..94161eba7 100644 --- a/apps/desktop/src/main/database/queries/notes/index.ts +++ b/apps/desktop/src/main/database/queries/notes/index.ts @@ -12,6 +12,7 @@ export { bulkInsertNotes, clearNoteCache, getAllNoteIds, + getAllCrdtNoteIds, getNotesModifiedAfter, type ListNotesOptions } from './note-crud' diff --git a/apps/desktop/src/main/database/queries/notes/note-crud.ts b/apps/desktop/src/main/database/queries/notes/note-crud.ts index fb65d2626..adf4edbc8 100644 --- a/apps/desktop/src/main/database/queries/notes/note-crud.ts +++ b/apps/desktop/src/main/database/queries/notes/note-crud.ts @@ -198,6 +198,15 @@ export function getAllNoteIds(db: IndexDb): string[] { .map((r) => r.id) } +export function getAllCrdtNoteIds(db: IndexDb): string[] { + return db + .select({ id: noteCache.id }) + .from(noteCache) + .where(eq(noteCache.fileType, 'markdown')) + .all() + .map((r) => r.id) +} + export function getNotesModifiedAfter(db: IndexDb, date: string): NoteCache[] { return db .select() diff --git a/apps/desktop/src/main/index.phase2.test.ts b/apps/desktop/src/main/index.phase2.test.ts index bb4d35223..ea1adeba3 100644 --- a/apps/desktop/src/main/index.phase2.test.ts +++ b/apps/desktop/src/main/index.phase2.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const appOnMock = vi.fn() const whenReadyMock = vi.fn(() => new Promise(() => {})) const requestSingleInstanceLockMock = vi.fn(() => true) +const getPathMock = vi.fn((name: string) => `/mock/${name}`) +const setPathMock = vi.fn() const dotenvConfigMock = vi.fn(() => ({ error: undefined })) vi.mock('dotenv', () => ({ @@ -39,6 +41,8 @@ vi.mock('electron', () => ({ app: { isPackaged: false, getAppPath: vi.fn(() => '/mock/app'), + getPath: getPathMock, + setPath: setPathMock, requestSingleInstanceLock: requestSingleInstanceLockMock, on: appOnMock, whenReady: whenReadyMock, @@ -90,6 +94,7 @@ describe('main index phase2 exports', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + getPathMock.mockImplementation((name: string) => `/mock/${name}`) process.env = { ...ORIGINAL_ENV } }) @@ -125,6 +130,15 @@ describe('main index phase2 exports', () => { expect(module.envConfig.embeddingModel).toBe('embed-test') }) + it('skips the single-instance lock for multi-device test launches', async () => { + process.env.NODE_ENV = 'test' + process.env.MEMRY_DEVICE = 'A' + + await importMainModule() + + expect(requestSingleInstanceLockMock).not.toHaveBeenCalled() + }) + it('registerOAuthState schedules expiry cleanup at 10 minutes', async () => { vi.useFakeTimers() diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 26c5bb83a..3131c390d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -27,6 +27,7 @@ import { startSnoozeScheduler, stopSnoozeScheduler, checkDueItemsOnStartup } fro import { stopVoiceModel } from './inbox/voice-model' import { startReminderScheduler, stopReminderScheduler } from './lib/reminders' import { log, createLogger, disableConsoleTransport } from './lib/logger' +import { registerTestHooks } from './test-hooks' import { computeSpkiHashFromPem, isPinningDisabled, @@ -58,6 +59,8 @@ const quickCaptureLog = createLogger('QuickCapture') const shutdownLog = createLogger('Shutdown') const deepLinkLog = createLogger('DeepLink') +registerTestHooks() + for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.on(signal, () => { disableConsoleTransport() @@ -314,17 +317,24 @@ function handleDeepLink(url: string): void { } } -// Windows/Linux: deep links arrive via second-instance event -const gotTheLock = app.requestSingleInstanceLock() -if (!gotTheLock) { - app.quit() -} else { - app.on('second-instance', (_event, commandLine) => { - const deepLinkUrl = commandLine.find((arg) => arg.startsWith('memry://')) - if (deepLinkUrl) { - handleDeepLink(deepLinkUrl) - } - }) +const allowMultiInstanceForDeviceTests = + process.env.NODE_ENV === 'test' && typeof process.env.MEMRY_DEVICE === 'string' + +// Windows/Linux: deep links arrive via second-instance event. +// Device-scoped E2E runs need two Electron instances side by side, so skip the +// process-wide lock only for that test harness path. +if (!allowMultiInstanceForDeviceTests) { + const gotTheLock = app.requestSingleInstanceLock() + if (!gotTheLock) { + app.quit() + } else { + app.on('second-instance', (_event, commandLine) => { + const deepLinkUrl = commandLine.find((arg) => arg.startsWith('memry://')) + if (deepLinkUrl) { + handleDeepLink(deepLinkUrl) + } + }) + } } // This method will be called when Electron has finished diff --git a/apps/desktop/src/main/sync/crdt-provider.ts b/apps/desktop/src/main/sync/crdt-provider.ts index 0d2a09d8b..96946c799 100644 --- a/apps/desktop/src/main/sync/crdt-provider.ts +++ b/apps/desktop/src/main/sync/crdt-provider.ts @@ -46,6 +46,7 @@ interface ActiveDoc { doc: Y.Doc windowIds: Set accumulatedBytes: number + pendingSnapshotBytes: number lastEncodedSize: number lastSizeCheckAt: number closing?: boolean @@ -99,6 +100,9 @@ export class CrdtProvider { const existing = this.docs.get(noteId) if (existing && !existing.closing) { if (windowId) existing.windowIds.add(windowId) + if (!options?.skipSeed) { + await this.seedFromMarkdown(noteId, existing.doc) + } return existing.doc } @@ -107,6 +111,9 @@ export class CrdtProvider { const doc = await pending const entry = this.docs.get(noteId) if (entry && windowId) entry.windowIds.add(windowId) + if (!options?.skipSeed) { + await this.seedFromMarkdown(noteId, doc) + } return doc } @@ -146,6 +153,7 @@ export class CrdtProvider { doc, windowIds: new Set(windowId ? [windowId] : []), accumulatedBytes: 0, + pendingSnapshotBytes: 0, lastEncodedSize: 0, lastSizeCheckAt: 0 } @@ -171,11 +179,12 @@ export class CrdtProvider { this.flushNetworkBroadcast(noteId) - if (this.snapshotPushFn && entry.accumulatedBytes > 0) { + if (this.snapshotPushFn && entry.pendingSnapshotBytes > 0) { const state = Y.encodeStateAsUpdate(entry.doc) await this.snapshotPushFn(noteId, state).catch((err) => { log.warn('Failed to push snapshot on close', { noteId, error: err }) }) + entry.pendingSnapshotBytes = 0 } await this.flushDoc(noteId).catch((err) => { @@ -325,10 +334,12 @@ export class CrdtProvider { let pushed = 0 for (const [noteId, entry] of this.docs) { + if (entry.pendingSnapshotBytes <= 0) continue try { const state = Y.encodeStateAsUpdate(entry.doc) await this.snapshotPushFn(noteId, state) entry.accumulatedBytes = 0 + entry.pendingSnapshotBytes = 0 pushed++ log.info('Pushed server snapshot', { noteId, size: state.byteLength }) } catch (err) { @@ -362,7 +373,10 @@ export class CrdtProvider { } // Reset accumulatedBytes BEFORE push so close() won't fire a duplicate push - if (entry) entry.accumulatedBytes = 0 + if (entry) { + entry.accumulatedBytes = 0 + entry.pendingSnapshotBytes = 0 + } await this.snapshotPushFn(noteId, state) log.info('Pushed snapshot for note', { noteId, size: state.byteLength }) @@ -488,6 +502,9 @@ export class CrdtProvider { if (!entry) return entry.accumulatedBytes += update.byteLength + if (origin !== ORIGIN_NETWORK) { + entry.pendingSnapshotBytes += update.byteLength + } if (isIpcOrigin(origin)) { this.broadcastToWindows(noteId, update, 'ipc', origin.windowId) @@ -506,6 +523,9 @@ export class CrdtProvider { if (origin === ORIGIN_NETWORK) { recordNetworkUpdate(noteId) + } + + if (origin === ORIGIN_NETWORK || isIpcOrigin(origin)) { scheduleWriteback(noteId, entry.doc) } } @@ -619,8 +639,9 @@ export class CrdtProvider { this.compactionBuffers.set(noteId, []) try { - if (this.snapshotPushFn) { + if (this.snapshotPushFn && entry.pendingSnapshotBytes > 0) { await this.snapshotPushFn(noteId, result.compacted) + entry.pendingSnapshotBytes = 0 } if (this.persistence) { diff --git a/apps/desktop/src/main/sync/crdt-queue.ts b/apps/desktop/src/main/sync/crdt-queue.ts index 1572c31e5..f9384abf6 100644 --- a/apps/desktop/src/main/sync/crdt-queue.ts +++ b/apps/desktop/src/main/sync/crdt-queue.ts @@ -70,6 +70,10 @@ export class CrdtUpdateQueue { return count } + getOutstandingCount(): number { + return this.getPendingCount() + this.flushingNotes.size + } + private flushAll(): void { for (const noteId of this.buffers.keys()) { this.flushNote(noteId) diff --git a/apps/desktop/src/main/sync/crdt-writeback.ts b/apps/desktop/src/main/sync/crdt-writeback.ts index fa4c3f725..ccac000c4 100644 --- a/apps/desktop/src/main/sync/crdt-writeback.ts +++ b/apps/desktop/src/main/sync/crdt-writeback.ts @@ -36,6 +36,31 @@ const pendingTimers = new Map>() const ignoredWrites = new Map() const lastNetworkUpdateMs = new Map() +interface WritebackDebugState { + pending: boolean + scheduledCount: number + performedCount: number + lastMarkdown: string | null + lastError: string | null +} + +const debugState = new Map() + +function updateDebugState(noteId: string, patch: Partial): void { + const current = debugState.get(noteId) ?? { + pending: false, + scheduledCount: 0, + performedCount: 0, + lastMarkdown: null, + lastError: null + } + debugState.set(noteId, { ...current, ...patch }) +} + +export function getWritebackDebugState(noteId: string): WritebackDebugState | null { + return debugState.get(noteId) ?? null +} + function isJournalId(noteId: string): boolean { return noteId.startsWith('j') && /^j\d{4}-\d{2}-\d{2}$/.test(noteId) } @@ -87,10 +112,19 @@ function emitToRenderer(channel: string, data: unknown): void { export function scheduleWriteback(noteId: string, doc: Y.Doc): void { const existing = pendingTimers.get(noteId) if (existing) clearTimeout(existing) + updateDebugState(noteId, { + pending: true, + scheduledCount: (debugState.get(noteId)?.scheduledCount ?? 0) + 1, + lastError: null + }) const timer = setTimeout(() => { pendingTimers.delete(noteId) performWriteback(noteId, doc).catch((err) => { + updateDebugState(noteId, { + pending: false, + lastError: err instanceof Error ? err.message : String(err) + }) log.error('Write-back failed', { noteId, error: err }) emitToRenderer('sync:write-back-failed', { noteId }) }) @@ -108,6 +142,12 @@ export function cancelPendingWritebacks(): void { async function performWriteback(noteId: string, doc: Y.Doc): Promise { const markdown = await yDocToMarkdown(doc) + updateDebugState(noteId, { + pending: false, + performedCount: (debugState.get(noteId)?.performedCount ?? 0) + 1, + lastMarkdown: markdown, + lastError: null + }) if (markdown === null) { log.warn('Conversion returned null, keeping stale file', { noteId }) return diff --git a/apps/desktop/src/main/sync/engine.test.ts b/apps/desktop/src/main/sync/engine.test.ts index 4079ca7a5..ec240ac2e 100644 --- a/apps/desktop/src/main/sync/engine.test.ts +++ b/apps/desktop/src/main/sync/engine.test.ts @@ -1651,7 +1651,7 @@ describe('SyncEngine', () => { }) describe('#given WS reconnect #when handleWsConnected fires', () => { - it('#then schedules pull', async () => { + it('#then schedules pull and catches up open CRDT notes', async () => { const getServerMock = vi.fn().mockResolvedValue({ items: [], deleted: [], @@ -1660,24 +1660,36 @@ describe('SyncEngine', () => { }) vi.spyOn(await import('./http-client'), 'getFromServer').mockImplementation(getServerMock) - const deps = createMockDeps(testDb) + const deps = createMockDeps(testDb, { + crdtProvider: { + getOpenNoteIds: vi.fn().mockReturnValue(['note-1', 'note-2']) + } as SyncEngineDeps['crdtProvider'] + }) const engine = new SyncEngine(deps) await engine.start() getServerMock.mockClear() + const pullCrdtForNote = vi.fn().mockResolvedValue(undefined) + ;(engine as unknown as { crdtSync: { pullCrdtForNote: typeof pullCrdtForNote } }).crdtSync = { + pullCrdtForNote + } + const pullDone = new Promise((resolve) => { - const origPull = engine.pull.bind(engine) - engine.pull = async () => { - await origPull() - resolve() - } + pullCrdtForNote.mockImplementation(async (noteId: string) => { + if (noteId === 'note-2') { + resolve() + } + }) }) deps.ws.emit('connected') await pullDone expect(getServerMock).toHaveBeenCalled() + expect(pullCrdtForNote).toHaveBeenCalledTimes(2) + expect(pullCrdtForNote).toHaveBeenCalledWith('note-1') + expect(pullCrdtForNote).toHaveBeenCalledWith('note-2') await engine.stop() vi.restoreAllMocks() diff --git a/apps/desktop/src/main/sync/engine.ts b/apps/desktop/src/main/sync/engine.ts index 694754f00..681d65c44 100644 --- a/apps/desktop/src/main/sync/engine.ts +++ b/apps/desktop/src/main/sync/engine.ts @@ -548,7 +548,14 @@ export class SyncEngine extends EventEmitter { private handleWsConnected = (): void => { if (!this.stateManager.isPaused()) { - this.scheduleSync(() => this.pull()) + this.scheduleSync(async () => { + await this.pull() + + const openNoteIds = this.ctx.deps.crdtProvider?.getOpenNoteIds() ?? [] + for (const noteId of openNoteIds) { + await this.crdtSync.pullCrdtForNote(noteId) + } + }) } } } diff --git a/apps/desktop/src/main/sync/engine/crdt-sync-coordinator.test.ts b/apps/desktop/src/main/sync/engine/crdt-sync-coordinator.test.ts new file mode 100644 index 000000000..7b1a5d126 --- /dev/null +++ b/apps/desktop/src/main/sync/engine/crdt-sync-coordinator.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SyncContext } from './sync-context' +import { CrdtSyncCoordinator } from './crdt-sync-coordinator' + +const fetchCrdtSnapshotMock = vi.fn() +const postToServerMock = vi.fn() +const decryptCrdtUpdateMock = vi.fn() + +vi.mock('../../lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + }) +})) + +vi.mock('../http-client', () => ({ + fetchCrdtSnapshot: (...args: unknown[]) => fetchCrdtSnapshotMock(...args), + postToServer: (...args: unknown[]) => postToServerMock(...args) +})) + +vi.mock('../retry', () => ({ + withRetry: async (fn: () => Promise) => ({ value: await fn() }) +})) + +vi.mock('../crdt-encrypt', () => ({ + decryptCrdtUpdate: (...args: unknown[]) => decryptCrdtUpdateMock(...args) +})) + +describe('CrdtSyncCoordinator', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses the latest server snapshot as the batch baseline even when the local doc is non-empty', async () => { + const applyRemoteUpdate = vi.fn() + const open = vi.fn().mockResolvedValue({}) + const getStateVector = vi.fn().mockReturnValue(new Uint8Array([1, 2, 3, 4])) + + fetchCrdtSnapshotMock.mockResolvedValue({ + snapshot: new Uint8Array([9, 9, 9]), + sequenceNum: 36, + signerDeviceId: 'device-a' + }) + postToServerMock.mockResolvedValue({ + notes: { + 'note-1': { + updates: [], + hasMore: false + } + } + }) + decryptCrdtUpdateMock.mockReturnValue(new Uint8Array([7, 7, 7])) + + const ctx = { + deps: { + crdtProvider: { + open, + applyRemoteUpdate, + getStateVector, + seedFromMarkdownPublic: vi.fn() + } + }, + abortController: new AbortController() + } as unknown as SyncContext + + const resolveDeviceKey = vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])) + const coordinator = new CrdtSyncCoordinator(ctx, resolveDeviceKey) + + await coordinator.applyCrdtBatch(['note-1'], 'token-1', new Uint8Array([4, 5, 6])) + + expect(fetchCrdtSnapshotMock).toHaveBeenCalledWith('note-1', 'token-1') + expect(resolveDeviceKey).toHaveBeenCalledWith('device-a') + expect(applyRemoteUpdate).toHaveBeenCalledWith('note-1', new Uint8Array([7, 7, 7])) + expect(postToServerMock).toHaveBeenCalledWith( + '/sync/crdt/updates/batch', + { + notes: [{ noteId: 'note-1', since: 36 }], + limit: 100 + }, + 'token-1' + ) + }) + + it('reuses the highest applied CRDT sequence as the next batch baseline', async () => { + const applyRemoteUpdate = vi.fn() + const open = vi.fn().mockResolvedValue({}) + const getStateVector = vi.fn().mockReturnValue(new Uint8Array([1, 2, 3, 4])) + + fetchCrdtSnapshotMock.mockResolvedValue({ + snapshot: new Uint8Array([9, 9, 9]), + sequenceNum: 2, + signerDeviceId: 'device-a' + }) + postToServerMock + .mockResolvedValueOnce({ + notes: { + 'note-1': { + updates: [ + { + sequenceNum: 5, + data: 'eA==', + createdAt: 1, + signerDeviceId: 'device-a' + }, + { + sequenceNum: 6, + data: 'eQ==', + createdAt: 2, + signerDeviceId: 'device-a' + } + ], + hasMore: false + } + } + }) + .mockResolvedValueOnce({ + notes: { + 'note-1': { + updates: [], + hasMore: false + } + } + }) + decryptCrdtUpdateMock.mockReturnValue(new Uint8Array([7, 7, 7])) + + const ctx = { + deps: { + crdtProvider: { + open, + applyRemoteUpdate, + getStateVector, + seedFromMarkdownPublic: vi.fn() + } + }, + abortController: new AbortController() + } as unknown as SyncContext + + const resolveDeviceKey = vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])) + const coordinator = new CrdtSyncCoordinator(ctx, resolveDeviceKey) + + await coordinator.applyCrdtBatch(['note-1'], 'token-1', new Uint8Array([4, 5, 6])) + await coordinator.applyCrdtBatch(['note-1'], 'token-1', new Uint8Array([4, 5, 6])) + + expect(postToServerMock).toHaveBeenNthCalledWith( + 1, + '/sync/crdt/updates/batch', + { + notes: [{ noteId: 'note-1', since: 2 }], + limit: 100 + }, + 'token-1' + ) + expect(postToServerMock).toHaveBeenNthCalledWith( + 2, + '/sync/crdt/updates/batch', + { + notes: [{ noteId: 'note-1', since: 6 }], + limit: 100 + }, + 'token-1' + ) + }) +}) diff --git a/apps/desktop/src/main/sync/engine/crdt-sync-coordinator.ts b/apps/desktop/src/main/sync/engine/crdt-sync-coordinator.ts index d5e7c54d4..a7ac6b143 100644 --- a/apps/desktop/src/main/sync/engine/crdt-sync-coordinator.ts +++ b/apps/desktop/src/main/sync/engine/crdt-sync-coordinator.ts @@ -17,6 +17,7 @@ export type ResolveDeviceKey = (deviceId: string) => Promise export class CrdtSyncCoordinator { private ctx: SyncContext private pendingPulls = new Set() + private lastAppliedSequence = new Map() private resolveDeviceKey: ResolveDeviceKey constructor(ctx: SyncContext, resolveDeviceKey: ResolveDeviceKey) { @@ -38,6 +39,44 @@ export class CrdtSyncCoordinator { return this.pendingPulls.size } + private rememberAppliedSequence(noteId: string, sequenceNum: number): number { + const known = this.lastAppliedSequence.get(noteId) ?? 0 + const next = Math.max(known, sequenceNum) + this.lastAppliedSequence.set(noteId, next) + return next + } + + private async applySnapshotBaseline( + noteId: string, + token: string, + vaultKey: Uint8Array, + mode: 'single' | 'batch' + ): Promise { + const snapshotResult = await fetchCrdtSnapshot(noteId, token) + if (!snapshotResult || !this.ctx.deps.crdtProvider) { + return 0 + } + + const signerPubKey = await this.resolveDeviceKey(snapshotResult.signerDeviceId) + if (!signerPubKey) { + log.warn(`Skipping CRDT snapshot from unresolvable signer in ${mode} mode`, { + noteId, + signerDeviceId: snapshotResult.signerDeviceId + }) + return 0 + } + + const decrypted = decryptCrdtUpdate(snapshotResult.snapshot, vaultKey, noteId, signerPubKey) + this.ctx.deps.crdtProvider.applyRemoteUpdate(noteId, decrypted) + const baselineSequence = this.rememberAppliedSequence(noteId, snapshotResult.sequenceNum) + log.debug('Applied CRDT snapshot baseline', { + noteId, + mode, + sequenceNum: snapshotResult.sequenceNum + }) + return baselineSequence + } + async applyCrdtIncrementals( noteId: string, token: string, @@ -53,33 +92,7 @@ export class CrdtSyncCoordinator { const doc = await this.ctx.deps.crdtProvider.open(noteId, undefined, { skipSeed: true }) if (!doc) return - let since = 0 - - const stateVector = this.ctx.deps.crdtProvider.getStateVector(noteId) - const needsBootstrap = !stateVector || stateVector.length <= 2 - - if (needsBootstrap) { - const snapshotResult = await fetchCrdtSnapshot(noteId, token) - if (snapshotResult) { - const signerPubKey = await this.resolveDeviceKey(snapshotResult.signerDeviceId) - if (signerPubKey) { - const decrypted = decryptCrdtUpdate( - snapshotResult.snapshot, - vaultKey, - noteId, - signerPubKey - ) - this.ctx.deps.crdtProvider.applyRemoteUpdate(noteId, decrypted) - since = snapshotResult.sequenceNum - log.debug('Applied CRDT snapshot', { noteId, sequenceNum: since }) - } else { - log.warn('Skipping CRDT snapshot from unresolvable signer', { - noteId, - signerDeviceId: snapshotResult.signerDeviceId - }) - } - } - } + let since = await this.applySnapshotBaseline(noteId, token, vaultKey, 'single') let hasMore = true @@ -134,7 +147,7 @@ export class CrdtSyncCoordinator { const decrypted = decryptCrdtUpdate(packed, vaultKey, noteId, signerPubKey) this.ctx.deps.crdtProvider!.applyRemoteUpdate(noteId, decrypted) - since = entry.sequenceNum + since = this.rememberAppliedSequence(noteId, entry.sequenceNum) } hasMore = result.hasMore @@ -176,26 +189,7 @@ export class CrdtSyncCoordinator { continue } - let since = 0 - const stateVector = this.ctx.deps.crdtProvider.getStateVector(noteId) - const needsBootstrap = !stateVector || stateVector.length <= 2 - - if (needsBootstrap) { - const snap = await fetchCrdtSnapshot(noteId, token) - if (snap) { - const pubKey = await this.resolveDeviceKey(snap.signerDeviceId) - if (pubKey) { - const decrypted = decryptCrdtUpdate(snap.snapshot, vaultKey, noteId, pubKey) - this.ctx.deps.crdtProvider.applyRemoteUpdate(noteId, decrypted) - since = snap.sequenceNum - } else { - log.warn('Skipping CRDT snapshot from unresolvable signer in batch', { - noteId, - signerDeviceId: snap.signerDeviceId - }) - } - } - } + const since = await this.applySnapshotBaseline(noteId, token, vaultKey, 'batch') sinceMap.set(noteId, since) } @@ -242,7 +236,7 @@ export class CrdtSyncCoordinator { } const decrypted = decryptCrdtUpdate(packed, vaultKey, noteId, pubKey) this.ctx.deps.crdtProvider!.applyRemoteUpdate(noteId, decrypted) - activeSince.set(noteId, entry.sequenceNum) + activeSince.set(noteId, this.rememberAppliedSequence(noteId, entry.sequenceNum)) } if (!noteData.hasMore) activeSince.delete(noteId) diff --git a/apps/desktop/src/main/sync/engine/full-sync-runner.ts b/apps/desktop/src/main/sync/engine/full-sync-runner.ts index 2a7e8335c..d46fb0802 100644 --- a/apps/desktop/src/main/sync/engine/full-sync-runner.ts +++ b/apps/desktop/src/main/sync/engine/full-sync-runner.ts @@ -9,6 +9,8 @@ import { SYNC_STATE_KEYS } from './sync-context' import type { SyncStateManager } from './sync-state-manager' import type { PushCoordinator } from './push-coordinator' import type { CrdtSyncCoordinator } from './crdt-sync-coordinator' +import { getAllCrdtNoteIds } from '../../database/queries/notes' +import { getIndexDatabase, isIndexDatabaseInitialized } from '../../database/client' const log = createLogger('SyncEngine') @@ -121,6 +123,11 @@ export class FullSyncRunner { } satisfies InitialSyncProgressEvent) } finally { this.ctx.fullSyncActive = false + if (this.ctx.deps.crdtProvider && isIndexDatabaseInitialized()) { + for (const noteId of getAllCrdtNoteIds(getIndexDatabase())) { + this.crdtSync.addPendingPull(noteId) + } + } if (this.crdtSync.pendingPullCount > 0) { log.debug('fullSync: flushing pending CRDT pulls', { count: this.crdtSync.pendingPullCount diff --git a/apps/desktop/src/main/sync/network.test.ts b/apps/desktop/src/main/sync/network.test.ts index 7de156d21..17a5a4512 100644 --- a/apps/desktop/src/main/sync/network.test.ts +++ b/apps/desktop/src/main/sync/network.test.ts @@ -218,4 +218,42 @@ describe('NetworkMonitor', () => { expect(handler).not.toHaveBeenCalled() }) }) + + describe('#given test-controlled network override', () => { + beforeEach(() => { + deps = createMockDeps(true) + monitor = new NetworkMonitor(200, deps) + }) + + it('#when set offline for tests #then emits immediately and persists across polls', () => { + const handler = vi.fn() + monitor.on('status-changed', handler) + monitor.start() + + monitor.setOnlineForTests(false) + + expect(handler).toHaveBeenCalledOnce() + expect(handler).toHaveBeenCalledWith({ online: false }) + expect(monitor.online).toBe(false) + + deps.setOnline(true) + vi.advanceTimersByTime(20000) + + expect(monitor.online).toBe(false) + expect(handler).toHaveBeenCalledOnce() + }) + + it('#when restored online for tests #then emits immediately', () => { + const handler = vi.fn() + monitor.on('status-changed', handler) + monitor.start() + + monitor.setOnlineForTests(false) + monitor.setOnlineForTests(true) + + expect(handler).toHaveBeenNthCalledWith(1, { online: false }) + expect(handler).toHaveBeenNthCalledWith(2, { online: true }) + expect(monitor.online).toBe(true) + }) + }) }) diff --git a/apps/desktop/src/main/sync/network.ts b/apps/desktop/src/main/sync/network.ts index dcd743e9d..96ef531ca 100644 --- a/apps/desktop/src/main/sync/network.ts +++ b/apps/desktop/src/main/sync/network.ts @@ -26,6 +26,7 @@ const MAX_NETWORK_MONITOR_LISTENERS = 50 export class NetworkMonitor extends EventEmitter { private _online: boolean + private onlineOverrideForTests: boolean | null = null private pollTimer: ReturnType | null = null private debounceTimer: ReturnType | null = null private readonly deps: NetworkMonitorDeps @@ -42,7 +43,16 @@ export class NetworkMonitor extends EventEmitter { } get online(): boolean { - return this._online + return this.onlineOverrideForTests ?? this._online + } + + setOnlineForTests(online: boolean | null): void { + this.onlineOverrideForTests = online + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = null + } + this.applyStatus(this.resolveOnlineStatus()) } start(): void { @@ -75,7 +85,7 @@ export class NetworkMonitor extends EventEmitter { } private poll(): void { - const current = this.deps.getIsOnline() + const current = this.resolveOnlineStatus() if (current === this._online) { if (this.debounceTimer) { clearTimeout(this.debounceTimer) @@ -101,4 +111,8 @@ export class NetworkMonitor extends EventEmitter { this._online = status this.emit('status-changed', { online: status }) } + + private resolveOnlineStatus(): boolean { + return this.onlineOverrideForTests ?? this.deps.getIsOnline() + } } diff --git a/apps/desktop/src/main/sync/runtime.ts b/apps/desktop/src/main/sync/runtime.ts index 8888c0391..aba85f1ab 100644 --- a/apps/desktop/src/main/sync/runtime.ts +++ b/apps/desktop/src/main/sync/runtime.ts @@ -212,7 +212,6 @@ export async function startSyncRuntime(): Promise { ]) const crdtQueue = new CrdtUpdateQueue() - setOnTokenRefreshed(() => crdtQueue.resume()) crdtQueue.start(async (noteId, updates) => { const token = await getValidAccessToken() const vaultKey = await getOrDeriveVaultKey().catch(() => null) @@ -240,6 +239,15 @@ export async function startSyncRuntime(): Promise { () => postToServer('/sync/crdt/updates', { noteId, updates: b64Updates }, token), { maxRetries: 3, baseDelayMs: 2000 } ) + + try { + await crdtProvider.pushSnapshotForNote(noteId) + } catch (snapshotErr) { + log.warn('Failed to push CRDT snapshot after update batch', { + noteId, + error: snapshotErr + }) + } } catch (err) { if (err instanceof SyncServerError && err.statusCode === 401) { crdtQueue.pause() @@ -309,6 +317,21 @@ export async function startSyncRuntime(): Promise { const network = new NetworkMonitor() network.start() + if (!network.online) { + crdtQueue.pause() + } + network.on('status-changed', ({ online }: { online: boolean }) => { + if (online) { + crdtQueue.resume() + } else { + crdtQueue.pause() + } + }) + setOnTokenRefreshed(() => { + if (network.online) { + crdtQueue.resume() + } + }) const ws = new WebSocketManager({ getAccessToken: () => getValidAccessToken(), diff --git a/apps/desktop/src/main/test-hooks.ts b/apps/desktop/src/main/test-hooks.ts new file mode 100644 index 000000000..7eda6d7b0 --- /dev/null +++ b/apps/desktop/src/main/test-hooks.ts @@ -0,0 +1,86 @@ +import { store } from './store' +import { persistKeysAndRegisterDevice } from './ipc/sync-handlers' +import { yDocToMarkdown } from './sync/blocknote-converter' +import { getCrdtProvider } from './sync/crdt-provider' +import { getWritebackDebugState } from './sync/crdt-writeback' +import { getCrdtQueue, getNetworkMonitor } from './sync/runtime' + +export interface SyncTestBootstrapInput { + email: string + setupToken: string + masterKeyBase64: string + signingSecretKeyBase64: string + kdfSalt: string + keyVerifier: string + skipSetup?: boolean +} + +interface MemryTestHooks { + bootstrapSyncDevice(input: SyncTestBootstrapInput): Promise<{ deviceId: string }> + setNetworkOnlineForTests(online: boolean): Promise + getCrdtPendingCount(): Promise + getCrdtDocMarkdown(noteId: string): Promise + getWritebackDebugState(noteId: string): Promise<{ + pending: boolean + scheduledCount: number + performedCount: number + lastMarkdown: string | null + lastError: string | null + } | null> +} + +declare global { + var __memryTestHooks: MemryTestHooks | undefined +} + +export function registerTestHooks(): void { + if (process.env.NODE_ENV !== 'test') { + return + } + + globalThis.__memryTestHooks = { + async bootstrapSyncDevice(input: SyncTestBootstrapInput): Promise<{ deviceId: string }> { + const deviceId = await persistKeysAndRegisterDevice( + Buffer.from(input.masterKeyBase64, 'base64'), + Buffer.from(input.signingSecretKeyBase64, 'base64'), + input.setupToken, + input.kdfSalt, + input.keyVerifier, + input.skipSetup ?? false + ) + + store.set('sync', { + ...store.get('sync'), + email: input.email, + recoveryPhraseConfirmed: true + }) + + return { deviceId } + }, + + async setNetworkOnlineForTests(online: boolean): Promise { + const network = getNetworkMonitor() + if (!network) { + throw new Error('Sync runtime is not initialized') + } + + network.setOnlineForTests(online) + }, + + async getCrdtPendingCount(): Promise { + return getCrdtQueue()?.getOutstandingCount() ?? 0 + }, + + async getCrdtDocMarkdown(noteId: string): Promise { + const doc = getCrdtProvider().getDoc(noteId) + if (!doc) { + return null + } + return yDocToMarkdown(doc) + }, + + async getWritebackDebugState(noteId: string) { + return getWritebackDebugState(noteId) + } + } +} diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 425317b40..3b224d27d 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -148,6 +148,31 @@ const AppContent = (): React.JSX.Element => { return () => window.removeEventListener('memry:open-search', openSearch) }, []) + useEffect(() => { + const openTestNote = ( + event: CustomEvent<{ id?: string; title?: string; emoji?: string | null }> + ) => { + const { id, title, emoji } = event.detail ?? {} + if (!id || !title) return + + openTab({ + type: 'note', + title, + icon: 'file-text', + emoji: emoji ?? undefined, + path: `/notes/${id}`, + entityId: id, + isPinned: false, + isModified: false, + isPreview: false, + isDeleted: false + }) + } + + window.addEventListener('memry:test-open-note', openTestNote as EventListener) + return () => window.removeEventListener('memry:test-open-note', openTestNote as EventListener) + }, [openTab]) + useEffect(() => { return window.api.onSettingsOpenRequested((section) => { openSettings(section) diff --git a/apps/desktop/src/renderer/src/components/note/content-area/ContentArea.tsx b/apps/desktop/src/renderer/src/components/note/content-area/ContentArea.tsx index 67a1f2f1a..7bb250105 100644 --- a/apps/desktop/src/renderer/src/components/note/content-area/ContentArea.tsx +++ b/apps/desktop/src/renderer/src/components/note/content-area/ContentArea.tsx @@ -860,7 +860,8 @@ const ContentAreaEditor = memo(function ContentAreaEditor({ export const ContentArea = memo(function ContentArea(props: ContentAreaProps) { const { state } = useSync() - const syncActive = state.status === 'idle' || state.status === 'syncing' + const syncActive = + state.status === 'idle' || state.status === 'syncing' || state.status === 'offline' const { fragment, isReady, isRemoteUpdateRef } = useYjsCollaboration({ noteId: props.noteId, enabled: syncActive diff --git a/apps/desktop/src/renderer/src/components/note/content-area/hooks/use-editor-sync.ts b/apps/desktop/src/renderer/src/components/note/content-area/hooks/use-editor-sync.ts index b733a5745..2138bcfb2 100644 --- a/apps/desktop/src/renderer/src/components/note/content-area/hooks/use-editor-sync.ts +++ b/apps/desktop/src/renderer/src/components/note/content-area/hooks/use-editor-sync.ts @@ -223,7 +223,10 @@ export function useEditorSync({ if (isRemoteUpdateRef?.current) return - if (onMarkdownChange && isContentReadyRef.current) { + // When Yjs collaboration is active, the main-process CRDT doc owns body + // persistence and writes merged markdown back to disk. Avoid racing that + // writeback with a separate renderer-triggered markdown save. + if (!yjsFragment && onMarkdownChange && isContentReadyRef.current) { if (markdownDebounceRef.current) { clearTimeout(markdownDebounceRef.current) } diff --git a/apps/desktop/tests/e2e/body-crdt-coverage-variants.e2e.ts b/apps/desktop/tests/e2e/body-crdt-coverage-variants.e2e.ts new file mode 100644 index 000000000..bbd2248a5 --- /dev/null +++ b/apps/desktop/tests/e2e/body-crdt-coverage-variants.e2e.ts @@ -0,0 +1,502 @@ +import { test, expect } from './fixtures/sync-auth-fixtures' +import type { ElectronApplication, Page } from '@playwright/test' +import { + appendToNoteBody, + createNoteWithBody, + expectNoteBody, + getCrdtDocBodyByTitle, + getNoteFileBodyByTitle, + getNoteHandleByTitle, + getWritebackDebugByTitle, + openNoteByTitle, + readNoteBodyText +} from './utils/note-sync-helpers' +import { SELECTORS } from './utils/electron-helpers' +import { + goOffline, + goOnline, + syncBothAndWait, + waitForCrdtQueueIdle, + waitForSyncOffline, + waitForSyncOnline +} from './utils/network-control' + +type CursorPosition = 'start' | 'end' +type ReconnectOrder = 'a-first' | 'b-first' | 'together' +type ReceiverState = 'open' | 'closed' + +interface BlockEdit { + blockIndex: number + cursorPosition: CursorPosition + text: string +} + +async function seedSharedNote({ + pageA, + pageB, + creatorPage, + title, + body +}: { + pageA: Page + pageB: Page + creatorPage: Page + title: string + body: string +}): Promise { + await Promise.all([waitForSyncOnline(pageA), waitForSyncOnline(pageB)]) + + await createNoteWithBody(creatorPage, title, body) + await syncBothAndWait(pageA, pageB) + + await openNoteByTitle(pageA, title) + await expectNoteBody(pageA, body) + + await openNoteByTitle(pageB, title) + await expectNoteBody(pageB, body) +} + +async function applyBlockEdit(page: Page, title: string, edit: BlockEdit): Promise { + await openNoteByTitle(page, title) + const editorRoot = page.locator(SELECTORS.noteEditor).first() + await editorRoot.waitFor({ state: 'visible', timeout: 10000 }) + await editorRoot.click() + + await page.evaluate(({ blockIndex, cursorPosition }) => { + const editor = (window as unknown as { __memryEditor?: any }).__memryEditor + if (!editor) throw new Error('window.__memryEditor not exposed') + + const doc = editor.document as any[] + const target = doc[blockIndex] + if (!target?.id) { + throw new Error(`Block ${blockIndex} is not available`) + } + + editor.focus() + editor.setTextCursorPosition(target.id, cursorPosition) + }, edit) + + await page.keyboard.type(edit.text) +} + +async function closeTabByTitle(page: Page, title: string): Promise { + const tab = page.locator(`${SELECTORS.tab}:has-text("${title}")`).first() + await expect(tab).toBeVisible() + + await tab.hover() + const closeButton = tab.locator('button[aria-label^="Close"]').first() + if (await closeButton.isVisible().catch(() => false)) { + await closeButton.click() + } else { + await tab.click({ button: 'middle' }) + } + + await expect(tab).toBeHidden() +} + +async function reconnectDevices({ + electronAppA, + electronAppB, + pageA, + pageB, + order +}: { + electronAppA: ElectronApplication + electronAppB: ElectronApplication + pageA: Page + pageB: Page + order: ReconnectOrder +}): Promise { + if (order === 'a-first') { + await goOnline(electronAppA) + await waitForSyncOnline(pageA) + await goOnline(electronAppB) + await waitForSyncOnline(pageB) + } else if (order === 'b-first') { + await goOnline(electronAppB) + await waitForSyncOnline(pageB) + await goOnline(electronAppA) + await waitForSyncOnline(pageA) + } else { + await Promise.all([goOnline(electronAppA), goOnline(electronAppB)]) + await Promise.all([waitForSyncOnline(pageA), waitForSyncOnline(pageB)]) + } + + await syncBothAndWait(pageA, pageB) +} + +async function assertMergedNoteOnBothDevices( + electronAppA: ElectronApplication, + electronAppB: ElectronApplication, + pageA: Page, + pageB: Page, + title: string, + expectedBodies: string[] +): Promise { + let finalBody = '' + await expect + .poll(async () => { + const body = await readNoteBodyText(pageA) + if (!expectedBodies.includes(body)) { + return false + } + finalBody = body + return true + }) + .toBe(true) + + await expect.poll(() => readNoteBodyText(pageA)).toBe(finalBody) + await expect.poll(() => readNoteBodyText(pageB)).toBe(finalBody) + await expect.poll(() => getCrdtDocBodyByTitle(pageA, electronAppA, title)).toBe(finalBody) + await expect.poll(() => getCrdtDocBodyByTitle(pageB, electronAppB, title)).toBe(finalBody) + await expect + .poll(async () => (await getWritebackDebugByTitle(pageA, electronAppA, title))?.lastMarkdown ?? null) + .toBe(finalBody) + await expect + .poll(async () => (await getWritebackDebugByTitle(pageB, electronAppB, title))?.lastMarkdown ?? null) + .toBe(finalBody) + await expect.poll(() => getNoteFileBodyByTitle(pageA, title)).toBe(finalBody) + await expect.poll(() => getNoteFileBodyByTitle(pageB, title)).toBe(finalBody) +} + +async function runOfflineOfflineMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage, + title, + initialBody, + editA, + editB, + expectedBodies, + reconnectOrder, + closedBeforeReconnect +}: { + electronAppA: ElectronApplication + electronAppB: ElectronApplication + pageA: Page + pageB: Page + creatorPage: Page + title: string + initialBody: string + editA: BlockEdit + editB: BlockEdit + expectedBodies: string[] + reconnectOrder: ReconnectOrder + closedBeforeReconnect?: 'a' | 'b' +}): Promise { + await seedSharedNote({ pageA, pageB, creatorPage, title, body: initialBody }) + + await Promise.all([goOffline(electronAppA), goOffline(electronAppB)]) + await Promise.all([waitForSyncOffline(pageA), waitForSyncOffline(pageB)]) + + await Promise.all([applyBlockEdit(pageA, title, editA), applyBlockEdit(pageB, title, editB)]) + + if (closedBeforeReconnect === 'a') { + await closeTabByTitle(pageA, title) + } + if (closedBeforeReconnect === 'b') { + await closeTabByTitle(pageB, title) + } + + await reconnectDevices({ electronAppA, electronAppB, pageA, pageB, order: reconnectOrder }) + + if (closedBeforeReconnect === 'a') { + await openNoteByTitle(pageA, title) + } + if (closedBeforeReconnect === 'b') { + await openNoteByTitle(pageB, title) + } + + await assertMergedNoteOnBothDevices(electronAppA, electronAppB, pageA, pageB, title, expectedBodies) +} + +async function appendBodyAndPersist({ + page, + title, + editText, + expectedBody +}: { + page: Page + title: string + editText: string + expectedBody: string +}): Promise { + await openNoteByTitle(page, title) + await appendToNoteBody(page, editText) + await expectNoteBody(page, expectedBody) + await expect.poll(() => getNoteFileBodyByTitle(page, title)).toBe(expectedBody) +} + +async function runReceiverStateSingleWriterCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage, + editorPage, + receiverPage, + editorApp, + receiverApp, + title, + initialBody, + editText, + receiverState +}: { + electronAppA: ElectronApplication + electronAppB: ElectronApplication + pageA: Page + pageB: Page + creatorPage: Page + editorPage: Page + receiverPage: Page + editorApp: ElectronApplication + receiverApp: ElectronApplication + title: string + initialBody: string + editText: string + receiverState: ReceiverState +}): Promise { + const expectedBody = `${initialBody}\n\n${editText}` + + await seedSharedNote({ pageA, pageB, creatorPage, title, body: initialBody }) + + if (receiverState === 'closed') { + await closeTabByTitle(receiverPage, title) + } + + await goOffline(receiverApp) + await waitForSyncOffline(receiverPage) + + await appendBodyAndPersist({ page: editorPage, title, editText, expectedBody }) + await waitForCrdtQueueIdle(editorApp) + + await goOnline(receiverApp) + await waitForSyncOnline(receiverPage) + await syncBothAndWait(pageA, pageB) + + await expect.poll(() => getCrdtDocBodyByTitle(receiverPage, receiverApp, title)).toBe(expectedBody) + await expect.poll(() => getNoteFileBodyByTitle(receiverPage, title)).toBe(expectedBody) + + if (receiverState === 'closed') { + await openNoteByTitle(receiverPage, title) + } + + await expectNoteBody(receiverPage, expectedBody) + await assertMergedNoteOnBothDevices(electronAppA, electronAppB, pageA, pageB, title, [expectedBody]) +} + +async function expectSharedNoteTitles(page: Page, titles: string[]): Promise { + for (const title of titles) { + await getNoteHandleByTitle(page, title) + } + + const currentTitles = await page.evaluate(async (expectedTitles) => { + const result = await window.api.notes.list({}) + return result.notes + .map((note) => note.title) + .filter((title) => expectedTitles.includes(title)) + .sort((a, b) => a.localeCompare(b)) + }, titles) + + expect(currentTitles).toEqual([...titles].sort((a, b) => a.localeCompare(b))) +} + +test.describe('Body CRDT coverage variants', () => { + const offlineOfflineScenarios = [ + { + name: 'different blocks', + initialBody: 'shared top block\n\nshared bottom block', + editA: { blockIndex: 0, cursorPosition: 'end', text: ' from A' } satisfies BlockEdit, + editB: { blockIndex: 2, cursorPosition: 'end', text: ' from B' } satisfies BlockEdit, + expectedBodies: ['shared top block from A\n\nshared bottom block from B'], + creator: 'A' as const + }, + { + name: 'same block different insertion points', + initialBody: 'shared merge block', + editA: { blockIndex: 0, cursorPosition: 'start', text: 'A ' } satisfies BlockEdit, + editB: { blockIndex: 0, cursorPosition: 'end', text: ' B' } satisfies BlockEdit, + expectedBodies: ['A shared merge block B'], + creator: 'A' as const + }, + { + name: 'same exact cursor position', + initialBody: 'shared merge block', + editA: { blockIndex: 0, cursorPosition: 'start', text: 'A ' } satisfies BlockEdit, + editB: { blockIndex: 0, cursorPosition: 'start', text: 'B ' } satisfies BlockEdit, + expectedBodies: ['A B shared merge block', 'B A shared merge block'], + creator: 'A' as const + } + ] + + for (const reconnectOrder of ['a-first', 'b-first', 'together'] as const) { + for (const scenario of offlineOfflineScenarios) { + test(`V${reconnectOrder === 'a-first' ? '1' : reconnectOrder === 'b-first' ? '2' : '3'}: ${scenario.name} converges when reconnect order is ${reconnectOrder}`, async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `V${reconnectOrder}-${scenario.name}-${Date.now()}` + + await runOfflineOfflineMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: scenario.creator === 'A' ? pageA : pageB, + title, + initialBody: scenario.initialBody, + editA: scenario.editA, + editB: scenario.editB, + expectedBodies: scenario.expectedBodies, + reconnectOrder + }) + }) + } + } + + test('V4: receiver-open variants converge for representative single-writer and merge cases', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const singleWriterTitle = `V4 Receiver Open Single Writer ${Date.now()}` + await runReceiverStateSingleWriterCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: pageA, + editorPage: pageA, + receiverPage: pageB, + editorApp: electronAppA, + receiverApp: electronAppB, + title: singleWriterTitle, + initialBody: 'shared note from A', + editText: 'online edit from A while B offline', + receiverState: 'open' + }) + + const mergeTitle = `V4 Receiver Open Merge ${Date.now()}` + await runOfflineOfflineMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: pageA, + title: mergeTitle, + initialBody: 'shared receiver-open merge block', + editA: { blockIndex: 0, cursorPosition: 'start', text: 'A ' }, + editB: { blockIndex: 0, cursorPosition: 'end', text: ' B' }, + expectedBodies: ['A shared receiver-open merge block B'], + reconnectOrder: 'together' + }) + }) + + test('V5: receiver-closed variants converge after the note is reopened', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const singleWriterTitle = `V5 Receiver Closed Single Writer ${Date.now()}` + await runReceiverStateSingleWriterCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: pageA, + editorPage: pageA, + receiverPage: pageB, + editorApp: electronAppA, + receiverApp: electronAppB, + title: singleWriterTitle, + initialBody: 'shared note from A', + editText: 'online edit from A while B offline', + receiverState: 'closed' + }) + + const mergeTitle = `V5 Receiver Closed Merge ${Date.now()}` + await runOfflineOfflineMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: pageA, + title: mergeTitle, + initialBody: 'shared receiver-closed merge block', + editA: { blockIndex: 0, cursorPosition: 'start', text: 'A ' }, + editB: { blockIndex: 0, cursorPosition: 'end', text: ' B' }, + expectedBodies: ['A shared receiver-closed merge block B'], + reconnectOrder: 'together', + closedBeforeReconnect: 'b' + }) + }) + + test('V6: both devices create one note offline, sync, then preserve 4 cross-edits across 2 notes', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const prefix = `V6 Two Notes Four Edits ${Date.now()}` + const titleA = `${prefix} NoteA` + const titleB = `${prefix} NoteB` + + await Promise.all([goOffline(electronAppA), goOffline(electronAppB)]) + await Promise.all([waitForSyncOffline(pageA), waitForSyncOffline(pageB)]) + + await createNoteWithBody(pageA, titleA, 'noteA shared merge block') + await createNoteWithBody(pageB, titleB, 'noteB shared merge block') + + await reconnectDevices({ + electronAppA, + electronAppB, + pageA, + pageB, + order: 'together' + }) + + await expectSharedNoteTitles(pageA, [titleA, titleB]) + await expectSharedNoteTitles(pageB, [titleA, titleB]) + + await Promise.all([ + applyBlockEdit(pageA, titleB, { blockIndex: 0, cursorPosition: 'start', text: 'A ' }), + applyBlockEdit(pageB, titleB, { blockIndex: 0, cursorPosition: 'end', text: ' B' }) + ]) + await Promise.all([waitForCrdtQueueIdle(electronAppA), waitForCrdtQueueIdle(electronAppB)]) + await syncBothAndWait(pageA, pageB) + await assertMergedNoteOnBothDevices(electronAppA, electronAppB, pageA, pageB, titleB, [ + 'A noteB shared merge block B' + ]) + + await Promise.all([ + applyBlockEdit(pageA, titleA, { blockIndex: 0, cursorPosition: 'end', text: ' A' }), + applyBlockEdit(pageB, titleA, { blockIndex: 0, cursorPosition: 'start', text: 'B ' }) + ]) + await Promise.all([waitForCrdtQueueIdle(electronAppA), waitForCrdtQueueIdle(electronAppB)]) + await syncBothAndWait(pageA, pageB) + await assertMergedNoteOnBothDevices(electronAppA, electronAppB, pageA, pageB, titleA, [ + 'B noteA shared merge block A' + ]) + + await expectSharedNoteTitles(pageA, [titleA, titleB]) + await expectSharedNoteTitles(pageB, [titleA, titleB]) + }) +}) diff --git a/apps/desktop/tests/e2e/body-crdt-create-propagation.e2e.ts b/apps/desktop/tests/e2e/body-crdt-create-propagation.e2e.ts new file mode 100644 index 000000000..5ff1255b2 --- /dev/null +++ b/apps/desktop/tests/e2e/body-crdt-create-propagation.e2e.ts @@ -0,0 +1,172 @@ +import { test, expect } from './fixtures/sync-auth-fixtures' +import { + createNoteWithBody, + expectNoteBody, + getNoteFileBodyByTitle, + openNoteByTitle +} from './utils/note-sync-helpers' +import { + goOffline, + goOnline, + syncBothAndWait, + waitForSyncOffline, + waitForSyncOnline +} from './utils/network-control' +import type { ElectronApplication, Page } from '@playwright/test' + +async function noteExists(page: Parameters[0], title: string): Promise { + return page.evaluate(async (expectedTitle) => { + const result = await window.api.notes.list({}) + return result.notes.some((note) => note.title === expectedTitle) + }, title) +} + +async function runOfflineCreatePropagationCase({ + offlineApp, + creatorPage, + receiverPage, + title, + body +}: { + offlineApp: ElectronApplication + creatorPage: Page + receiverPage: Page + title: string + body: string +}): Promise { + await Promise.all([waitForSyncOnline(creatorPage), waitForSyncOnline(receiverPage)]) + + await goOffline(offlineApp) + await waitForSyncOffline(creatorPage) + + await createNoteWithBody(creatorPage, title, body) + + expect(await noteExists(creatorPage, title)).toBe(true) + expect(await noteExists(receiverPage, title)).toBe(false) + + await goOnline(offlineApp) + await waitForSyncOnline(creatorPage) + + await syncBothAndWait(creatorPage, receiverPage) + + expect(await getNoteFileBodyByTitle(receiverPage, title)).toBe(body) + + await openNoteByTitle(receiverPage, title) + await expectNoteBody(receiverPage, body) +} + +async function runReceiverOfflineCreatePropagationCase({ + offlineApp, + creatorPage, + receiverPage, + title, + body +}: { + offlineApp: ElectronApplication + creatorPage: Page + receiverPage: Page + title: string + body: string +}): Promise { + await Promise.all([waitForSyncOnline(creatorPage), waitForSyncOnline(receiverPage)]) + + await goOffline(offlineApp) + await waitForSyncOffline(receiverPage) + + await createNoteWithBody(creatorPage, title, body) + + expect(await noteExists(creatorPage, title)).toBe(true) + expect(await noteExists(receiverPage, title)).toBe(false) + + await goOnline(offlineApp) + await waitForSyncOnline(receiverPage) + + await syncBothAndWait(creatorPage, receiverPage) + + expect(await getNoteFileBodyByTitle(receiverPage, title)).toBe(body) + + await openNoteByTitle(receiverPage, title) + await expectNoteBody(receiverPage, body) +} + +test.describe('Body CRDT create propagation', () => { + test('C1: A offline creates a note, then A reconnects and B receives the exact body', async ({ + electronAppA, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `C1 Offline Create ${Date.now()}` + const body = 'device A offline line 1\n\ndevice A offline line 2' + + await runOfflineCreatePropagationCase({ + offlineApp: electronAppA, + creatorPage: pageA, + receiverPage: pageB, + title, + body + }) + }) + + test('C2: B offline creates a note, then B reconnects and A receives the exact body', async ({ + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `C2 Offline Create ${Date.now()}` + const body = 'device B offline line 1\n\ndevice B offline line 2' + + await runOfflineCreatePropagationCase({ + offlineApp: electronAppB, + creatorPage: pageB, + receiverPage: pageA, + title, + body + }) + }) + + test('C3: A online creates a note while B is offline, then B reconnects and receives the exact body', async ({ + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `C3 Online Create ${Date.now()}` + const body = 'device A online line 1\n\ndevice A online line 2' + + await runReceiverOfflineCreatePropagationCase({ + offlineApp: electronAppB, + creatorPage: pageA, + receiverPage: pageB, + title, + body + }) + }) + + test('C4: B online creates a note while A is offline, then A reconnects and receives the exact body', async ({ + electronAppA, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `C4 Online Create ${Date.now()}` + const body = 'device B online line 1\n\ndevice B online line 2' + + await runReceiverOfflineCreatePropagationCase({ + offlineApp: electronAppA, + creatorPage: pageB, + receiverPage: pageA, + title, + body + }) + }) +}) diff --git a/apps/desktop/tests/e2e/body-crdt-different-note-edit.e2e.ts b/apps/desktop/tests/e2e/body-crdt-different-note-edit.e2e.ts new file mode 100644 index 000000000..94c3a9730 --- /dev/null +++ b/apps/desktop/tests/e2e/body-crdt-different-note-edit.e2e.ts @@ -0,0 +1,272 @@ +import { test, expect } from './fixtures/sync-auth-fixtures' +import type { ElectronApplication, Page } from '@playwright/test' +import { + appendToNoteBody, + createNoteWithBody, + expectNoteBody, + getNoteFileBodyByTitle, + openNoteByTitle +} from './utils/note-sync-helpers' +import { + goOffline, + goOnline, + syncBothAndWait, + waitForCrdtQueueIdle, + waitForSyncOffline, + waitForSyncOnline +} from './utils/network-control' + +async function seedSharedNotes({ + pageA, + pageB, + titleA, + titleB, + bodyA, + bodyB +}: { + pageA: Page + pageB: Page + titleA: string + titleB: string + bodyA: string + bodyB: string +}): Promise { + await Promise.all([waitForSyncOnline(pageA), waitForSyncOnline(pageB)]) + + await createNoteWithBody(pageA, titleA, bodyA) + await syncBothAndWait(pageA, pageB) + + await createNoteWithBody(pageB, titleB, bodyB) + await syncBothAndWait(pageA, pageB) + + await assertNoteOnBothDevices(pageA, pageB, titleA, bodyA) + await assertNoteOnBothDevices(pageA, pageB, titleB, bodyB) +} + +async function appendBodyAndPersist({ + page, + title, + editText, + expectedBody +}: { + page: Page + title: string + editText: string + expectedBody: string +}): Promise { + await openNoteByTitle(page, title) + await appendToNoteBody(page, editText) + await expectNoteBody(page, expectedBody) + await expect.poll(() => getNoteFileBodyByTitle(page, title)).toBe(expectedBody) +} + +async function assertNoteOnBothDevices( + pageA: Page, + pageB: Page, + title: string, + expectedBody: string +): Promise { + await expect.poll(() => getNoteFileBodyByTitle(pageA, title)).toBe(expectedBody) + await expect.poll(() => getNoteFileBodyByTitle(pageB, title)).toBe(expectedBody) + + await openNoteByTitle(pageA, title) + await expectNoteBody(pageA, expectedBody) + + await openNoteByTitle(pageB, title) + await expectNoteBody(pageB, expectedBody) +} + +async function runDifferentNoteEditCase({ + electronAppA, + electronAppB, + pageA, + pageB, + titleA, + titleB, + initialBodyA, + initialBodyB, + editTextA, + editTextB, + offlineBeforeEditA, + offlineBeforeEditB +}: { + electronAppA: ElectronApplication + electronAppB: ElectronApplication + pageA: Page + pageB: Page + titleA: string + titleB: string + initialBodyA: string + initialBodyB: string + editTextA: string + editTextB: string + offlineBeforeEditA: boolean + offlineBeforeEditB: boolean +}): Promise { + const expectedBodyA = `${initialBodyA}\n\n${editTextA}` + const expectedBodyB = `${initialBodyB}\n\n${editTextB}` + + await seedSharedNotes({ + pageA, + pageB, + titleA, + titleB, + bodyA: initialBodyA, + bodyB: initialBodyB + }) + + if (offlineBeforeEditA) { + await goOffline(electronAppA) + await waitForSyncOffline(pageA) + } else { + await waitForSyncOnline(pageA) + } + + if (offlineBeforeEditB) { + await goOffline(electronAppB) + await waitForSyncOffline(pageB) + } else { + await waitForSyncOnline(pageB) + } + + await Promise.all([ + appendBodyAndPersist({ + page: pageA, + title: titleA, + editText: editTextA, + expectedBody: expectedBodyA + }), + appendBodyAndPersist({ + page: pageB, + title: titleB, + editText: editTextB, + expectedBody: expectedBodyB + }) + ]) + + const liveEditors: Promise[] = [] + if (!offlineBeforeEditA) { + liveEditors.push(waitForCrdtQueueIdle(electronAppA)) + } + if (!offlineBeforeEditB) { + liveEditors.push(waitForCrdtQueueIdle(electronAppB)) + } + await Promise.all(liveEditors) + + if (offlineBeforeEditA) { + await goOnline(electronAppA) + await waitForSyncOnline(pageA) + } + + if (offlineBeforeEditB) { + await goOnline(electronAppB) + await waitForSyncOnline(pageB) + } + + await syncBothAndWait(pageA, pageB) + + await assertNoteOnBothDevices(pageA, pageB, titleA, expectedBodyA) + await assertNoteOnBothDevices(pageA, pageB, titleB, expectedBodyB) +} + +test.describe('Body CRDT different-note edit propagation', () => { + test('D1: A offline edits noteA while B offline edits noteB, then both devices converge', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + await runDifferentNoteEditCase({ + electronAppA, + electronAppB, + pageA, + pageB, + titleA: `D1 Note A ${Date.now()}`, + titleB: `D1 Note B ${Date.now()}`, + initialBodyA: 'shared noteA body', + initialBodyB: 'shared noteB body', + editTextA: 'offline edit from A on noteA', + editTextB: 'offline edit from B on noteB', + offlineBeforeEditA: true, + offlineBeforeEditB: true + }) + }) + + test('D2: A online edits noteA while B offline edits noteB, then both devices converge', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + await runDifferentNoteEditCase({ + electronAppA, + electronAppB, + pageA, + pageB, + titleA: `D2 Note A ${Date.now()}`, + titleB: `D2 Note B ${Date.now()}`, + initialBodyA: 'shared noteA body', + initialBodyB: 'shared noteB body', + editTextA: 'online edit from A on noteA', + editTextB: 'offline edit from B on noteB', + offlineBeforeEditA: false, + offlineBeforeEditB: true + }) + }) + + test('D3: A offline edits noteA while B online edits noteB, then both devices converge', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + await runDifferentNoteEditCase({ + electronAppA, + electronAppB, + pageA, + pageB, + titleA: `D3 Note A ${Date.now()}`, + titleB: `D3 Note B ${Date.now()}`, + initialBodyA: 'shared noteA body', + initialBodyB: 'shared noteB body', + editTextA: 'offline edit from A on noteA', + editTextB: 'online edit from B on noteB', + offlineBeforeEditA: true, + offlineBeforeEditB: false + }) + }) + + test('D4: A online and B online edit different notes concurrently, then both devices converge', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + await runDifferentNoteEditCase({ + electronAppA, + electronAppB, + pageA, + pageB, + titleA: `D4 Note A ${Date.now()}`, + titleB: `D4 Note B ${Date.now()}`, + initialBodyA: 'shared noteA body', + initialBodyB: 'shared noteB body', + editTextA: 'online edit from A on noteA', + editTextB: 'online edit from B on noteB', + offlineBeforeEditA: false, + offlineBeforeEditB: false + }) + }) +}) diff --git a/apps/desktop/tests/e2e/body-crdt-same-note-merge.e2e.ts b/apps/desktop/tests/e2e/body-crdt-same-note-merge.e2e.ts new file mode 100644 index 000000000..846430d0c --- /dev/null +++ b/apps/desktop/tests/e2e/body-crdt-same-note-merge.e2e.ts @@ -0,0 +1,432 @@ +import { test, expect } from './fixtures/sync-auth-fixtures' +import type { ElectronApplication, Page } from '@playwright/test' +import { + createNoteWithBody, + expectNoteBody, + getCrdtDocBodyByTitle, + getNoteFileBodyByTitle, + getWritebackDebugByTitle, + openNoteByTitle, + readNoteBodyText +} from './utils/note-sync-helpers' +import { SELECTORS } from './utils/electron-helpers' +import { + goOffline, + goOnline, + syncBothAndWait, + waitForCrdtQueueIdle, + waitForSyncOffline, + waitForSyncOnline +} from './utils/network-control' + +type CursorPosition = 'start' | 'end' + +interface BlockEdit { + blockIndex: number + cursorPosition: CursorPosition + text: string +} + +async function seedSharedNote({ + pageA, + pageB, + creatorPage, + title, + body +}: { + pageA: Page + pageB: Page + creatorPage: Page + title: string + body: string +}): Promise { + await Promise.all([waitForSyncOnline(pageA), waitForSyncOnline(pageB)]) + + await createNoteWithBody(creatorPage, title, body) + await syncBothAndWait(pageA, pageB) + + await openNoteByTitle(pageA, title) + await expectNoteBody(pageA, body) + + await openNoteByTitle(pageB, title) + await expectNoteBody(pageB, body) +} + +async function applyBlockEdit(page: Page, title: string, edit: BlockEdit): Promise { + await openNoteByTitle(page, title) + const editorRoot = page.locator(SELECTORS.noteEditor).first() + await editorRoot.waitFor({ state: 'visible', timeout: 10000 }) + await editorRoot.click() + + await page.evaluate(({ blockIndex, cursorPosition }) => { + const editor = (window as unknown as { __memryEditor?: any }).__memryEditor + if (!editor) throw new Error('window.__memryEditor not exposed') + + const doc = editor.document as any[] + const target = doc[blockIndex] + if (!target?.id) { + throw new Error(`Block ${blockIndex} is not available`) + } + + editor.focus() + editor.setTextCursorPosition(target.id, cursorPosition) + }, edit) + + await page.keyboard.type(edit.text) +} + +async function assertMergedNoteOnBothDevices( + electronAppA: ElectronApplication, + electronAppB: ElectronApplication, + pageA: Page, + pageB: Page, + title: string, + expectedBodies: string[] +): Promise { + await openNoteByTitle(pageA, title) + await openNoteByTitle(pageB, title) + + let finalBody = '' + await expect + .poll(async () => { + const body = await readNoteBodyText(pageA) + if (!expectedBodies.includes(body)) { + return false + } + finalBody = body + return true + }) + .toBe(true) + + await expect.poll(() => readNoteBodyText(pageA)).toBe(finalBody) + await expect.poll(() => readNoteBodyText(pageB)).toBe(finalBody) + await expect.poll(() => getCrdtDocBodyByTitle(pageA, electronAppA, title)).toBe(finalBody) + await expect.poll(() => getCrdtDocBodyByTitle(pageB, electronAppB, title)).toBe(finalBody) + await expect + .poll(async () => (await getWritebackDebugByTitle(pageA, electronAppA, title))?.lastMarkdown ?? null) + .toBe(finalBody) + await expect + .poll(async () => (await getWritebackDebugByTitle(pageB, electronAppB, title))?.lastMarkdown ?? null) + .toBe(finalBody) + await expect.poll(() => getNoteFileBodyByTitle(pageA, title)).toBe(finalBody) + await expect.poll(() => getNoteFileBodyByTitle(pageB, title)).toBe(finalBody) +} + +async function runSameNoteMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage, + title, + initialBody, + editA, + editB, + expectedBodies, + expectedLocalBodyA, + expectedLocalBodyB, + offlineBeforeEditA, + offlineBeforeEditB +}: { + electronAppA: ElectronApplication + electronAppB: ElectronApplication + pageA: Page + pageB: Page + creatorPage: Page + title: string + initialBody: string + editA: BlockEdit + editB: BlockEdit + expectedBodies: string[] + expectedLocalBodyA?: string + expectedLocalBodyB?: string + offlineBeforeEditA: boolean + offlineBeforeEditB: boolean +}): Promise { + await seedSharedNote({ pageA, pageB, creatorPage, title, body: initialBody }) + + if (offlineBeforeEditA) { + await goOffline(electronAppA) + await waitForSyncOffline(pageA) + } else { + await waitForSyncOnline(pageA) + } + + if (offlineBeforeEditB) { + await goOffline(electronAppB) + await waitForSyncOffline(pageB) + } else { + await waitForSyncOnline(pageB) + } + + await Promise.all([applyBlockEdit(pageA, title, editA), applyBlockEdit(pageB, title, editB)]) + + if (expectedLocalBodyA) { + await expect.poll(() => readNoteBodyText(pageA)).toBe(expectedLocalBodyA) + await expect.poll(() => getNoteFileBodyByTitle(pageA, title)).toBe(expectedLocalBodyA) + } + + if (expectedLocalBodyB) { + await expect.poll(() => readNoteBodyText(pageB)).toBe(expectedLocalBodyB) + await expect.poll(() => getNoteFileBodyByTitle(pageB, title)).toBe(expectedLocalBodyB) + } + + const liveEditors: Promise[] = [] + if (!offlineBeforeEditA) { + liveEditors.push(waitForCrdtQueueIdle(electronAppA)) + } + if (!offlineBeforeEditB) { + liveEditors.push(waitForCrdtQueueIdle(electronAppB)) + } + await Promise.all(liveEditors) + + if (offlineBeforeEditA) { + await goOnline(electronAppA) + await waitForSyncOnline(pageA) + } + + if (offlineBeforeEditB) { + await goOnline(electronAppB) + await waitForSyncOnline(pageB) + } + + await syncBothAndWait(pageA, pageB) + await assertMergedNoteOnBothDevices(electronAppA, electronAppB, pageA, pageB, title, expectedBodies) +} + +test.describe('Body CRDT same-note merge propagation', () => { + test('M1: A offline and B offline edit different blocks on the same note, then both devices preserve both edits', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `M1 Same Note Different Blocks ${Date.now()}` + const initialBody = 'shared top block\n\nshared bottom block' + const expectedBody = 'shared top block from A\n\nshared bottom block from B' + const expectedLocalBodyA = 'shared top block from A\n\nshared bottom block' + const expectedLocalBodyB = 'shared top block\n\nshared bottom block from B' + + await runSameNoteMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: pageA, + title, + initialBody, + editA: { blockIndex: 0, cursorPosition: 'end', text: ' from A' }, + editB: { blockIndex: 2, cursorPosition: 'end', text: ' from B' }, + expectedBodies: [expectedBody], + expectedLocalBodyA, + expectedLocalBodyB, + offlineBeforeEditA: true, + offlineBeforeEditB: true + }) + }) + + test('M2: A offline and B offline edit the same block at different insertion points, then both devices preserve the merge', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `M2 Same Block Different Positions ${Date.now()}` + const initialBody = 'shared merge block' + const expectedBody = 'A shared merge block B' + + await runSameNoteMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: pageA, + title, + initialBody, + editA: { blockIndex: 0, cursorPosition: 'start', text: 'A ' }, + editB: { blockIndex: 0, cursorPosition: 'end', text: ' B' }, + expectedBodies: [expectedBody], + offlineBeforeEditA: true, + offlineBeforeEditB: true + }) + }) + + test('M3: A offline and B offline insert at the same cursor position, then both devices converge on one deterministic merged body', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `M3 Same Position Insert ${Date.now()}` + const initialBody = 'shared merge block' + + await runSameNoteMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: pageA, + title, + initialBody, + editA: { blockIndex: 0, cursorPosition: 'start', text: 'A ' }, + editB: { blockIndex: 0, cursorPosition: 'start', text: 'B ' }, + expectedBodies: ['A B shared merge block', 'B A shared merge block'], + offlineBeforeEditA: true, + offlineBeforeEditB: true + }) + }) + + test('M4: A online and B offline edit the same note concurrently, then both devices preserve the merge', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `M4 A Online B Offline ${Date.now()}` + const initialBody = 'shared live merge block' + const expectedBody = 'A shared live merge block B' + + await runSameNoteMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: pageA, + title, + initialBody, + editA: { blockIndex: 0, cursorPosition: 'start', text: 'A ' }, + editB: { blockIndex: 0, cursorPosition: 'end', text: ' B' }, + expectedBodies: [expectedBody], + offlineBeforeEditA: false, + offlineBeforeEditB: true + }) + }) + + test('M5: A offline and B online edit the same note concurrently, then both devices preserve the merge', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `M5 A Offline B Online ${Date.now()}` + const initialBody = 'shared live merge block' + const expectedBody = 'A shared live merge block B' + + await runSameNoteMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: pageA, + title, + initialBody, + editA: { blockIndex: 0, cursorPosition: 'start', text: 'A ' }, + editB: { blockIndex: 0, cursorPosition: 'end', text: ' B' }, + expectedBodies: [expectedBody], + offlineBeforeEditA: true, + offlineBeforeEditB: false + }) + }) + + test('M6: A online and B online edit the same note concurrently, then both devices preserve the merge', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `M6 Both Online ${Date.now()}` + const initialBody = 'shared live merge block' + const expectedBody = 'A shared live merge block B' + + await runSameNoteMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: pageA, + title, + initialBody, + editA: { blockIndex: 0, cursorPosition: 'start', text: 'A ' }, + editB: { blockIndex: 0, cursorPosition: 'end', text: ' B' }, + expectedBodies: [expectedBody], + offlineBeforeEditA: false, + offlineBeforeEditB: false + }) + }) + + test('M7: A edits noteB while B also edits noteB, then both devices preserve the merge', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `M7 NoteB Cross Edit ${Date.now()}` + const initialBody = 'noteB shared merge block' + const expectedBody = 'A noteB shared merge block B' + + await runSameNoteMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: pageB, + title, + initialBody, + editA: { blockIndex: 0, cursorPosition: 'start', text: 'A ' }, + editB: { blockIndex: 0, cursorPosition: 'end', text: ' B' }, + expectedBodies: [expectedBody], + offlineBeforeEditA: true, + offlineBeforeEditB: true + }) + }) + + test('M8: B edits noteA while A also edits noteA, then both devices preserve the merge', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `M8 NoteA Cross Edit ${Date.now()}` + const initialBody = 'noteA shared merge block' + const expectedBody = 'B noteA shared merge block A' + + await runSameNoteMergeCase({ + electronAppA, + electronAppB, + pageA, + pageB, + creatorPage: pageA, + title, + initialBody, + editA: { blockIndex: 0, cursorPosition: 'end', text: ' A' }, + editB: { blockIndex: 0, cursorPosition: 'start', text: 'B ' }, + expectedBodies: [expectedBody], + offlineBeforeEditA: true, + offlineBeforeEditB: true + }) + }) +}) diff --git a/apps/desktop/tests/e2e/body-crdt-single-writer-edit.e2e.ts b/apps/desktop/tests/e2e/body-crdt-single-writer-edit.e2e.ts new file mode 100644 index 000000000..e9c1fdcb0 --- /dev/null +++ b/apps/desktop/tests/e2e/body-crdt-single-writer-edit.e2e.ts @@ -0,0 +1,303 @@ +import { test, expect } from './fixtures/sync-auth-fixtures' +import type { ElectronApplication, Page } from '@playwright/test' +import { + appendToNoteBody, + createNoteWithBody, + expectNoteBody, + getCrdtDocBodyByTitle, + getNoteFileBodyByTitle, + openNoteByTitle +} from './utils/note-sync-helpers' +import { + goOffline, + goOnline, + syncBothAndWait, + waitForCrdtQueueIdle, + waitForSyncOffline, + waitForSyncOnline +} from './utils/network-control' + +async function seedSharedNote({ + pageA, + pageB, + creatorPage, + title, + body +}: { + pageA: Page + pageB: Page + creatorPage: Page + title: string + body: string +}): Promise { + await Promise.all([waitForSyncOnline(pageA), waitForSyncOnline(pageB)]) + + await createNoteWithBody(creatorPage, title, body) + await syncBothAndWait(pageA, pageB) + + await openNoteByTitle(pageA, title) + await expectNoteBody(pageA, body) + + await openNoteByTitle(pageB, title) + await expectNoteBody(pageB, body) +} + +async function appendBodyAndPersist({ + page, + title, + editText, + expectedBody +}: { + page: Page + title: string + editText: string + expectedBody: string +}): Promise { + await openNoteByTitle(page, title) + await appendToNoteBody(page, editText) + await expectNoteBody(page, expectedBody) + await expect.poll(() => getNoteFileBodyByTitle(page, title)).toBe(expectedBody) +} + +async function runOfflineEditorEditCase({ + pageA, + pageB, + creatorPage, + editorPage, + receiverPage, + offlineApp, + title, + initialBody, + editText +}: { + pageA: Page + pageB: Page + creatorPage: Page + editorPage: Page + receiverPage: Page + offlineApp: ElectronApplication + title: string + initialBody: string + editText: string +}): Promise { + const expectedBody = `${initialBody}\n\n${editText}` + + await seedSharedNote({ pageA, pageB, creatorPage, title, body: initialBody }) + + await goOffline(offlineApp) + await waitForSyncOffline(editorPage) + + await appendBodyAndPersist({ page: editorPage, title, editText, expectedBody }) + + await goOnline(offlineApp) + await waitForSyncOnline(editorPage) + + await syncBothAndWait(pageA, pageB) + + await openNoteByTitle(receiverPage, title) + await expectNoteBody(receiverPage, expectedBody) +} + +async function runOfflineReceiverEditCase({ + pageA, + pageB, + creatorPage, + editorPage, + receiverPage, + editorApp, + receiverApp, + offlineApp, + title, + initialBody, + editText +}: { + pageA: Page + pageB: Page + creatorPage: Page + editorPage: Page + receiverPage: Page + editorApp: ElectronApplication + receiverApp: ElectronApplication + offlineApp: ElectronApplication + title: string + initialBody: string + editText: string +}): Promise { + const expectedBody = `${initialBody}\n\n${editText}` + + await seedSharedNote({ pageA, pageB, creatorPage, title, body: initialBody }) + + await goOffline(offlineApp) + await waitForSyncOffline(receiverPage) + + await appendBodyAndPersist({ page: editorPage, title, editText, expectedBody }) + await waitForCrdtQueueIdle(editorApp) + + await goOnline(offlineApp) + await waitForSyncOnline(receiverPage) + + await syncBothAndWait(pageA, pageB) + + await expect.poll(() => getCrdtDocBodyByTitle(receiverPage, receiverApp, title)).toBe(expectedBody) + await expect.poll(() => getNoteFileBodyByTitle(receiverPage, title)).toBe(expectedBody) + await openNoteByTitle(receiverPage, title) + await expectNoteBody(receiverPage, expectedBody) +} + +test.describe('Body CRDT single-writer edit propagation', () => { + test('E1: A offline edits an A-created shared note, then B receives the body change', async ({ + electronAppA, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `E1 Offline Own Edit ${Date.now()}` + const initialBody = 'shared note from A' + const editText = 'offline edit from A' + + await runOfflineEditorEditCase({ + pageA, + pageB, + creatorPage: pageA, + editorPage: pageA, + receiverPage: pageB, + offlineApp: electronAppA, + title, + initialBody, + editText + }) + }) + + test('E2: B offline edits a B-created shared note, then A receives the body change', async ({ + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `E2 Offline Own Edit ${Date.now()}` + const initialBody = 'shared note from B' + const editText = 'offline edit from B' + + await runOfflineEditorEditCase({ + pageA, + pageB, + creatorPage: pageB, + editorPage: pageB, + receiverPage: pageA, + offlineApp: electronAppB, + title, + initialBody, + editText + }) + }) + + test('E3: A offline edits a B-created shared note, then B receives the body change', async ({ + electronAppA, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `E3 Offline Peer Edit ${Date.now()}` + const initialBody = 'shared note from B' + const editText = 'offline edit from A on B note' + + await runOfflineEditorEditCase({ + pageA, + pageB, + creatorPage: pageB, + editorPage: pageA, + receiverPage: pageB, + offlineApp: electronAppA, + title, + initialBody, + editText + }) + }) + + test('E4: B offline edits an A-created shared note, then A receives the body change', async ({ + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `E4 Offline Peer Edit ${Date.now()}` + const initialBody = 'shared note from A' + const editText = 'offline edit from B on A note' + + await runOfflineEditorEditCase({ + pageA, + pageB, + creatorPage: pageA, + editorPage: pageB, + receiverPage: pageA, + offlineApp: electronAppB, + title, + initialBody, + editText + }) + }) + + test('E5: A online edits a shared note while B is offline, then B receives the body change', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `E5 Receiver Offline ${Date.now()}` + const initialBody = 'shared note from A' + const editText = 'online edit from A while B offline' + + await runOfflineReceiverEditCase({ + pageA, + pageB, + creatorPage: pageA, + editorPage: pageA, + receiverPage: pageB, + editorApp: electronAppA, + receiverApp: electronAppB, + offlineApp: electronAppB, + title, + initialBody, + editText + }) + }) + + test('E6: B online edits a shared note while A is offline, then A receives the body change', async ({ + electronAppA, + electronAppB, + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `E6 Receiver Offline ${Date.now()}` + const initialBody = 'shared note from B' + const editText = 'online edit from B while A offline' + + await runOfflineReceiverEditCase({ + pageA, + pageB, + creatorPage: pageB, + editorPage: pageB, + receiverPage: pageA, + editorApp: electronAppB, + receiverApp: electronAppA, + offlineApp: electronAppA, + title, + initialBody, + editText + }) + }) +}) diff --git a/apps/desktop/tests/e2e/dual-device-isolation.e2e.ts b/apps/desktop/tests/e2e/dual-device-isolation.e2e.ts new file mode 100644 index 000000000..8ca2fa999 --- /dev/null +++ b/apps/desktop/tests/e2e/dual-device-isolation.e2e.ts @@ -0,0 +1,14 @@ +import { test, expect } from './fixtures/sync-fixtures' + +test.describe('Dual-device isolation', () => { + test('launches A and B with separate vaults', async ({ pageA, pageB, vaultPathA, vaultPathB }) => { + const statusA = await pageA.evaluate(() => window.api.vault.getStatus()) + const statusB = await pageB.evaluate(() => window.api.vault.getStatus()) + + expect(statusA.isOpen).toBe(true) + expect(statusB.isOpen).toBe(true) + expect(statusA.path).toBe(vaultPathA) + expect(statusB.path).toBe(vaultPathB) + expect(statusA.path).not.toBe(statusB.path) + }) +}) diff --git a/apps/desktop/tests/e2e/fixtures/sync-auth-fixtures.ts b/apps/desktop/tests/e2e/fixtures/sync-auth-fixtures.ts new file mode 100644 index 000000000..0be4f17b2 --- /dev/null +++ b/apps/desktop/tests/e2e/fixtures/sync-auth-fixtures.ts @@ -0,0 +1,84 @@ +import { expect as playwrightExpect, type ElectronApplication } from '@playwright/test' +import { test as base, expect } from './sync-fixtures' +import { waitForAppReady } from '../utils/electron-helpers' +import { startSharedSyncBootstrap, type SharedSyncBootstrap } from '../utils/sync-backend' + +async function bootstrapSyncDevice( + electronApp: ElectronApplication, + input: SharedSyncBootstrap['deviceA'] +): Promise { + await electronApp.evaluate(async (_context, bootstrapInput) => { + const hooks = ( + globalThis as typeof globalThis & { + __memryTestHooks?: { + bootstrapSyncDevice(input: SharedSyncBootstrap['deviceA']): Promise<{ deviceId: string }> + } + } + ).__memryTestHooks + if (!hooks) { + throw new Error('Memry test hooks are not registered') + } + await hooks.bootstrapSyncDevice(bootstrapInput) + }, input) +} + +async function waitForSyncedDeviceList( + page: Parameters[0], + email: string +): Promise { + await playwrightExpect + .poll(() => + page.evaluate(() => { + return window.api.syncDevices.getDevices().then((result) => ({ + email: result.email, + deviceCount: result.devices.length, + currentDeviceCount: result.devices.filter((device) => device.isCurrentDevice).length + })) + }) + ) + .toMatchObject({ + email, + currentDeviceCount: 1 + }) +} + +export const test = base.extend<{ + syncBootstrap: SharedSyncBootstrap + bootstrappedSyncPair: void +}>({ + syncBootstrap: async ({}, use) => { + const bootstrap = await startSharedSyncBootstrap() + try { + await use(bootstrap) + } finally { + await bootstrap.server.stop() + } + }, + + syncServerUrl: async ({ syncBootstrap }, use) => { + await use(syncBootstrap.serverUrl) + }, + + bootstrappedSyncPair: async ( + { electronAppA, electronAppB, pageA, pageB, syncBootstrap }, + use + ) => { + await bootstrapSyncDevice(electronAppA, syncBootstrap.deviceA) + await bootstrapSyncDevice(electronAppB, syncBootstrap.deviceB) + + await pageA.reload() + await pageA.waitForLoadState('domcontentloaded') + await waitForAppReady(pageA) + + await pageB.reload() + await pageB.waitForLoadState('domcontentloaded') + await waitForAppReady(pageB) + + await waitForSyncedDeviceList(pageA, syncBootstrap.email) + await waitForSyncedDeviceList(pageB, syncBootstrap.email) + + await use() + } +}) + +export { expect } diff --git a/apps/desktop/tests/e2e/fixtures/sync-fixtures.ts b/apps/desktop/tests/e2e/fixtures/sync-fixtures.ts new file mode 100644 index 000000000..1a70c3064 --- /dev/null +++ b/apps/desktop/tests/e2e/fixtures/sync-fixtures.ts @@ -0,0 +1,148 @@ +import { + test as base, + expect as playwrightExpect, + _electron as electron, + ElectronApplication, + Page +} from '@playwright/test' +import { randomUUID } from 'crypto' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { waitForAppReady } from '../utils/electron-helpers' + +interface LaunchResult { + app: ElectronApplication + userDataDir: string + resolvedUserDataDir: string +} + +function createTestVault(prefix: string): string { + const vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + fs.mkdirSync(path.join(vaultPath, '.memry'), { recursive: true }) + fs.mkdirSync(path.join(vaultPath, 'notes'), { recursive: true }) + fs.mkdirSync(path.join(vaultPath, 'journal'), { recursive: true }) + return vaultPath +} + +async function launchElectronApp( + deviceId: string, + vaultPath: string, + syncServerUrl: string | null +): Promise { + const isCI = !!process.env.CI + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), `memry-userdata-${deviceId}-`)) + const app = await electron.launch({ + args: [ + ...(isCI ? ['--no-sandbox', '--disable-gpu'] : []), + `--user-data-dir=${userDataDir}`, + path.join(__dirname, '../../../out/main/index.js') + ], + env: { + ...process.env, + NODE_ENV: 'test', + MEMRY_DEVICE: deviceId, + TEST_VAULT_PATH: vaultPath, + ...(syncServerUrl && { SYNC_SERVER_URL: syncServerUrl }), + ...(isCI && { ELECTRON_DISABLE_SANDBOX: '1' }) + } + }) + + const resolvedUserDataDir = await app.evaluate(({ app }) => app.getPath('userData')) + + return { app, userDataDir, resolvedUserDataDir } +} + +async function getReadyPage(app: ElectronApplication): Promise { + const page = await app.firstWindow() + await page.waitForLoadState('domcontentloaded') + await waitForAppReady(page) + return page +} + +async function waitForVaultPath(page: Page, expectedVaultPath: string, timeout = 15000): Promise { + await playwrightExpect + .poll(() => page.evaluate(() => window.api.vault.getStatus()), { timeout }) + .toMatchObject({ + isOpen: true, + path: expectedVaultPath + }) +} + +function cleanupDir(dirPath: string): void { + fs.rmSync(dirPath, { recursive: true, force: true }) +} + +export const test = base.extend<{ + electronAppA: ElectronApplication + electronAppB: ElectronApplication + pageA: Page + pageB: Page + vaultPathA: string + vaultPathB: string + deviceIdA: string + deviceIdB: string + syncServerUrl: string | null +}>({ + syncServerUrl: async ({}, use) => { + await use(null) + }, + + deviceIdA: async ({}, use) => { + await use(`e2e-${randomUUID()}-A`) + }, + + deviceIdB: async ({}, use) => { + await use(`e2e-${randomUUID()}-B`) + }, + + vaultPathA: async ({}, use) => { + const vaultPath = createTestVault('memry-e2e-deviceA-') + await use(vaultPath) + cleanupDir(vaultPath) + }, + + vaultPathB: async ({}, use) => { + const vaultPath = createTestVault('memry-e2e-deviceB-') + await use(vaultPath) + cleanupDir(vaultPath) + }, + + electronAppA: async ({ deviceIdA, vaultPathA, syncServerUrl }, use) => { + const { app, userDataDir, resolvedUserDataDir } = await launchElectronApp( + deviceIdA, + vaultPathA, + syncServerUrl + ) + await use(app) + await app.close() + cleanupDir(userDataDir) + cleanupDir(resolvedUserDataDir) + }, + + electronAppB: async ({ deviceIdB, vaultPathB, syncServerUrl }, use) => { + const { app, userDataDir, resolvedUserDataDir } = await launchElectronApp( + deviceIdB, + vaultPathB, + syncServerUrl + ) + await use(app) + await app.close() + cleanupDir(userDataDir) + cleanupDir(resolvedUserDataDir) + }, + + pageA: async ({ electronAppA, vaultPathA }, use) => { + const page = await getReadyPage(electronAppA) + await waitForVaultPath(page, vaultPathA) + await use(page) + }, + + pageB: async ({ electronAppB, vaultPathB }, use) => { + const page = await getReadyPage(electronAppB) + await waitForVaultPath(page, vaultPathB) + await use(page) + } +}) + +export { expect } from '@playwright/test' diff --git a/apps/desktop/tests/e2e/manual-sync-smoke.e2e.ts b/apps/desktop/tests/e2e/manual-sync-smoke.e2e.ts new file mode 100644 index 000000000..9fabc7dcc --- /dev/null +++ b/apps/desktop/tests/e2e/manual-sync-smoke.e2e.ts @@ -0,0 +1,28 @@ +import { test, expect } from './fixtures/sync-auth-fixtures' +import { createNoteWithBody, expectNoteBody, openNoteByTitle } from './utils/note-sync-helpers' +import { syncBothAndWait, waitForSyncOnline } from './utils/network-control' + +test.describe('Manual sync smoke', () => { + test('A creates online, both trigger sync, B reaches idle and opens the note', async ({ + pageA, + pageB, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const title = `H5 Sync Smoke ${Date.now()}` + const body = 'manual sync line 1\n\nmanual sync line 2' + + await Promise.all([waitForSyncOnline(pageA), waitForSyncOnline(pageB)]) + + await createNoteWithBody(pageA, title, body) + + const { statusA, statusB } = await syncBothAndWait(pageA, pageB) + + expect(statusA.pendingCount).toBe(0) + expect(statusB.pendingCount).toBe(0) + + await openNoteByTitle(pageB, title) + await expectNoteBody(pageB, body) + }) +}) diff --git a/apps/desktop/tests/e2e/network-control.e2e.ts b/apps/desktop/tests/e2e/network-control.e2e.ts new file mode 100644 index 000000000..a917c5440 --- /dev/null +++ b/apps/desktop/tests/e2e/network-control.e2e.ts @@ -0,0 +1,42 @@ +import { test, expect } from './fixtures/sync-auth-fixtures' +import { goOffline, goOnline, waitForSyncOffline, waitForSyncOnline } from './utils/network-control' + +test.describe('Network control', () => { + test( + 'transitions both devices online -> offline -> online', + async ({ electronAppA, electronAppB, pageA, pageB, bootstrappedSyncPair }) => { + void bootstrappedSyncPair + + const [triggerA, triggerB] = await Promise.all([ + pageA.evaluate(() => window.api.syncOps.triggerSync()), + pageB.evaluate(() => window.api.syncOps.triggerSync()) + ]) + + expect(triggerA.success).toBe(true) + expect(triggerB.success).toBe(true) + + await waitForSyncOnline(pageA) + await waitForSyncOnline(pageB) + + await goOffline(electronAppA, electronAppB) + + const [offlineStatusA, offlineStatusB] = await Promise.all([ + waitForSyncOffline(pageA), + waitForSyncOffline(pageB) + ]) + + expect(offlineStatusA.offlineSince).toBeDefined() + expect(offlineStatusB.offlineSince).toBeDefined() + + await goOnline(electronAppA, electronAppB) + + const [onlineStatusA, onlineStatusB] = await Promise.all([ + waitForSyncOnline(pageA), + waitForSyncOnline(pageB) + ]) + + expect(onlineStatusA.offlineSince).toBeUndefined() + expect(onlineStatusB.offlineSince).toBeUndefined() + } + ) +}) diff --git a/apps/desktop/tests/e2e/note-sync-helpers.e2e.ts b/apps/desktop/tests/e2e/note-sync-helpers.e2e.ts new file mode 100644 index 000000000..725bd33d6 --- /dev/null +++ b/apps/desktop/tests/e2e/note-sync-helpers.e2e.ts @@ -0,0 +1,36 @@ +import { test, expect } from './fixtures/sync-fixtures' +import { + waitForVaultReady +} from './utils/electron-helpers' +import { + createNoteWithBody, + expectNoteBody, + openNoteByTitle +} from './utils/note-sync-helpers' + +test.describe('Note sync helpers smoke', () => { + test('can create, reopen, and read exact note body on both devices', async ({ pageA, pageB }) => { + const now = Date.now() + const noteA = { + title: `H4 Device A ${now}`, + body: 'alpha line 1\n\nalpha line 2' + } + const noteB = { + title: `H4 Device B ${now}`, + body: 'beta line 1\n\nbeta line 2' + } + + await Promise.all([waitForVaultReady(pageA), waitForVaultReady(pageB)]) + + await createNoteWithBody(pageA, noteA.title, noteA.body) + await createNoteWithBody(pageA, `${noteA.title} Decoy`, 'decoy') + await createNoteWithBody(pageB, noteB.title, noteB.body) + await createNoteWithBody(pageB, `${noteB.title} Decoy`, 'decoy') + + await openNoteByTitle(pageA, noteA.title) + await openNoteByTitle(pageB, noteB.title) + + await expectNoteBody(pageA, noteA.body) + await expectNoteBody(pageB, noteB.body) + }) +}) diff --git a/apps/desktop/tests/e2e/shared-sync-bootstrap.e2e.ts b/apps/desktop/tests/e2e/shared-sync-bootstrap.e2e.ts new file mode 100644 index 000000000..dfb1eba01 --- /dev/null +++ b/apps/desktop/tests/e2e/shared-sync-bootstrap.e2e.ts @@ -0,0 +1,29 @@ +import { test, expect } from './fixtures/sync-auth-fixtures' + +test.describe('Shared sync bootstrap', () => { + test( + 'bootstraps A and B so both can trigger sync', + async ({ + electronAppA, + electronAppB, + pageA, + pageB, + syncServerUrl, + bootstrappedSyncPair + }) => { + void bootstrappedSyncPair + + const serverUrlA = await electronAppA.evaluate(() => process.env.SYNC_SERVER_URL ?? null) + const serverUrlB = await electronAppB.evaluate(() => process.env.SYNC_SERVER_URL ?? null) + + expect(serverUrlA).toBe(syncServerUrl) + expect(serverUrlB).toBe(syncServerUrl) + + const resultA = await pageA.evaluate(() => window.api.syncOps.triggerSync()) + const resultB = await pageB.evaluate(() => window.api.syncOps.triggerSync()) + + expect(resultA.success).toBe(true) + expect(resultB.success).toBe(true) + } + ) +}) diff --git a/apps/desktop/tests/e2e/utils/electron-helpers.ts b/apps/desktop/tests/e2e/utils/electron-helpers.ts index fd533ee91..184863a41 100644 --- a/apps/desktop/tests/e2e/utils/electron-helpers.ts +++ b/apps/desktop/tests/e2e/utils/electron-helpers.ts @@ -29,7 +29,7 @@ export const SELECTORS = { // Notes - actual selectors from the app notesList: '[data-testid="notes-list"], [class*="notes-list"]', noteItem: '[data-testid="note-item"], [class*="note-item"]', - noteEditor: '.bn-container [contenteditable="true"]', // BlockNote editor container + noteEditor: '[aria-label="Rich text editor"] [contenteditable="true"]', // BlockNote editor input noteTitle: 'textarea[aria-label="Note title"]', // Title textarea noteTags: '[data-testid="note-tags"], [class*="tags-row"]', diff --git a/apps/desktop/tests/e2e/utils/network-control.ts b/apps/desktop/tests/e2e/utils/network-control.ts new file mode 100644 index 000000000..fdab3ce8c --- /dev/null +++ b/apps/desktop/tests/e2e/utils/network-control.ts @@ -0,0 +1,150 @@ +import { expect as playwrightExpect, type ElectronApplication, type Page } from '@playwright/test' + +interface SyncStatusSnapshot { + status: 'idle' | 'syncing' | 'offline' | 'error' + pendingCount: number + offlineSince?: number +} + +interface TriggerSyncResult { + success: boolean + error?: string +} + +interface MemryNetworkTestHooks { + setNetworkOnlineForTests(online: boolean): Promise + getCrdtPendingCount(): Promise +} + +async function setNetworkOnlineForTests( + electronApp: ElectronApplication, + online: boolean +): Promise { + await electronApp.evaluate(async (_context, requestedOnline) => { + const hooks = ( + globalThis as typeof globalThis & { + __memryTestHooks?: MemryNetworkTestHooks + } + ).__memryTestHooks + + if (!hooks) { + throw new Error('Memry test hooks are not registered') + } + + await hooks.setNetworkOnlineForTests(requestedOnline) + }, online) +} + +export async function goOffline(...apps: ElectronApplication[]): Promise { + await Promise.all(apps.map((app) => setNetworkOnlineForTests(app, false))) +} + +export async function goOnline(...apps: ElectronApplication[]): Promise { + await Promise.all(apps.map((app) => setNetworkOnlineForTests(app, true))) +} + +export async function readCrdtPendingCount(electronApp: ElectronApplication): Promise { + return electronApp.evaluate(async () => { + const hooks = ( + globalThis as typeof globalThis & { + __memryTestHooks?: MemryNetworkTestHooks + } + ).__memryTestHooks + + if (!hooks) { + throw new Error('Memry test hooks are not registered') + } + + return hooks.getCrdtPendingCount() + }) +} + +export async function readSyncStatus(page: Page): Promise { + return page.evaluate(() => window.api.syncOps.getStatus()) +} + +export async function triggerSync(page: Page): Promise { + return page.evaluate(() => window.api.syncOps.triggerSync()) +} + +export async function waitForSyncOffline(page: Page, timeout = 15000): Promise { + await playwrightExpect + .poll(() => readSyncStatus(page), { timeout }) + .toMatchObject({ status: 'offline' }) + + return readSyncStatus(page) +} + +export async function waitForSyncOnline(page: Page, timeout = 15000): Promise { + await playwrightExpect + .poll(async () => { + const status = await readSyncStatus(page) + return ( + (status.status === 'idle' || status.status === 'syncing') && + status.offlineSince == null + ) + }, { timeout }) + .toBe(true) + + return readSyncStatus(page) +} + +export async function waitForPendingCount( + page: Page, + expectedPendingCount: number, + timeout = 15000 +): Promise { + await playwrightExpect + .poll(async () => { + const status = await readSyncStatus(page) + return status.pendingCount + }, { timeout }) + .toBe(expectedPendingCount) + + return readSyncStatus(page) +} + +export async function waitForSyncIdle(page: Page, timeout = 15000): Promise { + await playwrightExpect + .poll(async () => { + const status = await readSyncStatus(page) + return status.status === 'idle' && status.pendingCount === 0 && status.offlineSince == null + }, { timeout }) + .toBe(true) + + return readSyncStatus(page) +} + +export async function waitForCrdtQueueIdle( + electronApp: ElectronApplication, + timeout = 15000 +): Promise { + await playwrightExpect + .poll(() => readCrdtPendingCount(electronApp), { timeout }) + .toBe(0) + + return readCrdtPendingCount(electronApp) +} + +export async function syncBothAndWait( + pageA: Page, + pageB: Page, + timeout = 15000 +): Promise<{ statusA: SyncStatusSnapshot; statusB: SyncStatusSnapshot }> { + const [triggerA, triggerB] = await Promise.all([triggerSync(pageA), triggerSync(pageB)]) + + if (!triggerA.success) { + throw new Error(triggerA.error || 'Device A sync trigger failed') + } + + if (!triggerB.success) { + throw new Error(triggerB.error || 'Device B sync trigger failed') + } + + const [statusA, statusB] = await Promise.all([ + waitForSyncIdle(pageA, timeout), + waitForSyncIdle(pageB, timeout) + ]) + + return { statusA, statusB } +} diff --git a/apps/desktop/tests/e2e/utils/note-sync-helpers.ts b/apps/desktop/tests/e2e/utils/note-sync-helpers.ts new file mode 100644 index 000000000..e0c17b2d1 --- /dev/null +++ b/apps/desktop/tests/e2e/utils/note-sync-helpers.ts @@ -0,0 +1,262 @@ +import { expect, type ElectronApplication, type Page } from '@playwright/test' +import { SELECTORS } from './electron-helpers' + +interface MemryNoteTestHooks { + getCrdtDocMarkdown(noteId: string): Promise + getWritebackDebugState(noteId: string): Promise<{ + pending: boolean + scheduledCount: number + performedCount: number + lastMarkdown: string | null + lastError: string | null + } | null> +} + +function normalizeBodyText(text: string): string { + return text.replace(/\r\n/g, '\n').replace(/\u00a0/g, ' ').replace(/\n{3,}/g, '\n\n').trimEnd() +} + +function bodyToParagraphBlocks(body: string) { + return body.split('\n').map((line) => ({ + type: 'paragraph', + content: line.length + ? [ + { + type: 'text', + text: line, + styles: {} + } + ] + : [] + })) +} + +async function openNoteInUi( + page: Page, + note: { id: string; title: string; emoji?: string | null } +): Promise { + await page.evaluate((detail) => { + window.dispatchEvent(new CustomEvent('memry:test-open-note', { detail })) + }, note) + + await expect(page.locator(SELECTORS.noteTitle).first()).toHaveValue(note.title) +} + +async function waitForPersistedNoteBody(page: Page, noteId: string, body: string): Promise { + const requiredSnippets = body.split('\n').filter((line) => line.length > 0) + await expect + .poll(async () => { + const note = await page.evaluate(async (id) => window.api.notes.get(id), noteId) + const content = note?.content ?? '' + return requiredSnippets.every((snippet) => content.includes(snippet)) + }) + .toBe(true) +} + +async function waitForNoteEditor(page: Page) { + const editor = page.locator(SELECTORS.noteEditor).first() + await editor.waitFor({ state: 'visible', timeout: 10000 }) + return editor +} + +export async function createNoteWithBody(page: Page, title: string, body: string): Promise { + const note = await page.evaluate(async (inputTitle) => { + const result = await window.api.notes.create({ title: inputTitle, content: '' }) + if (!result.success || !result.note) { + throw new Error(result.error || `Failed to create note "${inputTitle}"`) + } + return { + id: result.note.id, + title: result.note.title, + emoji: result.note.emoji ?? null + } + }, title) + + await openNoteInUi(page, note) + await replaceNoteBody(page, body) + await waitForPersistedNoteBody(page, note.id, body) +} + +export async function openNoteByTitle(page: Page, title: string): Promise { + const tab = page.locator(SELECTORS.tab).filter({ hasText: title }).first() + if (await tab.isVisible({ timeout: 1000 }).catch(() => false)) { + await tab.click() + await expect(page.locator(SELECTORS.noteTitle).first()).toHaveValue(title) + return + } + + const note = await getNoteHandleByTitle(page, title) + await openNoteInUi(page, note) +} + +export async function getNoteHandleByTitle( + page: Page, + title: string +): Promise<{ id: string; title: string; emoji?: string | null }> { + let note: { id: string; title: string; emoji?: string | null } | null = null + await expect + .poll(async () => { + note = await page.evaluate(async (expectedTitle) => { + const result = await window.api.notes.list({}) + const match = result.notes.find((item) => item.title === expectedTitle) + return match + ? { id: match.id, title: match.title, emoji: match.emoji ?? null } + : null + }, title) + return note !== null + }) + .toBe(true) + + return note! +} + +export async function getNoteFileBodyByTitle(page: Page, title: string): Promise { + const note = await getNoteHandleByTitle(page, title) + const body = await page.evaluate(async (id) => { + const loaded = await window.api.notes.get(id) + return loaded?.content ?? null + }, note.id) + return body === null ? null : normalizeBodyText(body) +} + +export async function getCrdtDocBodyByTitle( + page: Page, + electronApp: ElectronApplication, + title: string +): Promise { + const note = await getNoteHandleByTitle(page, title) + const body = await electronApp.evaluate(async (_context, noteId) => { + const hooks = ( + globalThis as typeof globalThis & { + __memryTestHooks?: MemryNoteTestHooks + } + ).__memryTestHooks + + if (!hooks) { + throw new Error('Memry test hooks are not registered') + } + + return hooks.getCrdtDocMarkdown(noteId) + }, note.id) + + return body === null ? null : normalizeBodyText(body) +} + +export async function getWritebackDebugByTitle( + page: Page, + electronApp: ElectronApplication, + title: string +): Promise<{ + pending: boolean + scheduledCount: number + performedCount: number + lastMarkdown: string | null + lastError: string | null +} | null> { + const note = await getNoteHandleByTitle(page, title) + const state = await electronApp.evaluate(async (_context, noteId) => { + const hooks = ( + globalThis as typeof globalThis & { + __memryTestHooks?: MemryNoteTestHooks + } + ).__memryTestHooks + + if (!hooks) { + throw new Error('Memry test hooks are not registered') + } + + return hooks.getWritebackDebugState(noteId) + }, note.id) + + if (!state) return null + + return { + ...state, + lastMarkdown: state.lastMarkdown === null ? null : normalizeBodyText(state.lastMarkdown) + } +} + +export async function replaceNoteBody(page: Page, body: string): Promise { + await waitForNoteEditor(page) + const nextBlocks = bodyToParagraphBlocks(body) + await page.evaluate((blocks) => { + const editor = (window as unknown as { __memryEditor?: any }).__memryEditor + if (!editor) throw new Error('window.__memryEditor not exposed') + editor.replaceBlocks(editor.document, blocks) + }, nextBlocks) +} + +export async function appendToNoteBody(page: Page, text: string): Promise { + const editorRoot = await waitForNoteEditor(page) + const hasContent = await page.evaluate(() => { + const editor = (window as unknown as { __memryEditor?: any }).__memryEditor + if (!editor) throw new Error('window.__memryEditor not exposed') + + const doc = editor.document as any[] + editor.focus() + if (doc.length > 0) { + const lastBlock = doc[doc.length - 1] + if (lastBlock?.id) { + editor.setTextCursorPosition(lastBlock.id, 'end') + } + } + return doc.length > 0 + }) + + await editorRoot.click() + if (hasContent) { + await page.keyboard.press('End') + await page.keyboard.press('Enter') + await page.keyboard.press('Enter') + } + await page.keyboard.type(text) +} + +export async function readNoteBodyText(page: Page): Promise { + await waitForNoteEditor(page) + const text = await page.evaluate(() => { + const editor = (window as unknown as { __memryEditor?: any }).__memryEditor + if (!editor) throw new Error('window.__memryEditor not exposed') + + const readInlineContent = (content: any[] | undefined): string => { + if (!Array.isArray(content)) return '' + return content + .map((item) => { + if (typeof item?.text === 'string') return item.text + if (Array.isArray(item?.content)) return readInlineContent(item.content) + return '' + }) + .join('') + } + + const readBlocks = (blocks: any[]): string[] => { + const parts: string[] = [] + for (const block of blocks) { + const ownText = readInlineContent(block?.content) + const childText = Array.isArray(block?.children) ? readBlocks(block.children).join('\n\n') : '' + const blockText = ownText && childText ? `${ownText}\n\n${childText}` : ownText || childText + parts.push(blockText) + } + return parts + } + + return readBlocks(editor.document as any[]).join('\n\n') + }) + return normalizeBodyText(text) +} + +export async function expectNoteBody(page: Page, expectedBody: string): Promise { + await expect.poll(() => readNoteBodyText(page)).toBe(normalizeBodyText(expectedBody)) +} + +export async function expectNoteBodiesEqual( + pageA: Page, + pageB: Page, + expectedBody: string +): Promise { + const normalizedExpectedBody = normalizeBodyText(expectedBody) + await expect.poll(() => Promise.all([readNoteBodyText(pageA), readNoteBodyText(pageB)])).toEqual([ + normalizedExpectedBody, + normalizedExpectedBody + ]) +} diff --git a/apps/desktop/tests/e2e/utils/sync-backend.ts b/apps/desktop/tests/e2e/utils/sync-backend.ts new file mode 100644 index 000000000..60c14b902 --- /dev/null +++ b/apps/desktop/tests/e2e/utils/sync-backend.ts @@ -0,0 +1,123 @@ +import sodium from 'libsodium-wrappers-sumo' +import { SignJWT } from 'jose' +import { ARGON2_PARAMS } from '@memry/contracts/crypto' + +const KEY_VERIFIER_CONTEXT = 'memrykve' +const KEY_VERIFIER_ID = 4 +const MASTER_KEY_LENGTH = 32 + +interface TestSyncServer { + start(): Promise + stop(): Promise + getD1(): Promise + getDirectUrl(): Promise + getKeys(): { privateKey: CryptoKey; privateKeyPem: string } +} + +interface SyncTestDeviceBootstrap { + email: string + setupToken: string + masterKeyBase64: string + signingSecretKeyBase64: string + kdfSalt: string + keyVerifier: string + skipSetup: boolean +} + +export interface SharedSyncBootstrap { + server: TestSyncServer + serverUrl: string + email: string + deviceA: SyncTestDeviceBootstrap + deviceB: SyncTestDeviceBootstrap +} + +function toBase64(bytes: Uint8Array): string { + return sodium.to_base64(bytes, sodium.base64_variants.ORIGINAL) +} + +async function signSetupToken(userId: string, privateKey: CryptoKey): Promise { + return new SignJWT({ + sub: userId, + type: 'setup', + jti: crypto.randomUUID() + }) + .setProtectedHeader({ alg: 'EdDSA' }) + .setIssuedAt() + .setIssuer('memry-sync') + .setAudience('memry-client') + .setExpirationTime('5m') + .sign(privateKey) +} + +async function createSharedUser(server: TestSyncServer, email: string): Promise { + const db = await server.getD1() + const userId = crypto.randomUUID() + const now = Math.floor(Date.now() / 1000) + + await db + .prepare( + `INSERT INTO users (id, email, email_verified, auth_method, storage_used, storage_limit, created_at, updated_at) + VALUES (?, ?, 1, 'otp', 0, 5368709120, ?, ?)` + ) + .bind(userId, email, now, now) + .run() + + await db + .prepare('INSERT INTO server_cursor_sequence (user_id, current_cursor) VALUES (?, 0)') + .bind(userId) + .run() + + return userId +} + +export async function startSharedSyncBootstrap(): Promise { + await sodium.ready + + const { SimulatedServer } = await import('../../../../../tests/sync-harness/src/simulated-server.ts') + + const server = new SimulatedServer() + await server.start() + + const serverUrl = (await server.getDirectUrl()).toString().replace(/\/$/, '') + const email = `e2e-${crypto.randomUUID()}@memry.local` + const userId = await createSharedUser(server, email) + const masterKey = sodium.randombytes_buf(MASTER_KEY_LENGTH) + const kdfSaltBytes = sodium.randombytes_buf(ARGON2_PARAMS.SALT_LENGTH) + const keyVerifierBytes = sodium.crypto_kdf_derive_from_key( + MASTER_KEY_LENGTH, + KEY_VERIFIER_ID, + KEY_VERIFIER_CONTEXT, + masterKey + ) + const keyVerifier = toBase64(keyVerifierBytes) + const kdfSalt = toBase64(kdfSaltBytes) + const setupTokenA = await signSetupToken(userId, server.getKeys().privateKey) + const setupTokenB = await signSetupToken(userId, server.getKeys().privateKey) + const signingKeyPairA = sodium.crypto_sign_keypair() + const signingKeyPairB = sodium.crypto_sign_keypair() + + return { + server, + serverUrl, + email, + deviceA: { + email, + setupToken: setupTokenA, + masterKeyBase64: toBase64(masterKey), + signingSecretKeyBase64: toBase64(signingKeyPairA.privateKey), + kdfSalt, + keyVerifier, + skipSetup: false + }, + deviceB: { + email, + setupToken: setupTokenB, + masterKeyBase64: toBase64(masterKey), + signingSecretKeyBase64: toBase64(signingKeyPairB.privateKey), + kdfSalt, + keyVerifier, + skipSetup: true + } + } +} diff --git a/apps/sync-server/.dev.vars.example b/apps/sync-server/.dev.vars.example index a66dca22e..e513b0173 100644 --- a/apps/sync-server/.dev.vars.example +++ b/apps/sync-server/.dev.vars.example @@ -1,6 +1,9 @@ +# Store PEM values on a single line with literal \n escapes. JWT_PUBLIC_KEY=replace-with-real-ed25519-public-key-pem JWT_PRIVATE_KEY=replace-with-real-ed25519-private-key-pem RESEND_API_KEY=re_replace_with_real_key GOOGLE_CLIENT_ID=replace-with-real-google-client-id.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=replace-with-real-google-client-secret +GOOGLE_REDIRECT_URI=replace-with-registered-google-redirect-uri +OTP_HMAC_KEY=replace-with-random-secret-for-otp-hmac RECOVERY_DUMMY_SECRET=replace-with-random-secret-for-dummy-recovery diff --git a/apps/sync-server/src/services/crdt.test.ts b/apps/sync-server/src/services/crdt.test.ts new file mode 100644 index 000000000..42c66219f --- /dev/null +++ b/apps/sync-server/src/services/crdt.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from 'vitest' + +import { + getSnapshot, + getUpdates, + pruneUpdatesBeforeSnapshot, + storeSnapshot, + storeUpdates +} from './crdt' + +interface FakeUpdateRow { + id: string + user_id: string + note_id: string + update_data: ArrayBuffer + sequence_num: number + signer_device_id: string + created_at: number +} + +interface FakeSnapshotRow { + id: string + user_id: string + note_id: string + blob_key: string + sequence_num: number + size_bytes: number + signer_device_id: string + created_at: number +} + +function createD1Database(): D1Database { + const updates: FakeUpdateRow[] = [] + const snapshots = new Map() + + const snapshotKey = (userId: string, noteId: string): string => `${userId}:${noteId}` + const getUpdateMax = (userId: string, noteId: string): number => + updates + .filter((row) => row.user_id === userId && row.note_id === noteId) + .reduce((max, row) => Math.max(max, row.sequence_num), 0) + const getSnapshotMax = (userId: string, noteId: string): number => + snapshots.get(snapshotKey(userId, noteId))?.sequence_num ?? 0 + const getCombinedMax = (userId: string, noteId: string): number => + Math.max(getUpdateMax(userId, noteId), getSnapshotMax(userId, noteId)) + + return { + prepare(sql: string) { + let params: unknown[] = [] + + const prepared = { + bind(...nextParams: unknown[]) { + params = nextParams + return prepared + }, + async first() { + if (sql.startsWith('INSERT INTO crdt_updates')) { + const nextSequence = + sql.includes('crdt_snapshots') && sql.includes('UNION ALL') + ? getCombinedMax(params[6] as string, params[7] as string) + 1 + : getUpdateMax(params[6] as string, params[7] as string) + 1 + + updates.push({ + id: params[0] as string, + user_id: params[1] as string, + note_id: params[2] as string, + update_data: params[3] as ArrayBuffer, + sequence_num: nextSequence, + signer_device_id: params[4] as string, + created_at: params[5] as number + }) + + return { sequence_num: nextSequence } as T + } + + if (sql.startsWith('SELECT COALESCE(MAX(sequence_num), 0) as max_seq')) { + const maxSeq = + sql.includes('crdt_snapshots') && sql.includes('UNION ALL') + ? getCombinedMax(params[0] as string, params[1] as string) + : getUpdateMax(params[0] as string, params[1] as string) + return { max_seq: maxSeq } as T + } + + if (sql.startsWith('SELECT sequence_num FROM crdt_snapshots')) { + const row = snapshots.get(snapshotKey(params[0] as string, params[1] as string)) + if (!row) return null + return { sequence_num: row.sequence_num } as T + } + + if (sql.startsWith('SELECT blob_key, sequence_num, signer_device_id FROM crdt_snapshots')) { + const row = snapshots.get(snapshotKey(params[0] as string, params[1] as string)) + if (!row) return null + return { + blob_key: row.blob_key, + sequence_num: row.sequence_num, + signer_device_id: row.signer_device_id + } as T + } + + return null + }, + async all() { + if ( + sql.startsWith( + 'SELECT id, user_id, note_id, update_data, sequence_num, signer_device_id, created_at FROM crdt_updates' + ) + ) { + const rows = updates + .filter( + (row) => + row.user_id === params[0] && + row.note_id === params[1] && + row.sequence_num > (params[2] as number) + ) + .sort((a, b) => a.sequence_num - b.sequence_num) + .slice(0, params[3] as number) + + return { results: rows as T[] } + } + + return { results: [] as T[] } + }, + async run() { + if (sql.startsWith('INSERT INTO crdt_snapshots')) { + const row: FakeSnapshotRow = { + id: params[0] as string, + user_id: params[1] as string, + note_id: params[2] as string, + blob_key: params[3] as string, + sequence_num: params[4] as number, + size_bytes: params[5] as number, + signer_device_id: params[6] as string, + created_at: params[7] as number + } + snapshots.set(snapshotKey(row.user_id, row.note_id), row) + return { meta: { changes: 1 } } + } + + if (sql.startsWith('DELETE FROM crdt_updates')) { + const before = updates.length + const remaining = updates.filter( + (row) => + !( + row.user_id === params[0] && + row.note_id === params[1] && + row.sequence_num <= (params[2] as number) + ) + ) + updates.splice(0, updates.length, ...remaining) + return { meta: { changes: before - updates.length } } + } + + return { meta: { changes: 0 } } + } + } + + return prepared as unknown as D1PreparedStatement + } + } as unknown as D1Database +} + +function createMemoryBucket(): R2Bucket { + const objects = new Map() + + return { + async put(key: string, value: ArrayBuffer | ArrayBufferView) { + const bytes = + value instanceof ArrayBuffer + ? new Uint8Array(value) + : new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + objects.set(key, bytes.slice()) + return null as unknown as R2Object + }, + async get(key: string) { + const bytes = objects.get(key) + if (!bytes) return null + return { + async arrayBuffer() { + return bytes.slice().buffer + } + } as unknown as R2ObjectBody + } + } as unknown as R2Bucket +} + +function bytes(value: string): ArrayBuffer { + const encoded = new TextEncoder().encode(value) + const copy = new Uint8Array(encoded.byteLength) + copy.set(encoded) + return copy.buffer +} + +describe('CRDT service sequencing', () => { + it('keeps later offline updates above the existing snapshot watermark', async () => { + const db = createD1Database() + const storage = createMemoryBucket() + + const initialSequences = await storeUpdates(db, 'user-1', 'note-1', 'device-a', [ + bytes('a1'), + bytes('a2') + ]) + expect(initialSequences).toEqual([1, 2]) + + const firstSnapshot = await storeSnapshot( + db, + storage, + 'user-1', + 'note-1', + 'device-a', + bytes('snapshot-a') + ) + expect(firstSnapshot.sequenceNum).toBe(2) + expect(await pruneUpdatesBeforeSnapshot(db, 'user-1', 'note-1')).toBe(2) + + const laterSequences = await storeUpdates(db, 'user-1', 'note-1', 'device-b', [ + bytes('b1'), + bytes('b2') + ]) + expect(laterSequences).toEqual([3, 4]) + + const replacementSnapshot = await storeSnapshot( + db, + storage, + 'user-1', + 'note-1', + 'device-b', + bytes('snapshot-b') + ) + expect(replacementSnapshot.sequenceNum).toBe(2) + expect(await pruneUpdatesBeforeSnapshot(db, 'user-1', 'note-1')).toBe(0) + + const snapshot = await getSnapshot(db, storage, 'user-1', 'note-1') + expect(snapshot?.sequenceNum).toBe(2) + + const pulled = await getUpdates(db, 'user-1', 'note-1', 2, 10) + expect(pulled.updates.map((update) => update.sequence_num)).toEqual([3, 4]) + }) +}) diff --git a/apps/sync-server/src/services/crdt.ts b/apps/sync-server/src/services/crdt.ts index 46411b753..34f3bb39e 100644 --- a/apps/sync-server/src/services/crdt.ts +++ b/apps/sync-server/src/services/crdt.ts @@ -19,6 +19,26 @@ interface CrdtSnapshot { created_at: number } +const getMaxSequenceNumber = async ( + db: D1Database, + userId: string, + noteId: string +): Promise => { + const row = await db + .prepare( + `SELECT COALESCE(MAX(sequence_num), 0) as max_seq + FROM ( + SELECT sequence_num FROM crdt_updates WHERE user_id = ? AND note_id = ? + UNION ALL + SELECT sequence_num FROM crdt_snapshots WHERE user_id = ? AND note_id = ? + )` + ) + .bind(userId, noteId, userId, noteId) + .first<{ max_seq: number | null }>() + + return row?.max_seq ?? 0 +} + export const storeUpdates = async ( db: D1Database, userId: string, @@ -36,10 +56,14 @@ export const storeUpdates = async ( .prepare( `INSERT INTO crdt_updates (id, user_id, note_id, update_data, sequence_num, signer_device_id, created_at) SELECT ?, ?, ?, ?, COALESCE(MAX(sequence_num), 0) + 1, ?, ? - FROM crdt_updates WHERE user_id = ? AND note_id = ? + FROM ( + SELECT sequence_num FROM crdt_updates WHERE user_id = ? AND note_id = ? + UNION ALL + SELECT sequence_num FROM crdt_snapshots WHERE user_id = ? AND note_id = ? + ) RETURNING sequence_num` ) - .bind(id, userId, noteId, update, signerDeviceId, now, userId, noteId) + .bind(id, userId, noteId, update, signerDeviceId, now, userId, noteId, userId, noteId) .first<{ sequence_num: number }>() sequences.push(row!.sequence_num) @@ -111,17 +135,17 @@ export const storeSnapshot = async ( const id = crypto.randomUUID() const now = Math.floor(Date.now() / 1000) const blobKey = `${userId}/crdt/${noteId}/snapshot` - - await storage.put(blobKey, snapshotData) - - const currentSeq = await db - .prepare( - 'SELECT COALESCE(MAX(sequence_num), 0) as max_seq FROM crdt_updates WHERE user_id = ? AND note_id = ?' - ) + const currentSeq = await getMaxSequenceNumber(db, userId, noteId) + const existingSnapshot = await db + .prepare('SELECT sequence_num FROM crdt_snapshots WHERE user_id = ? AND note_id = ?') .bind(userId, noteId) - .first<{ max_seq: number }>() + .first<{ sequence_num: number }>() + // Client-uploaded snapshots do not include causal metadata proving they already + // contain every server update above the prior snapshot watermark. Keep the + // watermark stable once a snapshot exists so later incrementals remain pullable. + const sequenceNum = existingSnapshot?.sequence_num ?? currentSeq - const sequenceNum = currentSeq?.max_seq ?? 0 + await storage.put(blobKey, snapshotData) await db .prepare( diff --git a/docs/superpowers/plans/2026-04-10-body-crdt-sync-e2e-checklist.md b/docs/superpowers/plans/2026-04-10-body-crdt-sync-e2e-checklist.md new file mode 100644 index 000000000..ee6bcbea8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-body-crdt-sync-e2e-checklist.md @@ -0,0 +1,181 @@ +# Body CRDT Sync E2E Checklist + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Verify body-only CRDT sync convergence for two desktop devices. + +**Architecture:** The test suite should exercise two isolated desktop clients connected to the same sync backend and drive them through offline/online transitions. Coverage is limited to note body CRDT behavior, so every case should assert final body convergence and explicitly avoid title, tags, properties, move/rename, attachments, and delete behavior. + +**Tech Stack:** Electron, Playwright, desktop sync runtime, Yjs/CRDT note sync + +--- + +## Scope + +- Note body only +- Exclude title changes +- Exclude tags +- Exclude note properties/frontmatter +- Exclude move/rename +- Exclude attachments +- Exclude delete + +## Conventions + +- `A` = Device A +- `B` = Device B +- `online` = sync available +- `offline` = sync unavailable +- `shared note` = both devices already have the same note + +## Phase 0: Harness Readiness + +- [x] H1: Launch `A` and `B` as truly separate devices with isolated user data and keychain state. +- [x] H2: Sign `A` and `B` into the same account and point both to the same sync backend. +- [x] H3: Add deterministic control for `offline -> online -> offline` transitions. +- [x] H4: Add helpers to create/open a note and assert exact body content on both devices. +- [x] H5: Add helpers to force sync and wait until both devices return to idle. + +### Phase 0A: Minimal Harness Implementation Order + +#### H1: Dual-App Isolation + +**Likely files:** + +- Modify: `apps/desktop/tests/e2e/fixtures.ts` +- Create: `apps/desktop/tests/e2e/fixtures/sync-fixtures.ts` +- Reuse: `apps/desktop/tests/e2e/utils/electron-helpers.ts` + +- [x] H1.1: Add a dedicated dual-device Playwright fixture instead of reusing the current single-app fixture. +- [x] H1.2: Launch one Electron app with `MEMRY_DEVICE=A` and a second with `MEMRY_DEVICE=B`. +- [x] H1.3: Give `A` and `B` separate `--user-data-dir` values. +- [x] H1.4: Give `A` and `B` separate test vault paths. +- [x] H1.5: Expose `electronAppA`, `electronAppB`, `pageA`, `pageB`, `vaultPathA`, and `vaultPathB` from the fixture. +- [x] H1.6: Ensure teardown closes both apps and deletes all temp dirs. +- [x] H1.7: Add a smoke test that launches both apps and confirms they do not share local state. + +#### H2: Shared Sync Bootstrap + +**Likely files:** + +- Create: `apps/desktop/tests/e2e/utils/sync-backend.ts` +- Create: `apps/desktop/tests/e2e/fixtures/sync-auth-fixtures.ts` +- Reuse: `tests/sync-harness/src/simulated-server.ts` +- Reuse: `tests/sync-harness/src/test-auth.ts` + +- [x] H2.1: Decide the bootstrap strategy: do not drive OTP/OAuth in E2E. +- [x] H2.2: Stand up a local sync backend that the real Electron app can reach over HTTP and WebSocket. +- [x] H2.3: Seed one shared user with two registered devices. +- [x] H2.4: Generate device-specific auth/session material for `A` and `B`. +- [x] H2.5: Preload each app's local session state so sync runtime starts automatically. +- [x] H2.6: Point both apps at the same `SYNC_SERVER_URL`. +- [x] H2.7: Add a smoke test that both devices report sync enabled against the same backend. + +#### H3: Deterministic Offline/Online Control + +**Likely files:** + +- Create: `apps/desktop/tests/e2e/utils/network-control.ts` +- Modify if needed: `apps/desktop/tests/e2e/utils/electron-helpers.ts` +- Modify: `apps/desktop/src/main/sync/network.ts` +- Modify: `apps/desktop/src/main/sync/network.test.ts` +- Modify: `apps/desktop/src/main/test-hooks.ts` + +- [x] H3.1: Choose one control mechanism and keep it simple. +- [x] H3.2: Implement a test-only `NetworkMonitor` override in Electron main process for deterministic offline/online transitions. +- [x] H3.3: Add `goOffline(A|B|both)` and `goOnline(A|B|both)` helpers. +- [x] H3.4: Add an assertion helper that waits until the app shows `offline` sync status. +- [x] H3.5: Add an assertion helper that waits until the app leaves `offline` after reconnect. +- [x] H3.6: Add a smoke test for `online -> offline -> online` on both devices. + +#### H4: Note Body Helpers + +**Likely files:** + +- Modify: `apps/desktop/src/renderer/src/App.tsx` +- Modify: `apps/desktop/tests/e2e/utils/electron-helpers.ts` +- Create: `apps/desktop/tests/e2e/utils/note-sync-helpers.ts` +- Create: `apps/desktop/tests/e2e/note-sync-helpers.e2e.ts` + +- [x] H4.1: Add a helper to create a note with a known title and body. +- [x] H4.2: Add a helper to open a note by title on either device. +- [x] H4.3: Add a helper to replace the full body content deterministically. +- [x] H4.4: Add a helper to append text at a deterministic location. +- [x] H4.5: Add a helper to read the rendered editor body text from each device. +- [x] H4.6: Add a helper to assert exact body equality across both devices. +- [x] H4.7: Add a smoke test proving body text can be written and read reliably on both devices. + +#### H5: Force Sync And Idle Waits + +**Likely files:** + +- Modify: `apps/desktop/tests/e2e/utils/network-control.ts` +- Reuse: existing sync IPC surface +- Create: `apps/desktop/tests/e2e/manual-sync-smoke.e2e.ts` + +- [x] H5.1: Add a helper to call sync trigger on a specific device. +- [x] H5.2: Add a helper to poll sync status on a specific device. +- [x] H5.3: Add a helper to wait for `syncing -> idle`. +- [x] H5.4: Add a helper to wait until pending count reaches the expected value. +- [x] H5.5: Add a helper `syncBothAndWait()` for the common case. +- [x] H5.6: Add a smoke test where `A` creates online, `B` syncs, and both return to idle. + +### Phase 0B: Exit Criteria Before Writing Real CRDT Cases + +- [x] X1: Two isolated Electron clients can run in the same Playwright test. +- [x] X2: Both clients can authenticate into the same sync account without UI auth flows. +- [x] X3: Offline/online transitions are deterministic in tests. +- [x] X4: Note body creation, open, edit, read, and equality assertions are stable. +- [x] X5: Manual sync and idle waits are stable enough to remove arbitrary sleep-based timing. +- [x] X6: At least one end-to-end smoke case passes before starting `C1`. + +## Phase 1: Create Propagation + +- [x] C1: `A offline` creates a note with body, then `A online`, then `B syncs`. Assert `B` gets the note and exact body. +- [x] C2: `B offline` creates a note with body, then `B online`, then `A syncs`. Assert `A` gets the note and exact body. +- [x] C3: `A online` creates a note while `B offline`, then `B online`. Assert `B` gets the note and exact body. +- [x] C4: `B online` creates a note while `A offline`, then `A online`. Assert `A` gets the note and exact body. + +## Phase 2: Single-Writer Edit Propagation + +- [x] E1: `A offline` edits an `A-created` shared note, then reconnects. Assert `B` sees the body change. +- [x] E2: `B offline` edits a `B-created` shared note, then reconnects. Assert `A` sees the body change. +- [x] E3: `A offline` edits a `B-created` shared note, then reconnects. Assert `B` sees the body change. +- [x] E4: `B offline` edits an `A-created` shared note, then reconnects. Assert `A` sees the body change. +- [x] E5: `A online` edits a shared note while `B offline`, then `B online`. Assert `B` sees the body change. +- [x] E6: `B online` edits a shared note while `A offline`, then `A online`. Assert `A` sees the body change. + +## Phase 3: Independent Edits On Different Notes + +- [x] D1: `A offline` edits `noteA` while `B offline` edits `noteB`, then both go online. Assert both notes converge on both devices. +- [x] D2: `A online` edits `noteA` while `B offline` edits `noteB`, then `B online`. Assert both notes converge. +- [x] D3: `A offline` edits `noteA` while `B online` edits `noteB`, then `A online`. Assert both notes converge. +- [x] D4: `A online` and `B online` edit different notes concurrently. Assert both notes converge. + +## Phase 4: Concurrent Edits On The Same Note + +- [x] M1: `A offline` and `B offline` edit the same note in different blocks, then reconnect. Assert both edits are preserved. +- [x] M2: `A offline` and `B offline` edit the same note in the same block at different insertion points, then reconnect. Assert merge is preserved. +- [x] M3: `A offline` and `B offline` edit the same exact text range, then reconnect. Assert deterministic merged output and no corruption. +- [x] M4: `A online` and `B offline` edit the same note concurrently. Assert merge is preserved. +- [x] M5: `A offline` and `B online` edit the same note concurrently. Assert merge is preserved. +- [x] M6: `A online` and `B online` edit the same note concurrently. Assert merge is preserved. +- [x] M7: `A` edits `noteB` while `B` also edits `noteB`. Assert merge is preserved. +- [x] M8: `B` edits `noteA` while `A` also edits `noteA`. Assert merge is preserved. + +## Phase 5: Coverage Variants + +- [x] V1: Run all offline/offline merge cases with `A reconnects first`. +- [x] V2: Run all offline/offline merge cases with `B reconnects first`. +- [x] V3: Run all offline/offline merge cases with `A and B reconnect together`. +- [x] V4: Run representative `E*` and `M*` cases with the receiver note already open in the editor. +- [x] V5: Run representative `E*` and `M*` cases with the receiver note closed, then reopened after sync. +- [x] V6: Run the cross-edit user scenario: `A` and `B` each create one note offline, both sync, then both cross-edit both notes. Assert `2 notes` and `4 edits` are preserved on both devices. + +## Done Criteria + +- [x] No body edit is lost. +- [x] Both devices converge to identical final body content. +- [x] No duplicate note creation appears. +- [x] No corrupted editor state appears. +- [x] Sync recovers cleanly from offline states without manual repair. diff --git a/tests/sync-harness/src/simulated-server.ts b/tests/sync-harness/src/simulated-server.ts index b4d57fcd8..109b537fc 100644 --- a/tests/sync-harness/src/simulated-server.ts +++ b/tests/sync-harness/src/simulated-server.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'url' import { buildWorker } from '../scripts/build-worker.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(__dirname, '../../..') const SCHEMA_PATH = path.resolve(__dirname, '../../../apps/sync-server/schema/d1.sql') export interface ServerKeys { @@ -24,6 +25,8 @@ export class SimulatedServer { this.keys = await this.generateJwtKeys() this.mf = new Miniflare({ + rootPath: ROOT, + modulesRoot: ROOT, modules: true, scriptPath: workerPath, compatibilityDate: '2025-01-01', @@ -78,6 +81,11 @@ export class SimulatedServer { return this.mf.getD1Database('DB') } + async getDirectUrl(): Promise { + if (!this.mf) throw new Error('Server not started') + return this.mf.ready + } + getKeys(): ServerKeys { if (!this.keys) throw new Error('Server not started') return this.keys