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:
parent
5ab13032ec
commit
076f7ce0d9
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
16
tests/engine/tauri/mock-cleanup.test.ts
Normal file
16
tests/engine/tauri/mock-cleanup.test.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue