fix: restore engine test coverage and isolation (#653)

* ci: require complete engine test shard coverage

* test: provide path data in the font editing gate fixture

* test: remove stale Tauri runtime markers during cleanup

* fix(collab): deduplicate pending page switches

* test: simplify pending page switch regression setup
This commit is contained in:
Danila Poyarkov 2026-09-06 23:29:45 +03:00 committed by GitHub
parent 5ab13032ec
commit 076f7ce0d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 139 additions and 20 deletions

View file

@ -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 }

View file

@ -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 () => {

View file

@ -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<void>,
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<undefined>()
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<void>((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 = {

View file

@ -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)

View file

@ -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)
})

View file

@ -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
}

View file

@ -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',

View file

@ -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')