diff --git a/src/app/collab/yjs-sync.ts b/src/app/collab/yjs-sync.ts index 83f022fe7..c1963d90e 100644 --- a/src/app/collab/yjs-sync.ts +++ b/src/app/collab/yjs-sync.ts @@ -123,6 +123,8 @@ export function createYjsGraphSync({ getYimages, setSuppressYjsEvents }: YjsGraphSyncOptions) { + let pendingPageSwitch: { store: EditorStore; pageId: string } | undefined + function syncNodeToYjs(nodeId: string) { const store = getStore() const ydoc = getYdoc() @@ -250,7 +252,19 @@ export function createYjsGraphSync({ const pages = store.graph.getPages() if (pages.some((page) => page.id === store.state.currentPageId)) return if (pages.length === 0) return - void store.switchPage(pages[0].id) + const pageId = pages[0].id + if (pendingPageSwitch?.store === store && pendingPageSwitch.pageId === pageId) return + const pending = { store, pageId } + pendingPageSwitch = pending + void store + .switchPage(pageId) + .catch((error: unknown) => { + if (error instanceof Error && error.name === 'AbortError') return + logCollabSyncError('Failed to switch to a synced page', error) + }) + .finally(() => { + if (pendingPageSwitch === pending) pendingPageSwitch = undefined + }) } return { syncNodeToYjs, syncAllNodesToYjs, applyYjsToGraph } diff --git a/tests/engine/app/integrations/pi-mcp.test.ts b/tests/engine/app/integrations/pi-mcp.test.ts index 61eecce3c..0840496f1 100644 --- a/tests/engine/app/integrations/pi-mcp.test.ts +++ b/tests/engine/app/integrations/pi-mcp.test.ts @@ -1,11 +1,12 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { replaceMCPConnectionSettings, setMCPConnectionCredential } from '@/app/integrations/mcp' import { buildPiMCPServers } from '@/app/integrations/mcp/pi' -import { installTauriMockWindow } from '#tests/helpers/tauri/mocks' +import { clearTauriMocks, installTauriMockWindow } from '#tests/helpers/tauri/mocks' -installTauriMockWindow() +beforeEach(installTauriMockWindow) +afterEach(clearTauriMocks) describe('Pi MCP configuration', () => { test('adapts the built-in and external MCP servers to Pi native configuration', async () => { diff --git a/tests/engine/collab/yjs-sync.test.ts b/tests/engine/collab/yjs-sync.test.ts index 4e6e7d345..2b8dfa371 100644 --- a/tests/engine/collab/yjs-sync.test.ts +++ b/tests/engine/collab/yjs-sync.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from 'bun:test' +import { describe, test, expect, spyOn } from 'bun:test' import { create as createPRNG } from 'lib0/prng' import * as Y from 'yjs' @@ -127,6 +127,8 @@ function createSyncedStores(options: SyncedStoreOptions = {}) { return peerSuppressGraphSync }, cleanup: () => { + hostStore.preparationController.dispose() + peerStore.preparationController.dispose() disconnectYDocs?.() hostDoc.destroy() peerDoc.destroy() @@ -134,10 +136,13 @@ function createSyncedStores(options: SyncedStoreOptions = {}) { } } -function withSyncedStores(run: (stores: SyncedStores) => void, options: SyncedStoreOptions = {}) { +async function withSyncedStores( + run: (stores: SyncedStores) => void | Promise, + options: SyncedStoreOptions = {} +) { const stores = createSyncedStores(options) try { - run(stores) + await run(stores) } finally { stores.cleanup() } @@ -323,22 +328,52 @@ describe('collab yjs-sync', () => { expect(getNodeOrThrow(peer, rect.id).type).toBe('RECTANGLE') }) - test('syncAllNodesToYjs populates peer graph and current page', () => { - withSyncedStores(({ hostStore, peerStore, hostSync }) => { + test('deduplicates a pending page switch and handles its rejection', async () => { + await withSyncedStores(async ({ hostStore, peerStore, hostSync }) => { + const page = firstPage(hostStore.graph) + const node = hostStore.graph.createNode('RECTANGLE', page.id) + const { promise: pending, reject: rejectSwitch } = Promise.withResolvers() + const switchPage = spyOn(peerStore, 'switchPage').mockReturnValue(pending) + const log = spyOn(console, 'error').mockImplementation(() => undefined) + try { + hostSync.syncAllNodesToYjs() + hostSync.syncNodeToYjs(node.id) + expect(switchPage).toHaveBeenCalledTimes(1) + const error = new Error('Page preparation failed') + rejectSwitch(error) + await pending.catch(() => undefined) + await Promise.resolve() + expect(log).toHaveBeenCalledWith('[Collab] Failed to switch to a synced page:', error) + } finally { + switchPage.mockRestore() + log.mockRestore() + } + }) + }) + + test('syncAllNodesToYjs populates peer graph and current page', async () => { + await withSyncedStores(async ({ hostStore, peerStore, hostSync }) => { const hostPage = firstPage(hostStore.graph) const rect = hostStore.graph.createNode('RECTANGLE', hostPage.id, { width: 80, height: 60 }) + const pageChanged = new Promise((resolve) => { + const off = peerStore.onEditorEvent('page:changed', () => { + off() + resolve() + }) + }) hostSync.syncAllNodesToYjs() expect(peerStore.graph.rootId).toBe(hostStore.graph.rootId) + await pageChanged expect(peerStore.state.currentPageId).toBe(hostPage.id) expect(peerStore.graph.getPages().map((page) => page.id)).toContain(hostPage.id) expect(getNodeOrThrow(peerStore.graph, rect.id).type).toBe('RECTANGLE') }) }) - test('live-created and edited nodes sync in both directions', () => { - withSyncedStores(({ hostStore, peerStore, hostSync, peerSync }) => { + test('live-created and edited nodes sync in both directions', async () => { + await withSyncedStores(({ hostStore, peerStore, hostSync, peerSync }) => { const hostPage = firstPage(hostStore.graph) hostSync.syncAllNodesToYjs() @@ -357,8 +392,8 @@ describe('collab yjs-sync', () => { }) }) - test('unchanged node synchronization emits no Yjs update', () => { - withSyncedStores(({ hostStore, hostSync, hostDoc }) => { + test('unchanged node synchronization emits no Yjs update', async () => { + await withSyncedStores(({ hostStore, hostSync, hostDoc }) => { const hostPage = firstPage(hostStore.graph) const rect = hostStore.graph.createNode('RECTANGLE', hostPage.id, { width: 80, height: 60 }) hostSync.syncNodeToYjs(rect.id) @@ -378,8 +413,8 @@ describe('collab yjs-sync', () => { }) }) - test('repeated drag-like updates stay field-sized and do not echo', () => { - withSyncedStores(({ hostStore, peerStore, hostSync, hostDoc, peerDoc }) => { + test('repeated drag-like updates stay field-sized and do not echo', async () => { + await withSyncedStores(({ hostStore, peerStore, hostSync, hostDoc, peerDoc }) => { const hostPage = firstPage(hostStore.graph) const rect = hostStore.graph.createNode('RECTANGLE', hostPage.id, { width: 80, height: 60 }) hostSync.syncAllNodesToYjs() @@ -412,13 +447,13 @@ describe('collab yjs-sync', () => { }) }) - test('queued concurrent edits converge through the official Yjs test connector', () => { + test('queued concurrent edits converge through the official Yjs test connector', async () => { const connector = new TestConnector(createPRNG(526)) const hostDoc: TestYInstance = connector.createY(1) const peerDoc: TestYInstance = connector.createY(2) connector.syncAll() - withSyncedStores( + await withSyncedStores( ({ hostStore, peerStore, hostSync, peerSync }) => { const hostPage = firstPage(hostStore.graph) const rect = hostStore.graph.createNode('RECTANGLE', hostPage.id, { @@ -448,8 +483,8 @@ describe('collab yjs-sync', () => { ) }) - test('image fills sync image bytes', () => { - withSyncedStores(({ hostStore, peerStore, hostSync }) => { + test('image fills sync image bytes', async () => { + await withSyncedStores(({ hostStore, peerStore, hostSync }) => { const hostPage = firstPage(hostStore.graph) const imageHash = 'image-hash' const imageFill: Fill = { diff --git a/tests/engine/core/editor/text/start-editing-gate.test.ts b/tests/engine/core/editor/text/start-editing-gate.test.ts index 898265e7f..3b467f4cc 100644 --- a/tests/engine/core/editor/text/start-editing-gate.test.ts +++ b/tests/engine/core/editor/text/start-editing-gate.test.ts @@ -20,12 +20,26 @@ describe('startTextEditing — path-text font gate', () => { const editor = createEditor() const page = editor.state.currentPageId const box = { x: 0, y: 0, width: 200, height: 200 } + const textPathData = { + network: { + vertices: [ + { x: 0, y: 100 }, + { x: 200, y: 100 } + ], + segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }], + regions: [] + }, + normalizedSize: { x: 200, y: 200 }, + tValue: 0, + forward: true + } // Path text, font AVAILABLE → editable. const editable = editor.graph.createNode('TEXT', page, { text: 'ab', fontFamily: 'Inter', fontWeight: 400, + textPathData: structuredClone(textPathData), textPathBox: { ...box } }) editor.startTextEditing(editable.id) @@ -37,6 +51,7 @@ describe('startTextEditing — path-text font gate', () => { text: 'ab', fontFamily: 'NoSuchFont', fontWeight: 400, + textPathData: structuredClone(textPathData), textPathBox: { ...box } }) editor.startTextEditing(baked.id) diff --git a/tests/engine/tauri/mock-cleanup.test.ts b/tests/engine/tauri/mock-cleanup.test.ts new file mode 100644 index 000000000..689175d03 --- /dev/null +++ b/tests/engine/tauri/mock-cleanup.test.ts @@ -0,0 +1,16 @@ +import { afterEach, expect, test } from 'bun:test' + +import { clearTauriMocks, installTauriMockWindow, mockTauriIPC } from '#tests/helpers/tauri/mocks' + +afterEach(clearTauriMocks) + +test('clearing IPC mocks removes runtime markers before reinstalling a window', async () => { + await mockTauriIPC(() => null) + expect('__TAURI_INTERNALS__' in window).toBe(true) + + await clearTauriMocks() + installTauriMockWindow() + + expect('__TAURI_INTERNALS__' in window).toBe(false) + expect('__TAURI_EVENT_PLUGIN_INTERNALS__' in window).toBe(false) +}) diff --git a/tests/helpers/tauri/mocks.ts b/tests/helpers/tauri/mocks.ts index eea7cc6be..620992432 100644 --- a/tests/helpers/tauri/mocks.ts +++ b/tests/helpers/tauri/mocks.ts @@ -24,5 +24,7 @@ export async function clearTauriMocks() { if (!('window' in globalThis)) return const { clearMocks } = await import('@tauri-apps/api/mocks') clearMocks() + Reflect.deleteProperty(window, '__TAURI_INTERNALS__') + Reflect.deleteProperty(window, '__TAURI_EVENT_PLUGIN_INTERNALS__') delete (globalThis as typeof globalThis & { window?: unknown }).window } diff --git a/tools/unit-tests/src/shards.ts b/tools/unit-tests/src/shards.ts index 758e810cf..f68323c68 100644 --- a/tools/unit-tests/src/shards.ts +++ b/tools/unit-tests/src/shards.ts @@ -5,10 +5,17 @@ import { fileURLToPath } from 'node:url' const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..') export const UNIT_TEST_GROUPS = { - app: ['tests/engine/acp', 'tests/engine/app', 'tests/engine/cli', 'tests/engine/tauri'], + app: [ + 'tests/engine/acp', + 'tests/engine/app', + 'tests/engine/cli', + 'tests/engine/collab', + 'tests/engine/tauri' + ], dom: ['tests/engine/dom-css', 'tests/engine/color', 'tests/engine/icons', 'tests/engine/pen'], editor: [ 'tests/engine/clipboard', + 'tests/engine/core', 'tests/engine/editor', 'tests/engine/hit-test', 'tests/engine/snap' @@ -16,6 +23,8 @@ export const UNIT_TEST_GROUPS = { fig: ['tests/engine/figma', 'tests/engine/io', 'tests/engine/kiwi'], render: ['tests/engine/geometry', 'tests/engine/layout', 'tests/engine/render'], scene: [ + 'tests/engine/bytes', + 'tests/engine/library', 'tests/engine/lint', 'tests/engine/random', 'tests/engine/scene-graph', diff --git a/tools/unit-tests/tests/shards.test.ts b/tools/unit-tests/tests/shards.test.ts index 385f449e5..5b0e953f5 100644 --- a/tools/unit-tests/tests/shards.test.ts +++ b/tools/unit-tests/tests/shards.test.ts @@ -1,4 +1,5 @@ import { expect, test } from 'bun:test' +import { fileURLToPath } from 'node:url' import { isHeavyUnitTest, @@ -8,6 +9,32 @@ import { unitTestGroupNames } from '../src/shards' +test('every engine test belongs to exactly one shard', async () => { + const discovered = await Array.fromAsync( + new Bun.Glob('tests/engine/**/*.test.ts').scan({ + cwd: fileURLToPath(new URL('../../..', import.meta.url)) + }) + ) + const paths = pathsForUnitTestGroup('all') + const invalidAssignments = discovered.flatMap((file) => { + const owners = paths.filter((path) => file.startsWith(`${path}/`)) + return owners.length === 1 ? [] : [{ file, owners }] + }) + + expect(discovered.length).toBeGreaterThan(0) + expect(invalidAssignments).toEqual([]) + expect((await listUnitTests('all', { includeHeavy: true })).sort()).toEqual(discovered.sort()) +}) + +test('quick and explicit heavy tests partition the full engine suite', async () => { + const quick = await listUnitTests('all') + const heavy = await listHeavyUnitTests() + const all = await listUnitTests('all', { includeHeavy: true }) + + expect(quick.filter((file) => heavy.includes(file))).toEqual([]) + expect([...quick, ...heavy].sort()).toEqual(all) +}) + test('unit test groups cover all declared shards', () => { expect(unitTestGroupNames()).toContain('all') expect(pathsForUnitTestGroup('dom')).toContain('tests/engine/dom-css')