From e074ae912fca15ffb1c8316cba93bc69fac88349 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:49:14 -0700 Subject: [PATCH 1/2] fix: allow empty project-set creation while sync is offline Fixes #405 --- hub-client/e2e/project-set-offline.spec.ts | 71 ++++++++ .../src/hooks/useCollectionSets.test.tsx | 152 ++++++++++++++++ hub-client/src/hooks/useCollectionSets.ts | 2 +- .../src/services/projectSetService.test.ts | 171 ++++++++++++++++++ hub-client/src/services/projectSetService.ts | 98 ++++++---- 5 files changed, 460 insertions(+), 34 deletions(-) create mode 100644 hub-client/e2e/project-set-offline.spec.ts create mode 100644 hub-client/src/hooks/useCollectionSets.test.tsx create mode 100644 hub-client/src/services/projectSetService.test.ts diff --git a/hub-client/e2e/project-set-offline.spec.ts b/hub-client/e2e/project-set-offline.spec.ts new file mode 100644 index 000000000..7bb0bfcd6 --- /dev/null +++ b/hub-client/e2e/project-set-offline.spec.ts @@ -0,0 +1,71 @@ +/** + * E2E regression for #405: a fresh browser can create its empty personal + * project set while the configured sync WebSocket is unavailable. The empty + * root must survive a reload from IndexedDB and retain the configured URL so + * the reconnecting adapter can sync it when that server becomes reachable. + */ + +import { expect, test } from '@playwright/test'; +import { seedUiVariant } from './helpers/projectFactory'; +import type {} from './helpers/testHooks'; + +const OFFLINE_SYNC_SERVER = 'ws://127.0.0.1:65534/offline-project-set'; + +test('fresh setup creates and reloads an empty personal root while sync is offline', async ({ + page, +}) => { + await page.route('/auth/me', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: '{"error":"unauthorized"}', + }), + ); + await seedUiVariant(page, 'collections'); + await page.goto('/'); + + await expect( + page.getByText(/Get started by creating a new project set/i), + ).toBeVisible(); + await page.locator('#setup-sync-server').fill(OFFLINE_SYNC_SERVER); + await page.getByRole('button', { name: /Create New Project Set/i }).click(); + + await expect(page.getByPlaceholder('Search projects…')).toBeVisible({ + timeout: 5000, + }); + + const firstRoot = await page.evaluate(async () => { + await window.__quartoTestReady; + const root = window.__quartoTest?.projectSet.listCollections()[0]; + return ( + root && { + docId: root.docId, + syncServer: root.syncServer, + entries: root.entries, + } + ); + }); + expect(firstRoot).toEqual({ + docId: expect.any(String), + syncServer: OFFLINE_SYNC_SERVER, + entries: [], + }); + + await page.reload(); + await expect(page.getByPlaceholder('Search projects…')).toBeVisible({ + timeout: 5000, + }); + + const reloadedRoot = await page.evaluate(async () => { + await window.__quartoTestReady; + const root = window.__quartoTest?.projectSet.listCollections()[0]; + return ( + root && { + docId: root.docId, + syncServer: root.syncServer, + entries: root.entries, + } + ); + }); + expect(reloadedRoot).toEqual(firstRoot); +}); diff --git a/hub-client/src/hooks/useCollectionSets.test.tsx b/hub-client/src/hooks/useCollectionSets.test.tsx new file mode 100644 index 000000000..1e04f31c9 --- /dev/null +++ b/hub-client/src/hooks/useCollectionSets.test.tsx @@ -0,0 +1,152 @@ +/** + * @vitest-environment jsdom + */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../services/projectSetStorage', () => ({ + getCollectionPointers: vi.fn(), + addCollectionPointer: vi.fn(), + removeCollectionPointer: vi.fn(), + setProjectSetPointer: vi.fn(), +})); + +vi.mock('../services/projectStorage', () => ({ + listProjects: vi.fn(), +})); + +vi.mock('../services/projectSetReconciler', () => ({ + reconcileIntoConnectedProjectSet: vi.fn(async () => 0), +})); + +vi.mock('../services/projectSetService', () => ({ + setProjectSetHandlers: vi.fn(), + connectCollections: vi.fn(), + connectCollection: vi.fn(), + createProjectSet: vi.fn(), + createCollection: vi.fn(), + listCollections: vi.fn(), + listProjects: vi.fn(() => []), + addProjectsBulk: vi.fn(), + renameCollection: vi.fn(), + addProject: vi.fn(), + removeProjectFromCollection: vi.fn(), + updateProjectDescriptionEverywhere: vi.fn(), + updateProjectSummaryEverywhere: vi.fn(), + touchProjectEverywhere: vi.fn(), + getProjectSetDocId: vi.fn(), + disconnectCollection: vi.fn(), + addProjectToCollection: vi.fn(), + moveProjectBetweenCollections: vi.fn(), +})); + +import { useCollectionSets } from './useCollectionSets'; +import * as projectSetStorage from '../services/projectSetStorage'; +import * as projectStorage from '../services/projectStorage'; +import * as projectSetService from '../services/projectSetService'; + +const mockGetCollectionPointers = vi.mocked( + projectSetStorage.getCollectionPointers, +); +const mockAddCollectionPointer = vi.mocked( + projectSetStorage.addCollectionPointer, +); +const mockSetProjectSetPointer = vi.mocked( + projectSetStorage.setProjectSetPointer, +); +const mockListLegacyProjects = vi.mocked(projectStorage.listProjects); +const mockCreateProjectSet = vi.mocked(projectSetService.createProjectSet); +const mockCreateCollection = vi.mocked(projectSetService.createCollection); +const mockListCollections = vi.mocked(projectSetService.listCollections); + +const rootSnapshot = { + docId: 'offline-root-doc', + syncServer: 'wss://offline.example/ws', + name: 'My projects', + entries: [], + isRoot: true, +}; + +describe('useCollectionSets setup creation policy', () => { + beforeEach(() => { + vi.clearAllMocks(); + const localValues = new Map(); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + get length() { + return localValues.size; + }, + clear: () => localValues.clear(), + getItem: (key: string) => localValues.get(key) ?? null, + key: (index: number) => [...localValues.keys()][index] ?? null, + removeItem: (key: string) => { + localValues.delete(key); + }, + setItem: (key: string, value: string) => { + localValues.set(key, value); + }, + } satisfies Storage, + }); + mockGetCollectionPointers.mockResolvedValue([]); + mockListLegacyProjects.mockResolvedValue([]); + mockCreateProjectSet.mockResolvedValue('offline-root-doc'); + mockListCollections.mockReturnValue([rootSnapshot]); + }); + + it('uses the offline-capable personal-root wrapper and stores both configured-server pointers', async () => { + const { result } = renderHook(() => useCollectionSets()); + await waitFor(() => expect(result.current[0].status).toBe('needs-setup')); + + await act(async () => { + await result.current[1].createProjectSet('wss://offline.example/ws'); + }); + + expect(mockCreateProjectSet).toHaveBeenCalledWith( + 'wss://offline.example/ws', + 'My projects', + ); + expect(mockCreateCollection).not.toHaveBeenCalled(); + expect(mockAddCollectionPointer).toHaveBeenCalledWith({ + projectSetDocId: 'offline-root-doc', + syncServer: 'wss://offline.example/ws', + }); + expect(mockSetProjectSetPointer).toHaveBeenCalledWith( + 'offline-root-doc', + 'wss://offline.example/ws', + ); + expect(result.current[0].status).toBe('connected'); + expect(result.current[1].getSyncServer()).toBe('wss://offline.example/ws'); + }); + + it('keeps migration on server-gated shared collection creation', async () => { + const legacyProject = { + id: 'legacy-1', + indexDocId: 'legacy-index', + syncServer: 'wss://projects.example/ws', + description: 'Legacy project', + createdAt: '2026-07-01T00:00:00.000Z', + lastAccessed: '2026-07-01T00:00:00.000Z', + }; + mockListLegacyProjects.mockResolvedValue([legacyProject]); + mockCreateCollection.mockRejectedValue(new Error('server offline')); + + const { result } = renderHook(() => useCollectionSets()); + await waitFor(() => + expect(result.current[0].status).toBe('needs-migration'), + ); + + await act(async () => { + await result.current[1].migrateProjects('wss://offline.example/ws'); + }); + + expect(mockCreateCollection).toHaveBeenCalledWith( + 'wss://offline.example/ws', + 'My projects', + ); + expect(mockCreateProjectSet).not.toHaveBeenCalled(); + expect(result.current[0].status).toBe('needs-migration'); + expect(result.current[0].error).toContain('server offline'); + }); +}); diff --git a/hub-client/src/hooks/useCollectionSets.ts b/hub-client/src/hooks/useCollectionSets.ts index 517374f36..f0cae6a40 100644 --- a/hub-client/src/hooks/useCollectionSets.ts +++ b/hub-client/src/hooks/useCollectionSets.ts @@ -255,7 +255,7 @@ export function useCollectionSets(): [CollectionSetsState, CollectionSetsActions setStatus('connecting'); setError(null); try { - const docId = await projectSetService.createCollection(syncServer, DEFAULT_ROOT_NAME); + const docId = await projectSetService.createProjectSet(syncServer, DEFAULT_ROOT_NAME); await establishRoot(docId, syncServer); await migrateLocalCollections(syncServer); setCollections(projectSetService.listCollections()); diff --git a/hub-client/src/services/projectSetService.test.ts b/hub-client/src/services/projectSetService.test.ts new file mode 100644 index 000000000..638f53832 --- /dev/null +++ b/hub-client/src/services/projectSetService.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + class FakeHandle { + documentId = 'offline-root-doc'; + currentDoc: Record | undefined = { + projects: {}, + version: 1, + }; + doc = vi.fn(() => this.currentDoc); + whenReady = vi.fn(async () => {}); + on = vi.fn(); + off = vi.fn(); + change = vi.fn(); + } + + class FakeRepo { + static instances: FakeRepo[] = []; + static flushError: Error | undefined; + static importError: Error | undefined; + + handle = new FakeHandle(); + flush = vi.fn(async () => {}); + import = vi.fn(() => { + if (FakeRepo.importError) throw FakeRepo.importError; + return this.handle; + }); + find = vi.fn(async () => this.handle); + peerHandler: (() => void) | undefined; + networkSubsystem = { + on: vi.fn((_event: string, handler: () => void) => { + this.peerHandler = handler; + }), + }; + + constructor() { + if (FakeRepo.flushError) { + this.flush.mockRejectedValue(FakeRepo.flushError); + } + FakeRepo.instances.push(this); + } + } + + class FakeWebSocketAdapter { + disconnect = vi.fn(); + } + + return { + FakeRepo, + FakeWebSocketAdapter, + automergeFrom: vi.fn((initial: Record) => initial), + automergeSerialize: vi.fn(() => new Uint8Array([1, 2, 3])), + }; +}); + +vi.mock('@automerge/automerge-repo', () => ({ + Repo: mocks.FakeRepo, +})); + +vi.mock('@automerge/automerge-repo-network-websocket', () => ({ + BrowserWebSocketClientAdapter: mocks.FakeWebSocketAdapter, +})); + +vi.mock('@automerge/automerge-repo-storage-indexeddb', () => ({ + IndexedDBStorageAdapter: class {}, +})); + +vi.mock('@automerge/automerge', () => ({ + from: mocks.automergeFrom, + save: mocks.automergeSerialize, +})); + +import * as projectSetService from './projectSetService'; + +describe('projectSetService creation policy', () => { + beforeEach(async () => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.FakeRepo.instances.length = 0; + mocks.FakeRepo.flushError = undefined; + mocks.FakeRepo.importError = undefined; + await projectSetService.disconnect(); + }); + + afterEach(async () => { + await projectSetService.disconnect(); + vi.useRealTimers(); + }); + + it('creates and flushes a personal root without waiting for a server peer', async () => { + const onConnectionChange = vi.fn(); + projectSetService.setProjectSetHandlers({ onConnectionChange }); + const created = projectSetService.createProjectSet( + 'wss://offline.example/ws', + 'My projects', + ); + + await expect(created).resolves.toBe('offline-root-doc'); + const repo = mocks.FakeRepo.instances[0]; + expect(repo.flush).toHaveBeenCalledWith(['offline-root-doc']); + expect(mocks.automergeFrom).toHaveBeenCalledWith( + expect.objectContaining({ name: 'My projects', projects: {} }), + ); + expect(onConnectionChange).toHaveBeenLastCalledWith(false); + + repo.peerHandler?.(); + await Promise.resolve(); + expect(onConnectionChange).toHaveBeenLastCalledWith(true); + }); + + it('surfaces a local storage flush failure for a personal root', async () => { + const error = new Error('IndexedDB write failed'); + mocks.FakeRepo.flushError = error; + const creation = projectSetService.createProjectSet( + 'wss://offline.example/ws', + ); + + await expect(creation).rejects.toThrow('IndexedDB write failed'); + expect(projectSetService.isConnected()).toBe(false); + }); + + it('surfaces a local document import failure for a personal root', async () => { + mocks.FakeRepo.importError = new Error('Automerge import failed'); + + await expect( + projectSetService.createProjectSet('wss://offline.example/ws'), + ).rejects.toThrow('Automerge import failed'); + expect(projectSetService.isConnected()).toBe(false); + }); + + it('keeps ordinary shared collection creation server-gated', async () => { + const creation = projectSetService.createCollection( + 'wss://offline.example/ws', + 'Shared collection', + ); + + let settled = false; + void creation.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await Promise.resolve(); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(10000); + await expect(creation).rejects.toThrow( + 'Could not reach sync server. Please check your connection and try again.', + ); + expect(mocks.FakeRepo.instances[0].import).not.toHaveBeenCalled(); + }); + + it('still rejects an uncached existing collection while offline', async () => { + const connection = projectSetService.connectCollection({ + projectSetDocId: 'uncached-doc', + syncServer: 'wss://offline.example/ws', + }); + const repo = mocks.FakeRepo.instances[0]; + repo.handle.currentDoc = undefined; + + await Promise.resolve(); + const rejection = expect(connection).rejects.toThrow( + 'Collection not found in local storage. Connect online first to sync.', + ); + await vi.advanceTimersByTimeAsync(5000); + await rejection; + }); +}); diff --git a/hub-client/src/services/projectSetService.ts b/hub-client/src/services/projectSetService.ts index 5acfe5c09..d752eb353 100644 --- a/hub-client/src/services/projectSetService.ts +++ b/hub-client/src/services/projectSetService.ts @@ -88,6 +88,8 @@ interface ServerConnection { ready: Promise; } +type CollectionCreationPolicy = 'server-required' | 'local-first'; + // ============================================================================ // Internal State // ============================================================================ @@ -289,45 +291,72 @@ export async function connectCollections( return { connected, failed }; } +async function createCollectionDocument( + syncServerUrl: string, + name: string | undefined, + policy: CollectionCreationPolicy, +): Promise { + const server = acquireServer(syncServerUrl); + try { + if (policy === 'server-required') { + // Shared collections require a peer so callers know the document has + // reached the configured server before they publish its pointer. + if (!(await awaitServerReady(server, 10000))) { + throw new Error( + 'Could not reach sync server. Please check your connection and try again.', + ); + } + onConnectionChange?.(true); + } else { + // A fresh personal root is useful while offline. The websocket adapter + // keeps reconnecting, and the Repo will announce this handle when its + // first peer eventually arrives. + onConnectionChange?.(false); + void server.ready.then(() => onConnectionChange?.(true)); + } + + const initial = { + projects: {}, + version: CURRENT_PROJECT_SET_SCHEMA_VERSION, + ...(name !== undefined ? { name } : {}), + } as Record; + const doc = automergeFrom(initial); + const handle = server.repo.import(automergeSerialize(doc)); + + if (policy === 'local-first') { + // Repo saves are normally debounced. Setup must not publish pointers + // until the empty root is durably available for an offline reload. + await server.repo.flush([handle.documentId]); + } + + const onChange = () => notifyChange(); + handle.on('change', onChange); + const conn: CollectionConnection = { + docId: handle.documentId, + syncServer: syncServerUrl, + handle, + cleanup: () => handle.off('change', onChange), + }; + connections.set(conn.docId, conn); + notifyChange(); + return handle.documentId; + } catch (err) { + releaseServer(syncServerUrl); + throw err; + } +} + /** - * Create a new collection document on a sync server. + * Create a new shared collection document on a sync server. * * @returns The document ID of the new ProjectSetDocument. * @throws If the sync server is unreachable. */ -export async function createCollection( +export function createCollection( syncServerUrl: string, name?: string, ): Promise { - const server = acquireServer(syncServerUrl); - // Creation requires the server so the document actually syncs. - if (!(await awaitServerReady(server, 10000))) { - releaseServer(syncServerUrl); - throw new Error( - 'Could not reach sync server. Please check your connection and try again.', - ); - } - onConnectionChange?.(true); - - const initial = { - projects: {}, - version: CURRENT_PROJECT_SET_SCHEMA_VERSION, - ...(name !== undefined ? { name } : {}), - } as Record; - const doc = automergeFrom(initial); - const handle = server.repo.import(automergeSerialize(doc)); - - const onChange = () => notifyChange(); - handle.on('change', onChange); - const conn: CollectionConnection = { - docId: handle.documentId, - syncServer: syncServerUrl, - handle, - cleanup: () => handle.off('change', onChange), - }; - connections.set(conn.docId, conn); - notifyChange(); - return handle.documentId; + return createCollectionDocument(syncServerUrl, name, 'server-required'); } /** @@ -511,9 +540,12 @@ export async function connect( * * @returns The document ID of the newly created ProjectSetDocument. */ -export async function createProjectSet(syncServerUrl: string): Promise { +export async function createProjectSet( + syncServerUrl: string, + name?: string, +): Promise { await disconnect(); - return createCollection(syncServerUrl); + return createCollectionDocument(syncServerUrl, name, 'local-first'); } /** From f1ecaa55c8119c3d4cda56b7b230c1bdf12ff3d8 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Fri, 31 Jul 2026 06:23:32 -0700 Subject: [PATCH 2/2] test: drop e2e spec and collapse unit tests to the offline-root case Keeps the behavior change unchanged and reduces the test surface from three new files to one focused case, so the PR is +116/-34 instead of +460/-34. --- hub-client/e2e/project-set-offline.spec.ts | 71 ------- .../src/hooks/useCollectionSets.test.tsx | 152 -------------- .../src/services/projectSetService.test.ts | 189 ++++-------------- 3 files changed, 34 insertions(+), 378 deletions(-) delete mode 100644 hub-client/e2e/project-set-offline.spec.ts delete mode 100644 hub-client/src/hooks/useCollectionSets.test.tsx diff --git a/hub-client/e2e/project-set-offline.spec.ts b/hub-client/e2e/project-set-offline.spec.ts deleted file mode 100644 index 7bb0bfcd6..000000000 --- a/hub-client/e2e/project-set-offline.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * E2E regression for #405: a fresh browser can create its empty personal - * project set while the configured sync WebSocket is unavailable. The empty - * root must survive a reload from IndexedDB and retain the configured URL so - * the reconnecting adapter can sync it when that server becomes reachable. - */ - -import { expect, test } from '@playwright/test'; -import { seedUiVariant } from './helpers/projectFactory'; -import type {} from './helpers/testHooks'; - -const OFFLINE_SYNC_SERVER = 'ws://127.0.0.1:65534/offline-project-set'; - -test('fresh setup creates and reloads an empty personal root while sync is offline', async ({ - page, -}) => { - await page.route('/auth/me', (route) => - route.fulfill({ - status: 401, - contentType: 'application/json', - body: '{"error":"unauthorized"}', - }), - ); - await seedUiVariant(page, 'collections'); - await page.goto('/'); - - await expect( - page.getByText(/Get started by creating a new project set/i), - ).toBeVisible(); - await page.locator('#setup-sync-server').fill(OFFLINE_SYNC_SERVER); - await page.getByRole('button', { name: /Create New Project Set/i }).click(); - - await expect(page.getByPlaceholder('Search projects…')).toBeVisible({ - timeout: 5000, - }); - - const firstRoot = await page.evaluate(async () => { - await window.__quartoTestReady; - const root = window.__quartoTest?.projectSet.listCollections()[0]; - return ( - root && { - docId: root.docId, - syncServer: root.syncServer, - entries: root.entries, - } - ); - }); - expect(firstRoot).toEqual({ - docId: expect.any(String), - syncServer: OFFLINE_SYNC_SERVER, - entries: [], - }); - - await page.reload(); - await expect(page.getByPlaceholder('Search projects…')).toBeVisible({ - timeout: 5000, - }); - - const reloadedRoot = await page.evaluate(async () => { - await window.__quartoTestReady; - const root = window.__quartoTest?.projectSet.listCollections()[0]; - return ( - root && { - docId: root.docId, - syncServer: root.syncServer, - entries: root.entries, - } - ); - }); - expect(reloadedRoot).toEqual(firstRoot); -}); diff --git a/hub-client/src/hooks/useCollectionSets.test.tsx b/hub-client/src/hooks/useCollectionSets.test.tsx deleted file mode 100644 index 1e04f31c9..000000000 --- a/hub-client/src/hooks/useCollectionSets.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import { act, renderHook, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('../services/projectSetStorage', () => ({ - getCollectionPointers: vi.fn(), - addCollectionPointer: vi.fn(), - removeCollectionPointer: vi.fn(), - setProjectSetPointer: vi.fn(), -})); - -vi.mock('../services/projectStorage', () => ({ - listProjects: vi.fn(), -})); - -vi.mock('../services/projectSetReconciler', () => ({ - reconcileIntoConnectedProjectSet: vi.fn(async () => 0), -})); - -vi.mock('../services/projectSetService', () => ({ - setProjectSetHandlers: vi.fn(), - connectCollections: vi.fn(), - connectCollection: vi.fn(), - createProjectSet: vi.fn(), - createCollection: vi.fn(), - listCollections: vi.fn(), - listProjects: vi.fn(() => []), - addProjectsBulk: vi.fn(), - renameCollection: vi.fn(), - addProject: vi.fn(), - removeProjectFromCollection: vi.fn(), - updateProjectDescriptionEverywhere: vi.fn(), - updateProjectSummaryEverywhere: vi.fn(), - touchProjectEverywhere: vi.fn(), - getProjectSetDocId: vi.fn(), - disconnectCollection: vi.fn(), - addProjectToCollection: vi.fn(), - moveProjectBetweenCollections: vi.fn(), -})); - -import { useCollectionSets } from './useCollectionSets'; -import * as projectSetStorage from '../services/projectSetStorage'; -import * as projectStorage from '../services/projectStorage'; -import * as projectSetService from '../services/projectSetService'; - -const mockGetCollectionPointers = vi.mocked( - projectSetStorage.getCollectionPointers, -); -const mockAddCollectionPointer = vi.mocked( - projectSetStorage.addCollectionPointer, -); -const mockSetProjectSetPointer = vi.mocked( - projectSetStorage.setProjectSetPointer, -); -const mockListLegacyProjects = vi.mocked(projectStorage.listProjects); -const mockCreateProjectSet = vi.mocked(projectSetService.createProjectSet); -const mockCreateCollection = vi.mocked(projectSetService.createCollection); -const mockListCollections = vi.mocked(projectSetService.listCollections); - -const rootSnapshot = { - docId: 'offline-root-doc', - syncServer: 'wss://offline.example/ws', - name: 'My projects', - entries: [], - isRoot: true, -}; - -describe('useCollectionSets setup creation policy', () => { - beforeEach(() => { - vi.clearAllMocks(); - const localValues = new Map(); - Object.defineProperty(globalThis, 'localStorage', { - configurable: true, - value: { - get length() { - return localValues.size; - }, - clear: () => localValues.clear(), - getItem: (key: string) => localValues.get(key) ?? null, - key: (index: number) => [...localValues.keys()][index] ?? null, - removeItem: (key: string) => { - localValues.delete(key); - }, - setItem: (key: string, value: string) => { - localValues.set(key, value); - }, - } satisfies Storage, - }); - mockGetCollectionPointers.mockResolvedValue([]); - mockListLegacyProjects.mockResolvedValue([]); - mockCreateProjectSet.mockResolvedValue('offline-root-doc'); - mockListCollections.mockReturnValue([rootSnapshot]); - }); - - it('uses the offline-capable personal-root wrapper and stores both configured-server pointers', async () => { - const { result } = renderHook(() => useCollectionSets()); - await waitFor(() => expect(result.current[0].status).toBe('needs-setup')); - - await act(async () => { - await result.current[1].createProjectSet('wss://offline.example/ws'); - }); - - expect(mockCreateProjectSet).toHaveBeenCalledWith( - 'wss://offline.example/ws', - 'My projects', - ); - expect(mockCreateCollection).not.toHaveBeenCalled(); - expect(mockAddCollectionPointer).toHaveBeenCalledWith({ - projectSetDocId: 'offline-root-doc', - syncServer: 'wss://offline.example/ws', - }); - expect(mockSetProjectSetPointer).toHaveBeenCalledWith( - 'offline-root-doc', - 'wss://offline.example/ws', - ); - expect(result.current[0].status).toBe('connected'); - expect(result.current[1].getSyncServer()).toBe('wss://offline.example/ws'); - }); - - it('keeps migration on server-gated shared collection creation', async () => { - const legacyProject = { - id: 'legacy-1', - indexDocId: 'legacy-index', - syncServer: 'wss://projects.example/ws', - description: 'Legacy project', - createdAt: '2026-07-01T00:00:00.000Z', - lastAccessed: '2026-07-01T00:00:00.000Z', - }; - mockListLegacyProjects.mockResolvedValue([legacyProject]); - mockCreateCollection.mockRejectedValue(new Error('server offline')); - - const { result } = renderHook(() => useCollectionSets()); - await waitFor(() => - expect(result.current[0].status).toBe('needs-migration'), - ); - - await act(async () => { - await result.current[1].migrateProjects('wss://offline.example/ws'); - }); - - expect(mockCreateCollection).toHaveBeenCalledWith( - 'wss://offline.example/ws', - 'My projects', - ); - expect(mockCreateProjectSet).not.toHaveBeenCalled(); - expect(result.current[0].status).toBe('needs-migration'); - expect(result.current[0].error).toContain('server offline'); - }); -}); diff --git a/hub-client/src/services/projectSetService.test.ts b/hub-client/src/services/projectSetService.test.ts index 638f53832..2da8dbb23 100644 --- a/hub-client/src/services/projectSetService.test.ts +++ b/hub-client/src/services/projectSetService.test.ts @@ -1,171 +1,50 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - +import { expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => { - class FakeHandle { - documentId = 'offline-root-doc'; - currentDoc: Record | undefined = { - projects: {}, - version: 1, - }; - doc = vi.fn(() => this.currentDoc); - whenReady = vi.fn(async () => {}); - on = vi.fn(); - off = vi.fn(); - change = vi.fn(); - } - - class FakeRepo { - static instances: FakeRepo[] = []; - static flushError: Error | undefined; - static importError: Error | undefined; - - handle = new FakeHandle(); - flush = vi.fn(async () => {}); - import = vi.fn(() => { - if (FakeRepo.importError) throw FakeRepo.importError; - return this.handle; - }); - find = vi.fn(async () => this.handle); - peerHandler: (() => void) | undefined; - networkSubsystem = { - on: vi.fn((_event: string, handler: () => void) => { - this.peerHandler = handler; - }), - }; - - constructor() { - if (FakeRepo.flushError) { - this.flush.mockRejectedValue(FakeRepo.flushError); - } - FakeRepo.instances.push(this); - } - } - - class FakeWebSocketAdapter { - disconnect = vi.fn(); - } + const handle = { + documentId: 'offline-root-doc', + doc: vi.fn(() => ({ projects: {}, version: 1 })), + on: vi.fn(), + off: vi.fn(), + }; + const repo = { + import: vi.fn(() => handle), + flush: vi.fn(async () => {}), + networkSubsystem: { on: vi.fn() }, + }; return { - FakeRepo, - FakeWebSocketAdapter, - automergeFrom: vi.fn((initial: Record) => initial), - automergeSerialize: vi.fn(() => new Uint8Array([1, 2, 3])), + repo, + from: vi.fn((document: Record) => document), + save: vi.fn(() => new Uint8Array([1, 2, 3])), }; }); - vi.mock('@automerge/automerge-repo', () => ({ - Repo: mocks.FakeRepo, + Repo: class { + import = mocks.repo.import; + flush = mocks.repo.flush; + networkSubsystem = mocks.repo.networkSubsystem; + }, })); - vi.mock('@automerge/automerge-repo-network-websocket', () => ({ - BrowserWebSocketClientAdapter: mocks.FakeWebSocketAdapter, + BrowserWebSocketClientAdapter: class { disconnect = vi.fn(); }, })); - vi.mock('@automerge/automerge-repo-storage-indexeddb', () => ({ IndexedDBStorageAdapter: class {}, })); - vi.mock('@automerge/automerge', () => ({ - from: mocks.automergeFrom, - save: mocks.automergeSerialize, + from: mocks.from, + save: mocks.save, })); - -import * as projectSetService from './projectSetService'; - -describe('projectSetService creation policy', () => { - beforeEach(async () => { - vi.useFakeTimers(); - vi.clearAllMocks(); - mocks.FakeRepo.instances.length = 0; - mocks.FakeRepo.flushError = undefined; - mocks.FakeRepo.importError = undefined; - await projectSetService.disconnect(); - }); - - afterEach(async () => { - await projectSetService.disconnect(); - vi.useRealTimers(); - }); - - it('creates and flushes a personal root without waiting for a server peer', async () => { - const onConnectionChange = vi.fn(); - projectSetService.setProjectSetHandlers({ onConnectionChange }); - const created = projectSetService.createProjectSet( - 'wss://offline.example/ws', - 'My projects', - ); - - await expect(created).resolves.toBe('offline-root-doc'); - const repo = mocks.FakeRepo.instances[0]; - expect(repo.flush).toHaveBeenCalledWith(['offline-root-doc']); - expect(mocks.automergeFrom).toHaveBeenCalledWith( - expect.objectContaining({ name: 'My projects', projects: {} }), - ); - expect(onConnectionChange).toHaveBeenLastCalledWith(false); - - repo.peerHandler?.(); - await Promise.resolve(); - expect(onConnectionChange).toHaveBeenLastCalledWith(true); - }); - - it('surfaces a local storage flush failure for a personal root', async () => { - const error = new Error('IndexedDB write failed'); - mocks.FakeRepo.flushError = error; - const creation = projectSetService.createProjectSet( - 'wss://offline.example/ws', - ); - - await expect(creation).rejects.toThrow('IndexedDB write failed'); - expect(projectSetService.isConnected()).toBe(false); - }); - - it('surfaces a local document import failure for a personal root', async () => { - mocks.FakeRepo.importError = new Error('Automerge import failed'); - - await expect( - projectSetService.createProjectSet('wss://offline.example/ws'), - ).rejects.toThrow('Automerge import failed'); - expect(projectSetService.isConnected()).toBe(false); - }); - - it('keeps ordinary shared collection creation server-gated', async () => { - const creation = projectSetService.createCollection( - 'wss://offline.example/ws', - 'Shared collection', - ); - - let settled = false; - void creation.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); - await Promise.resolve(); - expect(settled).toBe(false); - - await vi.advanceTimersByTimeAsync(10000); - await expect(creation).rejects.toThrow( - 'Could not reach sync server. Please check your connection and try again.', - ); - expect(mocks.FakeRepo.instances[0].import).not.toHaveBeenCalled(); - }); - - it('still rejects an uncached existing collection while offline', async () => { - const connection = projectSetService.connectCollection({ - projectSetDocId: 'uncached-doc', - syncServer: 'wss://offline.example/ws', - }); - const repo = mocks.FakeRepo.instances[0]; - repo.handle.currentDoc = undefined; - - await Promise.resolve(); - const rejection = expect(connection).rejects.toThrow( - 'Collection not found in local storage. Connect online first to sync.', - ); - await vi.advanceTimersByTimeAsync(5000); - await rejection; +import { createProjectSet } from './projectSetService'; +it('imports an empty personal root locally while sync is offline', async () => { + await expect( + createProjectSet('wss://offline.example/ws', 'My projects'), + ).resolves.toBe('offline-root-doc'); + expect(mocks.from).toHaveBeenCalledWith({ + projects: {}, + version: 1, + name: 'My projects', }); + expect(mocks.repo.import).toHaveBeenCalledWith(new Uint8Array([1, 2, 3])); + expect(mocks.repo.flush).toHaveBeenCalledWith(['offline-root-doc']); });