test: migrate Scene Graph tests into the package and gate new tests/engine files (#790)

Scene Graph unit tests move to packages/scene-graph/tests with a package-local assert helper and test type-checking; five Core- and fig-owned tests move to their owners' engine homes. check:test-homes rejects any new test under tests/engine against a reviewed baseline so the migration debt only shrinks.
This commit is contained in:
Danila Poyarkov 2026-09-30 04:53:28 +04:00 committed by GitHub
parent 1b8db8985b
commit 7b9f506f4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 911 additions and 69 deletions

View file

@ -101,7 +101,7 @@ jobs:
run: bun run typecheck
- name: Enforce architecture and type-shape boundaries
run: bun run check:arch && bun run test:type-shapes
run: bun run check:arch && bun run check:test-homes && bun run test:type-shapes
package-quality:
name: Package integrity

View file

@ -479,6 +479,12 @@
"@open-pencil/vue": "workspace:*",
},
},
"tools/unit-tests": {
"name": "@open-pencil/tool-unit-tests",
"dependencies": {
"@open-pencil/package-artifacts": "workspace:*",
},
},
"tools/visual-oracles": {
"name": "@open-pencil/visual-oracle-tools",
"dependencies": {
@ -1370,6 +1376,8 @@
"@open-pencil/tauri-menu-tools": ["@open-pencil/tauri-menu-tools@workspace:tools/tauri-menu"],
"@open-pencil/tool-unit-tests": ["@open-pencil/tool-unit-tests@workspace:tools/unit-tests"],
"@open-pencil/visual-oracle-tools": ["@open-pencil/visual-oracle-tools@workspace:tools/visual-oracles"],
"@open-pencil/vue": ["@open-pencil/vue@workspace:packages/vue"],

View file

@ -22,6 +22,7 @@
"tools/authoring-reference",
"tools/package-quality",
"tools/release-packages",
"tools/unit-tests",
"tools/tauri-menu",
"tools/visual-oracles"
],
@ -42,7 +43,7 @@
"lint:structure": "oxlint -c oxlint.json commitlint.config.ts vite.config.ts vite/ .storybook/ src/ packages/scene-graph/src/ packages/scene-graph/scripts/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/harness/src/ packages/harness/tests/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/pen/src/ packages/pen/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ packages/fig/src/ packages/fig/tests/ packages/fig/scripts/ tests/ scripts/ tools/",
"format": "oxfmt --write .oxfmtrc.json commitlint.config.ts vite.config.ts vite/ .storybook/ src/ packages/scene-graph/src/ packages/scene-graph/scripts/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/harness/src/ packages/harness/tests/ packages/vue/src/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/pen/src/ packages/pen/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ packages/fig/src/ packages/fig/tests/ packages/fig/scripts/ tests scripts/ tools/",
"format:check": "bun run format && status=$(git status --porcelain -uall) && test -z \"$status\" || (echo \"$status\" && exit 1)",
"check": "bun run check:icons && bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:native-test && bun run check:i18n && bun run check:docs && bun run check:changelog && bun run check:packages && bun run check:deps && bun run check:audit && bun run check:secrets && bun run check:monorepo && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
"check": "bun run check:icons && bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:native-test && bun run check:i18n && bun run check:docs && bun run check:changelog && bun run check:packages && bun run check:deps && bun run check:audit && bun run check:secrets && bun run check:monorepo && bun run check:arch && bun run check:test-homes && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
"check:commits": "commitlint",
"check:changelog": "bun tools/release-packages/src/check-changelog.ts",
"check:deps": "knip --include unlisted,unresolved,binaries",
@ -56,6 +57,7 @@
"check:i18n": "bun tools/i18n/src/check-locales.ts",
"check:packages": "bun tools/package-quality/src/cli.ts check",
"check:arch": "steiger .",
"check:test-homes": "bun tools/unit-tests/src/check-homes.ts",
"check:vue": "vue-tsc --noEmit -p tsconfig.json && vue-tsc --noEmit -p packages/vue/tsconfig.json",
"test": "OPENPENCIL_TEST_SERVER=app playwright test --project=openpencil --grep-invert @real-llm",
"test:storybook": "OPENPENCIL_TEST_SERVER=storybook playwright test --project=storybook-chromium",

View file

@ -20,6 +20,8 @@ Core tests using SceneGraph still belong to Core. Central integration is for con
**Migration status:** much existing coverage remains under `tests/engine/**`, alongside package-local suites. The table is the agreed destination, not a claim that migration is complete. Until a domain migrates, extend its existing suite rather than create a second home. The repository-wide migration is separate from feature work.
`bun run check:test-homes` keeps the debt shrinking: every test under `tests/engine` must be listed in `tools/unit-tests/engine-baseline.txt`, so a new test fails the check until it lives in its owner's canonical home, and a moved file must be removed from the baseline. Scene Graph tests have migrated to `packages/scene-graph/tests`.
Discovery already covers the destinations: `tools/unit-tests/src/shards.ts` groups tests by owner and lists each owner's canonical home (`packages/<owner>/tests`, `tests/app`, `tests/integration`) together with the `tests/engine` directories it still owns, so `bun run test:unit` and the CI shards run a file from either place. Moving a domain is therefore a `git mv` plus import fixes; only a new owner or a new top-level home needs a shard entry. Do not move files into a directory the shard map does not list.
`test:unit:quick` runs files in Bun worker processes (`--parallel`), which is the fastest local loop. CI shards still run each group in one process; `test:unit:isolated` (`--isolate`, also run nightly) gives every file a fresh global object and is the check to run when a suite passes alone but fails in a shard. Shards share one Bun process, so module-level state (a `fake-indexeddb/auto` import, an IndexedDB connection left open by `createEditorStore()`, a patched global) leaks into later files in the same shard. A package-local run (`bun test tests` inside the package) is a fresh process and will not reproduce that leak. Close what a test opens and restore what it patches.

View file

@ -192,10 +192,11 @@
},
"scripts": {
"build": "bunx tsdown --config tsdown.config.ts",
"check": "bun run typecheck && bun run build && bun run smoke:dist",
"check": "bun run typecheck && bun run test && bun run build && bun run smoke:dist",
"prepack": "bun run build",
"smoke:dist": "bun scripts/smoke-dist.ts",
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
"test": "bun test tests",
"typecheck": "bunx tsc --noEmit -p tsconfig.test.json"
},
"dependencies": {
"culori": "^4.0.2",

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { SceneGraph } from '@open-pencil/scene-graph'
import { pageId, rect } from './helpers'

View file

@ -1,16 +1,15 @@
import { describe, expect, test } from 'bun:test'
import { generateId, SceneGraph } from '@open-pencil/core'
import { expectDefined } from '#tests/helpers/assert'
import { generateId, SceneGraph } from '@open-pencil/scene-graph'
import { expectDefined, getNodeOrThrow } from '../helpers/assert'
import { pageId, rect } from './helpers'
describe('SceneGraph', () => {
test('create rectangle', () => {
const graph = new SceneGraph()
const id = rect(graph, 'Rect', 100, 100, 200, 150)
const node = graph.getNode(id)
const node = getNodeOrThrow(graph, id)
expect(node).toBeDefined()
expect(expectDefined(node, 'node').type).toBe('RECTANGLE')
expect(node.x).toBe(100)
@ -124,7 +123,7 @@ describe('SceneGraph', () => {
const page = pageId(graph)
const comp = graph.createNode('COMPONENT', page, { name: 'Comp', width: 100, height: 40 })
graph.createNode('RECTANGLE', comp.id, { name: 'BG', width: 100, height: 40 })
const inst = graph.createInstance(comp.id, page)
const inst = expectDefined(graph.createInstance(comp.id, page), 'instance')
expect(inst).toBeDefined()
expect(graph.getInstances(comp.id)).toHaveLength(1)
@ -159,12 +158,12 @@ describe('SceneGraph', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const r = rect(graph, 'R', 100, 200)
const node = graph.getNode(r)
const node = getNodeOrThrow(graph, r)
expect(node).toBeDefined()
expect(expectDefined(node, 'node').parentId).toBe(page)
graph.reparentNode(r, page)
// Position should not change
const after = graph.getNode(r)
const after = getNodeOrThrow(graph, r)
expect(after).toBeDefined()
expect(expectDefined(after, 'updated node').x).toBe(100)
expect(expectDefined(after, 'updated node').y).toBe(200)
@ -189,7 +188,7 @@ describe('SceneGraph', () => {
graph.reparentNode(r, frame)
// Now node is inside frame at (200,100), so local coords should be (100, 50)
const after = graph.getNode(r)
const after = getNodeOrThrow(graph, r)
expect(after).toBeDefined()
expect(expectDefined(after, 'updated node').x).toBe(100)
expect(after.y).toBe(50)
@ -258,7 +257,7 @@ describe('SceneGraph', () => {
graph.reparentNode(inner, frame)
// Inner was at (100,100), frame is at (50,50), so inner's local = (50,50)
const innerNode = graph.getNode(inner)
const innerNode = getNodeOrThrow(graph, inner)
expect(innerNode).toBeDefined()
expect(expectDefined(innerNode, 'inner node').x).toBe(50)
expect(innerNode.y).toBe(50)
@ -339,7 +338,7 @@ describe('SceneGraph', () => {
const graph = new SceneGraph()
const id = rect(graph, 'R')
graph.updateNode(id, { x: 200, name: 'Updated' })
const node = graph.getNode(id)
const node = getNodeOrThrow(graph, id)
expect(node).toBeDefined()
expect(expectDefined(node, 'node').x).toBe(200)
expect(node.name).toBe('Updated')
@ -353,7 +352,7 @@ describe('SceneGraph', () => {
height: 40
})
const child = graph.createNode('RECTANGLE', comp.id, { name: 'BG', width: 100, height: 40 })
const instance = graph.createInstance(comp.id, pageId(graph))
const instance = expectDefined(graph.createInstance(comp.id, pageId(graph)), 'instance')
expect(instance).toBeDefined()
expect(expectDefined(instance, 'instance').type).toBe('INSTANCE')
expect(instance.componentId).toBe(comp.id)
@ -371,7 +370,7 @@ describe('SceneGraph', () => {
height: 100
})
const label = graph.createNode('TEXT', comp.id, { name: 'Title', text: 'Hello', fontSize: 14 })
const instance = graph.createInstance(comp.id, pageId(graph))
const instance = expectDefined(graph.createInstance(comp.id, pageId(graph)), 'instance')
expect(instance).toBeDefined()
const instLabel = graph.getChildren(expectDefined(instance, 'instance').id)[0]
expect(instLabel.text).toBe('Hello')
@ -391,7 +390,7 @@ describe('SceneGraph', () => {
height: 100
})
graph.createNode('TEXT', comp.id, { name: 'Title', text: 'Default', fontSize: 14 })
const instance = graph.createInstance(comp.id, pageId(graph))
const instance = expectDefined(graph.createInstance(comp.id, pageId(graph)), 'instance')
expect(instance).toBeDefined()
const instLabel = graph.getChildren(expectDefined(instance, 'instance').id)[0]
@ -421,7 +420,7 @@ describe('SceneGraph', () => {
height: 100
})
graph.createNode('RECTANGLE', comp.id, { name: 'BG' })
const instance = graph.createInstance(comp.id, pageId(graph))
const instance = expectDefined(graph.createInstance(comp.id, pageId(graph)), 'instance')
expect(instance).toBeDefined()
expect(graph.getChildren(expectDefined(instance, 'instance').id)).toHaveLength(1)
@ -442,7 +441,7 @@ describe('SceneGraph', () => {
height: 40
})
graph.createNode('RECTANGLE', comp.id, { name: 'BG' })
const instance = graph.createInstance(comp.id, pageId(graph))
const instance = expectDefined(graph.createInstance(comp.id, pageId(graph)), 'instance')
expect(instance).toBeDefined()
expect(expectDefined(instance, 'instance').type).toBe('INSTANCE')

View file

@ -1,4 +1,4 @@
import type { SceneGraph } from '@open-pencil/core'
import type { SceneGraph } from '@open-pencil/scene-graph'
export function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}

View file

@ -1,7 +1,8 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { SceneGraph } from '@open-pencil/scene-graph'
import { expectDefined } from '../helpers/assert'
import { pageId, rect } from './helpers'
describe('hitTest', () => {
@ -114,7 +115,10 @@ describe('hitTest', () => {
width: 30,
height: 30
})
const instId = graph.createInstance(compId, pageId(graph), { x: 300, y: 0 }).id
const instId = expectDefined(
graph.createInstance(compId, pageId(graph), { x: 300, y: 0 }),
'instance'
).id
// Hit on instance's child area — returns instance (opaque container)
expect(graph.hitTest(320, 20, pageId(graph))?.id).toBe(instId)
// Miss on instance's empty area (no fills)

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { SceneGraph } from '@open-pencil/scene-graph'
describe('LAYOUT_AFFECTING_KEYS membership', () => {
test('contains all direct transform properties', () => {

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { SceneGraph } from '@open-pencil/scene-graph'
describe('text rendering invalidation keys', () => {
test('TEXT_PICTURE_KEYS contains rendered-picture properties', () => {

View file

@ -1,9 +1,8 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { expectDefined } from '#tests/helpers/assert'
import { SceneGraph } from '@open-pencil/scene-graph'
import { expectDefined } from '../helpers/assert'
import { pageId, rect } from './helpers'
describe('updateNode', () => {
@ -49,7 +48,7 @@ describe('updateNode', () => {
width: 50,
height: 50
})
const inst = graph.createInstance(comp1.id, page, { x: 200, y: 0 })
const inst = expectDefined(graph.createInstance(comp1.id, page, { x: 200, y: 0 }), 'instance')
// Instance should be indexed under comp1
expect(graph.instanceIndex.get(comp1.id)?.has(inst.id)).toBe(true)
// Re-point instance to comp2

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { SceneGraph } from '@open-pencil/scene-graph'
describe('Variables', () => {
function pageId(graph: SceneGraph): string {

View file

@ -1,9 +1,11 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph, type SceneNode } from '@open-pencil/core'
import { SceneGraph, type SceneNode } from '@open-pencil/scene-graph'
import { getInstanceOverride, setInstanceOverride } from '@open-pencil/scene-graph'
import { cloneNodeProps } from '@open-pencil/scene-graph/copy'
import { expectDefined, getNodeOrThrow } from '../helpers/assert'
function pageId(graph: SceneGraph): string {
return graph.getPages()[0].id
}
@ -22,9 +24,9 @@ describe('cloneNodeProps deep-copies vectorNetwork', () => {
regions: [{ windingRule: 'NONZERO', loops: [[0, 1]] }]
}
})
const original = graph.getNode(node.id)
const clone = graph.cloneTree(node.id, pageId(graph))
const clonedNode = graph.getNode(clone.id)
const original = getNodeOrThrow(graph, node.id)
const clone = expectDefined(graph.cloneTree(node.id, pageId(graph)), 'clone')
const clonedNode = getNodeOrThrow(graph, clone.id)
expect(clonedNode.vectorNetwork).not.toBe(original.vectorNetwork)
if (original.vectorNetwork && clonedNode.vectorNetwork) {
@ -47,9 +49,9 @@ describe('cloneNodeProps deep-copies vectorNetwork', () => {
regions: []
}
})
const original = graph.getNode(node.id)
const clone = graph.cloneTree(node.id, pageId(graph))
const clonedNode = graph.getNode(clone.id)
const original = getNodeOrThrow(graph, node.id)
const clone = expectDefined(graph.cloneTree(node.id, pageId(graph)), 'clone')
const clonedNode = getNodeOrThrow(graph, clone.id)
if (clonedNode.vectorNetwork) clonedNode.vectorNetwork.vertices[0].x = 999
expect(original.vectorNetwork?.vertices[0].x).toBe(0)
@ -66,9 +68,9 @@ describe('cloneNodeProps deep-copies textPicture and derivedTextGlyphs', () => {
fontFamily: 'Inter',
textPicture: new Uint8Array([1, 2, 3, 4])
})
const original = graph.getNode(node.id)
const clone = graph.cloneTree(node.id, pageId(graph))
const clonedNode = graph.getNode(clone.id)
const original = getNodeOrThrow(graph, node.id)
const clone = expectDefined(graph.cloneTree(node.id, pageId(graph)), 'clone')
const clonedNode = getNodeOrThrow(graph, clone.id)
expect(clonedNode.textPicture).not.toBe(original.textPicture)
if (original.textPicture && clonedNode.textPicture) {
@ -89,9 +91,9 @@ describe('cloneNodeProps deep-copies textPicture and derivedTextGlyphs', () => {
{ commandsBlob: new Uint8Array([30, 40]), x: 10, y: 0, fontSize: 14 }
]
})
const original = graph.getNode(node.id)
const clone = graph.cloneTree(node.id, pageId(graph))
const clonedNode = graph.getNode(clone.id)
const original = getNodeOrThrow(graph, node.id)
const clone = expectDefined(graph.cloneTree(node.id, pageId(graph)), 'clone')
const clonedNode = getNodeOrThrow(graph, clone.id)
expect(clonedNode.derivedTextGlyphs).not.toBe(original.derivedTextGlyphs)
if (original.derivedTextGlyphs && clonedNode.derivedTextGlyphs) {
@ -117,16 +119,16 @@ describe('cloneNodeProps deep-copies overrides values', () => {
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
const instance = graph.createInstance(component.id, page)
const instance = expectDefined(graph.createInstance(component.id, page), 'instance')
if (!instance) throw new Error('instance failed')
const instanceChild = graph.getChildren(instance.id)[0]
setInstanceOverride(instance.instanceOverrides, instance.id, instanceChild.id, 'fills', [
{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, visible: true, opacity: 1 }
])
const clone = graph.cloneTree(instance.id, page)
const clone = expectDefined(graph.cloneTree(instance.id, page), 'clone')
if (!clone) throw new Error('clone failed')
const clonedInstance = graph.getNode(clone.id)
const clonedInstance = getNodeOrThrow(graph, clone.id)
const cloneOverrideVal = getInstanceOverride(
clonedInstance.instanceOverrides,
clonedInstance.id,
@ -159,6 +161,7 @@ describe('cloneNodeProps fig import mode', () => {
format: 'fig',
id: 'fig-source',
orderKey: 'a',
editedFields: [],
fig: {
rawSize: { x: 100, y: 40 },
rawTransform: null,
@ -187,7 +190,7 @@ describe('cloneNodeProps coverage guard', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const node = graph.createNode('RECTANGLE', page, { name: 'Rect' })
const raw = graph.getNode(node.id)
const raw = getNodeOrThrow(graph, node.id)
const patch: Partial<
Pick<
SceneNode,
@ -224,6 +227,7 @@ describe('cloneNodeProps coverage guard', () => {
format: 'fig',
id: 'source-node',
orderKey: 'a',
editedFields: [],
fig: {
rawSize: { x: 1, y: 2 },
rawTransform: null,

View file

@ -1,6 +1,8 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { SceneGraph } from '@open-pencil/scene-graph'
import { expectDefined, getNodeOrThrow } from '../helpers/assert'
describe('SceneGraph.cloneTree', () => {
test('clone clears source.id from the clone', () => {
@ -15,11 +17,11 @@ describe('SceneGraph.cloneTree', () => {
graph.updateNode(rect.id, {
source: { ...rect.source, id: '1:42', orderKey: '!', format: 'fig' }
})
const original = graph.getNode(rect.id)
const original = getNodeOrThrow(graph, rect.id)
expect(original).toBeDefined()
expect(original.source.id).toBe('1:42')
const clone = graph.cloneTree(rect.id, page.id)
const clone = expectDefined(graph.cloneTree(rect.id, page.id), 'clone')
expect(clone).not.toBeNull()
// Clone must NOT carry the original's Figma GUID
expect(clone.source.id).toBeNull()
@ -40,9 +42,9 @@ describe('SceneGraph.cloneTree', () => {
source: { ...rect.source, id: '1:99', format: 'fig' }
})
const clone1 = graph.cloneTree(rect.id, page.id)
const clone1 = expectDefined(graph.cloneTree(rect.id, page.id), 'clone')
expect(clone1).not.toBeNull()
const clone2 = graph.cloneTree(clone1.id, page.id)
const clone2 = expectDefined(graph.cloneTree(clone1.id, page.id), 'clone')
expect(clone2).not.toBeNull()
expect(clone2.source.id).toBeNull()
})
@ -62,12 +64,13 @@ describe('SceneGraph.cloneTree', () => {
type: 'SOLID',
color: { r: 1, g: 0, b: 0, a: 1 },
visible: true,
opacity: 1,
blendMode: 'NORMAL' as const
}
]
})
const clone = graph.cloneTree(rect.id, page.id)
const clone = expectDefined(graph.cloneTree(rect.id, page.id), 'clone')
expect(clone).not.toBeNull()
expect(clone.name).toBe('Original')
expect(clone.width).toBe(100)
@ -95,7 +98,7 @@ describe('SceneGraph.cloneTree', () => {
source: { ...child.source, id: '2:11', format: 'fig' }
})
const clone = graph.cloneTree(frame.id, page.id)
const clone = expectDefined(graph.cloneTree(frame.id, page.id), 'clone')
expect(clone).not.toBeNull()
expect(clone.source.id).toBeNull()
const clonedChild = graph.getChildren(clone.id)[0]
@ -129,10 +132,10 @@ describe('SceneGraph.cloneTree', () => {
}
})
const original = graph.getNode(rect.id)
const original = getNodeOrThrow(graph, rect.id)
expect(original.source.fig.rawNodeFields).toEqual({ visible: true, opacity: 1 })
const clone = graph.cloneTree(rect.id, page.id)
const clone = expectDefined(graph.cloneTree(rect.id, page.id), 'clone')
expect(clone).not.toBeNull()
// Mutate the clone's source.fig (simulating what clearEditedSourceMetadata does)

View file

@ -1,6 +1,6 @@
import { describe, test, expect } from 'bun:test'
import type { Fill, Stroke, Effect, StyleRun, GeometryPath } from '@open-pencil/core'
import type { Fill, Stroke, Effect, StyleRun, GeometryPath } from '@open-pencil/scene-graph'
import {
copyFill,
copyFills,
@ -11,7 +11,7 @@ import {
scaleGeometryPaths
} from '@open-pencil/scene-graph/copy'
import { expectDefined } from '#tests/helpers/assert'
import { expectDefined } from './helpers/assert'
describe('copy helpers — mutation isolation', () => {
test('copyFill: mutating copy does not affect original', () => {

View file

@ -0,0 +1,12 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
export function expectDefined<T>(value: T | null | undefined, label = 'value'): NonNullable<T> {
if (value == null) {
throw new Error(`${label} was expected to be defined`)
}
return value
}
export function getNodeOrThrow(graph: SceneGraph, id: string): SceneNode {
return expectDefined(graph.getNode(id), `node ${id}`)
}

View file

@ -10,7 +10,7 @@ import {
hasInstanceOverride,
serializeInstanceOverrideState,
setInstanceOverride
} from './instance-overrides'
} from '../src/instance-overrides'
describe('instance override state', () => {
test('stores self and descendant values structurally', () => {

View file

@ -144,7 +144,7 @@ describe('instance synchronization child deduplication', () => {
const page = graph.addPage('Page')
const comp = graph.createNode('COMPONENT', page.id, { name: 'Menu' })
const _item1 = graph.createNode('FRAME', comp.id, { name: 'Item 1' })
graph.createNode('FRAME', comp.id, { name: 'Item 1' })
const inst = graph.createNode('INSTANCE', page.id, {
name: 'Menu Instance',
@ -156,7 +156,7 @@ describe('instance synchronization child deduplication', () => {
})
// Now add a second child to the component
const _item2 = graph.createNode('FRAME', comp.id, { name: 'Item 2', height: 32 })
graph.createNode('FRAME', comp.id, { name: 'Item 2', height: 32 })
graph.syncInstances(comp.id)
expect(inst.childIds.length).toBe(2)

View file

@ -1,9 +1,8 @@
import { describe, test, expect } from 'bun:test'
import { SceneGraph, UndoManager } from '@open-pencil/core'
import type { JSONObject } from '@open-pencil/scene-graph/primitives'
import { SceneGraph, UndoManager, type SceneNode } from '@open-pencil/scene-graph'
import { getNodeOrThrow } from '#tests/helpers/assert'
import { getNodeOrThrow } from '../helpers/assert'
// ---------------------------------------------------------------------------
// SceneGraph + UndoManager — integration (updateNodeWithUndo pattern)
@ -22,7 +21,7 @@ describe('SceneGraph + UndoManager — updateNode undo integration', () => {
) {
const node = getNodeOrThrow(graph, id)
const previous = Object.fromEntries(
(Object.keys(changes) as string[]).map((k) => [k, (node as JSONObject)[k]])
(Object.keys(changes) as Array<keyof SceneNode>).map((k) => [k, node[k]])
)
graph.updateNode(id, changes)
undo.push({

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { SceneGraph } from '@open-pencil/scene-graph'
import { getInstanceOverride } from '@open-pencil/scene-graph'
function pageId(graph: SceneGraph): string {

View file

@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'
import { mergeVectorNetworks, type VectorNetwork } from '@open-pencil/scene-graph'
import { expectDefined } from '#tests/helpers/assert'
import { expectDefined } from './helpers/assert'
function lineNetwork(x: number): VectorNetwork {
return {

View file

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"emitDeclarationOnly": false
},
"include": ["src", "tests"]
}

View file

@ -5,7 +5,7 @@
## Placement
- Package-local tests mirror source domains under `packages/<owner>/tests/`; central app tests mirror `src/app/**` under `tests/app/`; `tests/integration/` requires a genuinely cross-owner contract; E2E under `tests/e2e/` follows user workflows; native and Figma acceptance are explicit exceptions.
- Existing `tests/engine/**` domains migrate together with runner discovery: `tools/unit-tests/src/shards.ts` lists each owner's canonical home and its current `tests/engine` directories, so a move is a `git mv` plus imports. Do not create competing homes or undiscovered suites.
- Existing `tests/engine/**` domains migrate together with runner discovery: `tools/unit-tests/src/shards.ts` lists each owner's canonical home and its current `tests/engine` directories, so a move is a `git mv` plus imports. `bun run check:test-homes` rejects any test added under `tests/engine` and any stale entry in `tools/unit-tests/engine-baseline.txt`; new tests go to the canonical home, and a moved file is removed from the baseline (`--write` regenerates it). Scene Graph has migrated to `packages/scene-graph/tests`.
- Owner-local helpers and fixtures stay local; only genuinely shared support goes under central `tests/helpers/<domain>/` and `tests/fixtures/`. MCP transport tests: `tests/engine/mcp/{server,stdio,transport}` with `tests/helpers/mcp`.
- Never commit temporary, diagnostic, or profile specs; keep them in ignored `scratch/`.

View file

@ -3,7 +3,7 @@
Private tooling lives under `tools/<domain>/{src,tests}` with kebab-case domains and focused tests; `bun run test:tools` runs every tool package with a `test` script. `scripts/` may contain only tiny compatibility entrypoints.
- Resolve the workspace with `resolveWorkspaceRoot` from `@open-pencil/package-artifacts`, not parent-directory traversal. Use domain aliases for cross-directory tooling imports, including tests; keep sibling imports relative.
- `tools/architecture` (Steiger rules) backs `check:arch`; `tools/docs` backs `check:docs`, including the guide-map check; `tools/i18n`, `tools/release-packages`, `tools/unit-tests`, `tools/ci`, and `tools/brand` own their namesake commands.
- `tools/architecture` (Steiger rules) backs `check:arch`; `tools/docs` backs `check:docs`, including the guide-map check; `tools/unit-tests` backs the unit shards and `check:test-homes` with its `engine-baseline.txt`; `tools/i18n`, `tools/release-packages`, `tools/ci`, and `tools/brand` own their namesake commands.
## CI

View file

@ -0,0 +1,592 @@
# Unit tests still under tests/engine, the migration debt from
# packages/docs/development/testing.md. New tests go to the owner's canonical
# home; remove a line here when its file moves. Regenerate with
# `bun tools/unit-tests/src/check-homes.ts --write`.
tests/engine/acp/permission.test.ts
tests/engine/acp/transport.test.ts
tests/engine/app/ai/attachment/image.test.ts
tests/engine/app/ai/catalog.test.ts
tests/engine/app/ai/context.test.ts
tests/engine/app/ai/failure.test.ts
tests/engine/app/ai/harness/transport.test.ts
tests/engine/app/ai/markdown.test.ts
tests/engine/app/ai/model-roles.test.ts
tests/engine/app/ai/model-settings.test.ts
tests/engine/app/ai/model.test.ts
tests/engine/app/ai/profile-selection.test.ts
tests/engine/app/ai/vision.test.ts
tests/engine/app/automation/child-ready.test.ts
tests/engine/app/automation/mcp-runtime.test.ts
tests/engine/app/automation/mcp-vite-plugin.test.ts
tests/engine/app/automation/portless-route.test.ts
tests/engine/app/cache.test.ts
tests/engine/app/chat/history/continuation.test.ts
tests/engine/app/chat/history/controller.test.ts
tests/engine/app/chat/history/idb.test.ts
tests/engine/app/chat/step-limit.test.ts
tests/engine/app/chat/tool-access.test.ts
tests/engine/app/chat/tool-state.test.ts
tests/engine/app/clipboard/component-dependencies.test.ts
tests/engine/app/clipboard/dependency-bindings.test.ts
tests/engine/app/clipboard/figma-images.test.ts
tests/engine/app/clipboard/html.test.ts
tests/engine/app/clipboard/keyboard.test.ts
tests/engine/app/clipboard/memory.test.ts
tests/engine/app/clipboard/notifications.test.ts
tests/engine/app/clipboard/references.test.ts
tests/engine/app/clipboard/styles.test.ts
tests/engine/app/clipboard/variables.test.ts
tests/engine/app/code/sandbox.test.ts
tests/engine/app/demo.test.ts
tests/engine/app/demo/viewport.test.ts
tests/engine/app/diagnostics/events.test.ts
tests/engine/app/diagnostics/recorder.test.ts
tests/engine/app/diagnostics/settings.test.ts
tests/engine/app/diagnostics/subscriptions.test.ts
tests/engine/app/diagnostics/transport.test.ts
tests/engine/app/document/autosave/create.test.ts
tests/engine/app/document/close/controller.test.ts
tests/engine/app/document/close/exit.test.ts
tests/engine/app/document/close/native.test.ts
tests/engine/app/document/io/browser.test.ts
tests/engine/app/document/io/changes.test.ts
tests/engine/app/document/io/deep-link.test.ts
tests/engine/app/document/io/imported-document.test.ts
tests/engine/app/document/io/pending-open.test.ts
tests/engine/app/document/io/source-identity.test.ts
tests/engine/app/document/io/web-link.test.ts
tests/engine/app/document/recovery/controller.test.ts
tests/engine/app/document/recovery/store.test.ts
tests/engine/app/editor/fonts/browser-fetch.test.ts
tests/engine/app/editor/panes.test.ts
tests/engine/app/editor/preparation/controller.test.ts
tests/engine/app/editor/preparation/multi-tab.test.ts
tests/engine/app/editor/selection/focus.test.ts
tests/engine/app/editor/store-path.test.ts
tests/engine/app/editor/variables.test.ts
tests/engine/app/editor/vectorize/providers.test.ts
tests/engine/app/external-link.test.ts
tests/engine/app/font-loading.test.ts
tests/engine/app/integrations/mcp-connections.test.ts
tests/engine/app/integrations/pi-mcp.test.ts
tests/engine/app/integrations/storage/cancellation.test.ts
tests/engine/app/integrations/storage/cors.test.ts
tests/engine/app/integrations/storage/list-objects.test.ts
tests/engine/app/integrations/storage/namespace.test.ts
tests/engine/app/integrations/storage/region.test.ts
tests/engine/app/integrations/storage/registry.test.ts
tests/engine/app/recent-files/store.test.ts
tests/engine/app/runtime/config.test.ts
tests/engine/app/settings/ai-tool-access.test.ts
tests/engine/app/settings/credential-migration.test.ts
tests/engine/app/settings/credentials.test.ts
tests/engine/app/settings/credentials/access-check.test.ts
tests/engine/app/settings/font.test.ts
tests/engine/app/settings/mcp-tool-access.test.ts
tests/engine/app/settings/mcp-workflow.test.ts
tests/engine/app/settings/model-schema.test.ts
tests/engine/app/settings/motion.test.ts
tests/engine/app/settings/navigation.test.ts
tests/engine/app/settings/storage-workflow.test.ts
tests/engine/app/settings/validation.test.ts
tests/engine/app/settings/vectorize-workflow.test.ts
tests/engine/app/shell/menu/file-batch.test.ts
tests/engine/app/shell/menu/schema.test.ts
tests/engine/app/shell/menu/shortcut.test.ts
tests/engine/app/shell/toast.test.ts
tests/engine/app/storage/cache-eviction.test.ts
tests/engine/app/storage/format-bytes.test.ts
tests/engine/app/storage/local-store.test.ts
tests/engine/app/storage/persist.test.ts
tests/engine/app/storage/reconcile.test.ts
tests/engine/app/storage/sync-outbox.test.ts
tests/engine/app/storage/unique-name.test.ts
tests/engine/app/tabs/open-file-dedup.test.ts
tests/engine/app/tauri/http.test.ts
tests/engine/app/usage/summarize.test.ts
tests/engine/app/vector-edit-transforms.test.ts
tests/engine/cli/app-client.test.ts
tests/engine/cli/eval.test.ts
tests/engine/cli/export/font-policy.test.ts
tests/engine/cli/export/index.test.ts
tests/engine/cli/export/storybook.test.ts
tests/engine/cli/fonts.test.ts
tests/engine/cli/headless.test.ts
tests/engine/cli/import.test.ts
tests/engine/cli/overlaps.test.ts
tests/engine/clipboard/figma/bounds.test.ts
tests/engine/clipboard/figma/codec.test.ts
tests/engine/clipboard/figma/html.test.ts
tests/engine/clipboard/figma/images.test.ts
tests/engine/clipboard/fixtures/gold-preview-roundtrip.test.ts
tests/engine/clipboard/openpencil/green-50.test.ts
tests/engine/clipboard/openpencil/images.test.ts
tests/engine/collab/room-id.test.ts
tests/engine/collab/yjs-sync.test.ts
tests/engine/color/basic.test.ts
tests/engine/color/color-model.test.ts
tests/engine/color/document-space-conversion.test.ts
tests/engine/color/okhcl/basic.test.ts
tests/engine/color/okhcl/document-color-space.test.ts
tests/engine/color/okhcl/toggle-conversion.test.ts
tests/engine/color/okhcl/ui-logic.test.ts
tests/engine/color/solid-commit.test.ts
tests/engine/core/cache/resource.test.ts
tests/engine/core/editor/text/path-edit.test.ts
tests/engine/core/editor/text/start-editing-gate.test.ts
tests/engine/core/text/path-layout.test.ts
tests/engine/dom-css/conversion.test.ts
tests/engine/dom-css/jsx.test.ts
tests/engine/dom-css/runtime.test.ts
tests/engine/dom-css/tailwind.test.ts
tests/engine/editor/clipboard/binding-corruption.test.ts
tests/engine/editor/clipboard/delete.test.ts
tests/engine/editor/clipboard/figma-images.test.ts
tests/engine/editor/clipboard/file-placement.test.ts
tests/engine/editor/clipboard/import-nodes/components.test.ts
tests/engine/editor/clipboard/import-nodes/filtering.test.ts
tests/engine/editor/clipboard/import-nodes/hierarchy.test.ts
tests/engine/editor/clipboard/import-nodes/instance-linkage.test.ts
tests/engine/editor/clipboard/import-nodes/paste-scope.test.ts
tests/engine/editor/clipboard/import-nodes/styling.test.ts
tests/engine/editor/clipboard/import-nodes/text/metrics.test.ts
tests/engine/editor/clipboard/import-nodes/undo/redo.test.ts
tests/engine/editor/clipboard/paste-target.test.ts
tests/engine/editor/clipboard/paste-to-replace.test.ts
tests/engine/editor/components/library-capabilities.test.ts
tests/engine/editor/components/properties.test.ts
tests/engine/editor/components/sync-layout-scope.test.ts
tests/engine/editor/components/variants.test.ts
tests/engine/editor/effects-and-resize.test.ts
tests/engine/editor/frame-presets.test.ts
tests/engine/editor/graph/events-integration.test.ts
tests/engine/editor/graph/events.test.ts
tests/engine/editor/graph/replacement.test.ts
tests/engine/editor/guides.test.ts
tests/engine/editor/history/atomic-tools.test.ts
tests/engine/editor/layout/mutation-impact.test.ts
tests/engine/editor/navigation-lifecycle.test.ts
tests/engine/editor/node-preview.test.ts
tests/engine/editor/nudge.test.ts
tests/engine/editor/opacity.test.ts
tests/engine/editor/pages/loading.test.ts
tests/engine/editor/preview-events.test.ts
tests/engine/editor/selection/alignment.test.ts
tests/engine/editor/selection/distribution.test.ts
tests/engine/editor/selection/flip.test.ts
tests/engine/editor/selection/hit-test.test.ts
tests/engine/editor/selection/properties.test.ts
tests/engine/editor/selection/semantics.test.ts
tests/engine/editor/structure/boolean.test.ts
tests/engine/editor/structure/flatten.test.ts
tests/engine/editor/structure/frame-selection.test.ts
tests/engine/editor/structure/state.test.ts
tests/engine/editor/structure/z-order.test.ts
tests/engine/editor/text-auto-resize.test.ts
tests/engine/editor/undo/basic.test.ts
tests/engine/editor/undo/batch-idle.test.ts
tests/engine/editor/undo/create-instance.test.ts
tests/engine/editor/undo/create-shape.test.ts
tests/engine/editor/undo/group-resize.test.ts
tests/engine/editor/undo/history-events.test.ts
tests/engine/editor/undo/redo-sequence.test.ts
tests/engine/editor/undo/variable-bindings.test.ts
tests/engine/editor/vectorize.test.ts
tests/engine/editor/view-state.test.ts
tests/engine/figma/api/absolute/position.test.ts
tests/engine/figma/api/auto-layout/basic.test.ts
tests/engine/figma/api/auto-layout/extras.test.ts
tests/engine/figma/api/clone.test.ts
tests/engine/figma/api/components.test.ts
tests/engine/figma/api/corner/radius.test.ts
tests/engine/figma/api/create/combine-as-variants.test.ts
tests/engine/figma/api/create/component-from-node.test.ts
tests/engine/figma/api/create/expose-instance-swap.test.ts
tests/engine/figma/api/create/instance-fill-override.test.ts
tests/engine/figma/api/document/pages.test.ts
tests/engine/figma/api/expanded.test.ts
tests/engine/figma/api/find/all-with-criteria.test.ts
tests/engine/figma/api/flatten-renderer.test.ts
tests/engine/figma/api/frozen/arrays.test.ts
tests/engine/figma/api/grouping-compat.test.ts
tests/engine/figma/api/grouping.test.ts
tests/engine/figma/api/images.test.ts
tests/engine/figma/api/internals/not-exposed.test.ts
tests/engine/figma/api/layout/plugin-compat.test.ts
tests/engine/figma/api/layout/sizing.test.ts
tests/engine/figma/api/mask.test.ts
tests/engine/figma/api/min-max/dimensions.test.ts
tests/engine/figma/api/node/creation.test.ts
tests/engine/figma/api/property/access.test.ts
tests/engine/figma/api/selection.test.ts
tests/engine/figma/api/serialization.test.ts
tests/engine/figma/api/stroke/details.test.ts
tests/engine/figma/api/stroke/individual-strokes.test.ts
tests/engine/figma/api/stubs.test.ts
tests/engine/figma/api/text/basic.test.ts
tests/engine/figma/api/text/extras.test.ts
tests/engine/figma/api/traversal.test.ts
tests/engine/figma/api/tree/operations.test.ts
tests/engine/figma/api/variable-binding-extended.test.ts
tests/engine/figma/api/variables.test.ts
tests/engine/figma/api/vector/paths.test.ts
tests/engine/geometry/angles.test.ts
tests/engine/geometry/arrow-caps.test.ts
tests/engine/geometry/bounds.test.ts
tests/engine/geometry/clip-polygon.test.ts
tests/engine/geometry/matrix.test.ts
tests/engine/geometry/rotation.test.ts
tests/engine/geometry/scene.test.ts
tests/engine/geometry/text-visual-bounds.test.ts
tests/engine/geometry/visual-bounds.test.ts
tests/engine/hit-test/basic.test.ts
tests/engine/hit-test/scope.test.ts
tests/engine/icons/api.test.ts
tests/engine/icons/iconify.test.ts
tests/engine/icons/svg.test.ts
tests/engine/io/dom-css-formats.test.ts
tests/engine/io/export-formats.test.ts
tests/engine/io/fig/export/auto-layout.test.ts
tests/engine/io/fig/export/boolean-operation.test.ts
tests/engine/io/fig/export/component-properties.test.ts
tests/engine/io/fig/export/corner-smoothing.test.ts
tests/engine/io/fig/export/font-feature-encoding.test.ts
tests/engine/io/fig/export/font-variations.test.ts
tests/engine/io/fig/export/guid-collision.test.ts
tests/engine/io/fig/export/images.test.ts
tests/engine/io/fig/export/lazy-population.test.ts
tests/engine/io/fig/export/masks.test.ts
tests/engine/io/fig/export/paint-schema-fields.test.ts
tests/engine/io/fig/export/parse-failures.test.ts
tests/engine/io/fig/export/path-text-reflow-export.test.ts
tests/engine/io/fig/export/rotation-transform.test.ts
tests/engine/io/fig/export/shared-styles.test.ts
tests/engine/io/fig/export/stroke-geometry.test.ts
tests/engine/io/fig/export/text.test.ts
tests/engine/io/fig/export/variables.test.ts
tests/engine/io/fig/export/worker.test.ts
tests/engine/io/fig/heavy/component-metadata.test.ts
tests/engine/io/fig/heavy/fixtures.test.ts
tests/engine/io/fig/import/basic.test.ts
tests/engine/io/fig/import/boolean-operation.test.ts
tests/engine/io/fig/import/cancellation.test.ts
tests/engine/io/fig/import/component-props.test.ts
tests/engine/io/fig/import/derived-symbol-data.test.ts
tests/engine/io/fig/import/edge-cases.test.ts
tests/engine/io/fig/import/field-protection.test.ts
tests/engine/io/fig/import/font-variations.test.ts
tests/engine/io/fig/import/group-reclassify.test.ts
tests/engine/io/fig/import/instance-overrides.test.ts
tests/engine/io/fig/import/instance-regressions.test.ts
tests/engine/io/fig/import/lazy-pages.test.ts
tests/engine/io/fig/import/legacy/arc/data.test.ts
tests/engine/io/fig/import/legacy/auto-layout/basic.test.ts
tests/engine/io/fig/import/legacy/blend/mode.test.ts
tests/engine/io/fig/import/legacy/clips/content.test.ts
tests/engine/io/fig/import/legacy/component/sets.test.ts
tests/engine/io/fig/import/legacy/constraints.test.ts
tests/engine/io/fig/import/legacy/corners.test.ts
tests/engine/io/fig/import/legacy/effects.test.ts
tests/engine/io/fig/import/legacy/fills.test.ts
tests/engine/io/fig/import/legacy/image/fills.test.ts
tests/engine/io/fig/import/legacy/independent/strokes.test.ts
tests/engine/io/fig/import/legacy/multiple/fills.test.ts
tests/engine/io/fig/import/legacy/node/types.test.ts
tests/engine/io/fig/import/legacy/shared-styles.test.ts
tests/engine/io/fig/import/legacy/text.test.ts
tests/engine/io/fig/import/legacy/transforms.test.ts
tests/engine/io/fig/import/legacy/variables.test.ts
tests/engine/io/fig/import/mask-oracle.test.ts
tests/engine/io/fig/import/masks.test.ts
tests/engine/io/fig/import/paint-schema-fields.test.ts
tests/engine/io/fig/import/pattern-paint-oracle.test.ts
tests/engine/io/fig/import/population-delta.test.ts
tests/engine/io/fig/import/property-integrity.test.ts
tests/engine/io/fig/import/raw-field-coverage.test.ts
tests/engine/io/fig/import/raw-metadata-oracle.test.ts
tests/engine/io/fig/import/rich-text-oracle.test.ts
tests/engine/io/fig/import/scaled-instance-strokes.test.ts
tests/engine/io/fig/import/schema-coverage.test.ts
tests/engine/io/fig/import/session-ownership.test.ts
tests/engine/io/fig/import/strokes.test.ts
tests/engine/io/fig/import/style-refs.test.ts
tests/engine/io/fig/import/text-path.test.ts
tests/engine/io/fig/import/text-sizing.test.ts
tests/engine/io/fig/instance/override-resave.test.ts
tests/engine/io/fig/instance/override-roundtrip.test.ts
tests/engine/io/fig/instance/swap-roundtrip.test.ts
tests/engine/io/fig/instance/text-marker-roundtrip.test.ts
tests/engine/io/fig/page-manifest.test.ts
tests/engine/io/fig/plugin-data.test.ts
tests/engine/io/fig/roundtrip/basic.test.ts
tests/engine/io/fig/roundtrip/exhaustive.test.ts
tests/engine/io/fig/roundtrip/export-settings.test.ts
tests/engine/io/fig/roundtrip/glyph-blob.test.ts
tests/engine/io/fig/roundtrip/group.test.ts
tests/engine/io/fig/roundtrip/source-metadata.test.ts
tests/engine/io/fig/roundtrip/variables.test.ts
tests/engine/io/fig/roundtrip/verifiers.test.ts
tests/engine/io/fig/thumbnail-page.test.ts
tests/engine/io/fig/thumbnail-range.test.ts
tests/engine/io/pptx/export/fidelity.test.ts
tests/engine/io/pptx/export/render.test.ts
tests/engine/io/subgraph.test.ts
tests/engine/io/svg/export/builder.test.ts
tests/engine/io/svg/export/paths.test.ts
tests/engine/io/svg/export/render.test.ts
tests/engine/io/svg/import.test.ts
tests/engine/io/svg/path-parse.test.ts
tests/engine/kiwi/instance-overrides/sync-clones.test.ts
tests/engine/kiwi/schema-runtime.test.ts
tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts
tests/engine/kiwi/serialize-fixes/borders/take-space.test.ts
tests/engine/kiwi/serialize-fixes/component-metadata.test.ts
tests/engine/kiwi/serialize-fixes/directionality.test.ts
tests/engine/kiwi/serialize-fixes/font/family.test.ts
tests/engine/kiwi/serialize-fixes/frame/mask.test.ts
tests/engine/kiwi/serialize-fixes/integration.test.ts
tests/engine/kiwi/serialize-fixes/line/height.test.ts
tests/engine/layout/absolute-position/cache.test.ts
tests/engine/layout/auto-layout/absolute/children-in-yoga-tree.test.ts
tests/engine/layout/auto-layout/absolute/positioning.test.ts
tests/engine/layout/auto-layout/alignment/counter-axis.test.ts
tests/engine/layout/auto-layout/alignment/primary-axis.test.ts
tests/engine/layout/auto-layout/counter-axis/align-content.test.ts
tests/engine/layout/auto-layout/counter-axis/spacing-no-wrap.test.ts
tests/engine/layout/auto-layout/edge-cases.test.ts
tests/engine/layout/auto-layout/fill/flex-basis.test.ts
tests/engine/layout/auto-layout/gap/mapping-correctness.test.ts
tests/engine/layout/auto-layout/hidden-children/basic.test.ts
tests/engine/layout/auto-layout/hidden-children/preserve-dimensions.test.ts
tests/engine/layout/auto-layout/horizontal/basic.test.ts
tests/engine/layout/auto-layout/imported-derived-layout/effective-generated-text.test.ts
tests/engine/layout/auto-layout/layout-align-self/extended-values.test.ts
tests/engine/layout/auto-layout/layout/imported-derived.test.ts
tests/engine/layout/auto-layout/layout/mode-none.test.ts
tests/engine/layout/auto-layout/min-max/constraints.test.ts
tests/engine/layout/auto-layout/mixed/sizing.test.ts
tests/engine/layout/auto-layout/nested/basic.test.ts
tests/engine/layout/auto-layout/nested/fill-children.test.ts
tests/engine/layout/auto-layout/self/alignment.test.ts
tests/engine/layout/auto-layout/sizing/modes.test.ts
tests/engine/layout/auto-layout/text/measurement.test.ts
tests/engine/layout/auto-layout/vertical/basic.test.ts
tests/engine/layout/auto-layout/wrap/layout.test.ts
tests/engine/layout/grid-layout.test.ts
tests/engine/library/discovery.test.ts
tests/engine/library/hash-normalization.test.ts
tests/engine/library/instance-updates.test.ts
tests/engine/library/materialize.test.ts
tests/engine/library/metadata.test.ts
tests/engine/library/preview.test.ts
tests/engine/library/routed-catalog.test.ts
tests/engine/library/selective.test.ts
tests/engine/library/snapshot.test.ts
tests/engine/library/source-publication.test.ts
tests/engine/library/storage-catalog.test.ts
tests/engine/library/update-groups.test.ts
tests/engine/library/update.test.ts
tests/engine/lint/basic.test.ts
tests/engine/lint/variable-binding-validation.test.ts
tests/engine/mcp/auth.test.ts
tests/engine/mcp/browser-rpc.test.ts
tests/engine/mcp/path-scoping.test.ts
tests/engine/mcp/result.test.ts
tests/engine/mcp/server/cleanup.test.ts
tests/engine/mcp/server/index.test.ts
tests/engine/mcp/server/security.test.ts
tests/engine/mcp/server/transport.test.ts
tests/engine/mcp/server/watchdog.test.ts
tests/engine/mcp/stdio/auth.test.ts
tests/engine/mcp/stdio/index.test.ts
tests/engine/mcp/stdio/reconnect.test.ts
tests/engine/mcp/test-support/discovery-isolation.test.ts
tests/engine/mcp/tools.test.ts
tests/engine/mcp/transport/paths.test.ts
tests/engine/mcp/webmcp-settings.test.ts
tests/engine/mcp/webmcp.test.ts
tests/engine/pen/file.test.ts
tests/engine/pen/layout.test.ts
tests/engine/pen/var-padding.test.ts
tests/engine/pen/variables.test.ts
tests/engine/profiler/capture-stack.test.ts
tests/engine/profiler/draw-call-counter.test.ts
tests/engine/profiler/frame-stats.test.ts
tests/engine/profiler/gpu-timer.test.ts
tests/engine/profiler/phase-timer.test.ts
tests/engine/profiler/speedscope.test.ts
tests/engine/random/basic.test.ts
tests/engine/render/canvas/arrow-heads-draw.test.ts
tests/engine/render/canvas/blend.test.ts
tests/engine/render/canvas/boolean.test.ts
tests/engine/render/canvas/cache.test.ts
tests/engine/render/canvas/chunks/cache.test.ts
tests/engine/render/canvas/chunks/context.test.ts
tests/engine/render/canvas/chunks/gold-preview.test.ts
tests/engine/render/canvas/chunks/index.test.ts
tests/engine/render/canvas/chunks/record.test.ts
tests/engine/render/canvas/component-label-text.test.ts
tests/engine/render/canvas/corner-smoothing.test.ts
tests/engine/render/canvas/culling.test.ts
tests/engine/render/canvas/derived-text.test.ts
tests/engine/render/canvas/diamond-gradient.test.ts
tests/engine/render/canvas/effect-raster-cache.test.ts
tests/engine/render/canvas/effect-raster-integration.test.ts
tests/engine/render/canvas/effects/edge-cases.test.ts
tests/engine/render/canvas/effects/inner-shadow.test.ts
tests/engine/render/canvas/effects/ordering.test.ts
tests/engine/render/canvas/effects/path-shapes.test.ts
tests/engine/render/canvas/effects/spread.test.ts
tests/engine/render/canvas/effects/text-shadow.test.ts
tests/engine/render/canvas/effects/types.test.ts
tests/engine/render/canvas/font-readiness.test.ts
tests/engine/render/canvas/gradient.test.ts
tests/engine/render/canvas/guides/draw.test.ts
tests/engine/render/canvas/guides/geometry.test.ts
tests/engine/render/canvas/guides/redlines.test.ts
tests/engine/render/canvas/image-fill.test.ts
tests/engine/render/canvas/label-cache.test.ts
tests/engine/render/canvas/labels/color.test.ts
tests/engine/render/canvas/labels/fonts.test.ts
tests/engine/render/canvas/labels/layout.test.ts
tests/engine/render/canvas/labels/paragraph-cache.test.ts
tests/engine/render/canvas/labels/paragraph-reuse.test.ts
tests/engine/render/canvas/labels/transform.test.ts
tests/engine/render/canvas/layout-grids.test.ts
tests/engine/render/canvas/line.test.ts
tests/engine/render/canvas/masks.test.ts
tests/engine/render/canvas/measurement.test.ts
tests/engine/render/canvas/normalize-dash-pattern.test.ts
tests/engine/render/canvas/painted-text.test.ts
tests/engine/render/canvas/picture-invalidation.test.ts
tests/engine/render/canvas/raster-export.test.ts
tests/engine/render/canvas/renderer-lifecycle.test.ts
tests/engine/render/canvas/retained-backing-pixels.test.ts
tests/engine/render/canvas/retained-backing.test.ts
tests/engine/render/canvas/schema-fill-fallback.test.ts
tests/engine/render/canvas/surface-replacement.test.ts
tests/engine/render/canvas/text-font-variations.test.ts
tests/engine/render/canvas/text-preparation-cache.test.ts
tests/engine/render/canvas/text.test.ts
tests/engine/render/canvas/tiles/cache.test.ts
tests/engine/render/canvas/tiles/geometry.test.ts
tests/engine/render/canvas/tiles/gold-preview.test.ts
tests/engine/render/canvas/tiles/planner.test.ts
tests/engine/render/canvas/tiles/render.test.ts
tests/engine/render/canvas/tiles/scheduler.test.ts
tests/engine/render/canvas/variable-modes.test.ts
tests/engine/render/canvas/visual/boolean.test.ts
tests/engine/render/jsx/builders.test.ts
tests/engine/render/jsx/component/properties.test.ts
tests/engine/render/jsx/component/property-references.test.ts
tests/engine/render/jsx/component/set-properties.test.ts
tests/engine/render/jsx/export.test.ts
tests/engine/render/jsx/gaps.test.ts
tests/engine/render/jsx/grid-layout.test.ts
tests/engine/render/jsx/instance-sizing.test.ts
tests/engine/render/jsx/reference.test.ts
tests/engine/render/jsx/render-tree.test.ts
tests/engine/render/jsx/scalar-variables.test.ts
tests/engine/render/jsx/schema.test.ts
tests/engine/render/jsx/text-props.test.ts
tests/engine/snap/basic.test.ts
tests/engine/tauri/acp-transport.test.ts
tests/engine/tauri/automation-files.test.ts
tests/engine/tauri/command.test.ts
tests/engine/tauri/document-io.test.ts
tests/engine/tauri/fig-export.test.ts
tests/engine/tauri/file-actions.test.ts
tests/engine/tauri/font-cache.test.ts
tests/engine/tauri/fonts.test.ts
tests/engine/tauri/harness-process.test.ts
tests/engine/tauri/http.test.ts
tests/engine/tauri/mcp-spawn.test.ts
tests/engine/tauri/mock-cleanup.test.ts
tests/engine/tauri/processes.test.ts
tests/engine/text/coverage.test.ts
tests/engine/text/derived-text/clipboard.test.ts
tests/engine/text/edit-undo.test.ts
tests/engine/text/editor.test.ts
tests/engine/text/fonts/coverage.test.ts
tests/engine/text/fonts/fallbacks.test.ts
tests/engine/text/fonts/lifecycle.test.ts
tests/engine/text/fonts/loading.test.ts
tests/engine/text/fonts/normalize-export.test.ts
tests/engine/text/fonts/status.test.ts
tests/engine/text/fonts/variations.test.ts
tests/engine/text/fonts/web-fonts.test.ts
tests/engine/text/opentype/outlines.test.ts
tests/engine/text/resolver.test.ts
tests/engine/text/rtl-auto-layout-input.test.ts
tests/engine/text/style-runs.test.ts
tests/engine/text/style-runs/mutation.test.ts
tests/engine/tools/ai-adapter.test.ts
tests/engine/tools/analyze/overlaps/bounds.test.ts
tests/engine/tools/analyze/overlaps/index.test.ts
tests/engine/tools/analyze/overlaps/page-scope.test.ts
tests/engine/tools/calc/expression.test.ts
tests/engine/tools/calc/tool.test.ts
tests/engine/tools/cli.test.ts
tests/engine/tools/create.test.ts
tests/engine/tools/describe.test.ts
tests/engine/tools/eval-wrap.test.ts
tests/engine/tools/export-image.test.ts
tests/engine/tools/input.test.ts
tests/engine/tools/library-components.test.ts
tests/engine/tools/modify.test.ts
tests/engine/tools/modify/image-fill.test.ts
tests/engine/tools/read.test.ts
tests/engine/tools/registry.test.ts
tests/engine/tools/stock-photo/apply.test.ts
tests/engine/tools/stock-photo/credentials.test.ts
tests/engine/tools/stock-photo/requests.test.ts
tests/engine/tools/structure.test.ts
tests/engine/tools/xpath/basic.test.ts
tests/engine/vector/blob/mutation.test.ts
tests/engine/vector/blob/roundtrip.test.ts
tests/engine/vector/bounds.test.ts
tests/engine/vector/centerline.test.ts
tests/engine/vector/fill-geometry-regen.test.ts
tests/engine/vector/normalize.test.ts
tests/engine/vector/path-rendering.test.ts
tests/engine/vector/validate.test.ts
tests/engine/vector/vectorize.test.ts
tests/engine/vue/canvas/color-space.test.ts
tests/engine/vue/canvas/render-loop.test.ts
tests/engine/vue/controls/appearance.test.ts
tests/engine/vue/controls/binding-mode-edit.test.ts
tests/engine/vue/controls/binding-modes.test.ts
tests/engine/vue/controls/color-binding-provider.test.ts
tests/engine/vue/controls/component-properties.test.ts
tests/engine/vue/controls/constraints.test.ts
tests/engine/vue/controls/node-preview.test.ts
tests/engine/vue/controls/number-expression.test.ts
tests/engine/vue/controls/property-preview.test.ts
tests/engine/vue/controls/shared-style.test.ts
tests/engine/vue/controls/stroke.test.ts
tests/engine/vue/controls/typography-depth.test.ts
tests/engine/vue/document-workspace.test.ts
tests/engine/vue/editor/commands/shortcut.test.ts
tests/engine/vue/editor/menu-model/canvas.test.ts
tests/engine/vue/editor/selected-nodes.test.ts
tests/engine/vue/fill/model.test.ts
tests/engine/vue/i18n/locale.test.ts
tests/engine/vue/input/auto-layout-hover.test.ts
tests/engine/vue/input/draw.test.ts
tests/engine/vue/input/drop-files.test.ts
tests/engine/vue/input/explicit-snap-targets.test.ts
tests/engine/vue/input/geometry.test.ts
tests/engine/vue/input/guides.test.ts
tests/engine/vue/input/move-threshold.test.ts
tests/engine/vue/input/node-edit-snap.test.ts
tests/engine/vue/input/resize/geometry.test.ts
tests/engine/vue/input/resize/path-text/basic.test.ts
tests/engine/vue/input/resize/path-text/reflow.test.ts
tests/engine/vue/input/resize/snapping.test.ts
tests/engine/vue/input/snap-guides.test.ts
tests/engine/vue/layer-tree/model.test.ts
tests/engine/vue/layout/hug-contents.test.ts
tests/engine/vue/lifecycle/retention.test.ts
tests/engine/vue/renderer.test.ts
tests/engine/vue/shared/wheel.test.ts
tests/engine/vue/testing/test-id.test.ts

View file

@ -4,5 +4,8 @@
"type": "module",
"scripts": {
"test": "bun test tests"
},
"dependencies": {
"@open-pencil/package-artifacts": "workspace:*"
}
}

View file

@ -0,0 +1,33 @@
#!/usr/bin/env bun
/**
* Fails when a unit test is added under `tests/engine` instead of its owner's canonical home,
* or when the baseline lists a file that has moved.
*
* bun tools/unit-tests/src/check-homes.ts # check
* bun tools/unit-tests/src/check-homes.ts --write # rewrite the baseline from the current tree
*/
import { writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { resolveWorkspaceRoot } from '@open-pencil/package-artifacts'
import { ENGINE_BASELINE_PATH, checkTestHomes, formatBaseline, listEngineTests } from './homes'
const repoRoot = await resolveWorkspaceRoot(process.cwd())
if (process.argv.includes('--write')) {
const files = await listEngineTests(repoRoot)
await writeFile(resolve(repoRoot, ENGINE_BASELINE_PATH), formatBaseline(files))
console.log(`Wrote ${files.length} tests/engine files to ${ENGINE_BASELINE_PATH}.`)
process.exit(0)
}
const result = await checkTestHomes({ repoRoot })
if (result.errors.length > 0) {
console.error('Test placement check failed:')
for (const error of result.errors) console.error(`- ${error}`)
process.exit(1)
}
console.log(
`Test placement check passed; ${result.remaining} tests/engine files remain to migrate.`
)

View file

@ -0,0 +1,85 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { UNIT_TEST_GROUPS } from './shards'
export const ENGINE_TESTS_ROOT = 'tests/engine'
export const ENGINE_BASELINE_PATH = 'tools/unit-tests/engine-baseline.txt'
export interface TestHomesOptions {
repoRoot: string
baselinePath?: string
/** Defaults to the discovered `tests/engine` files; tests inject a listing. */
engineTests?: readonly string[]
}
export interface TestHomesResult {
errors: string[]
/** `tests/engine` files that the baseline still allows. */
remaining: number
}
/** Canonical home for a `tests/engine/<dir>/...` file, from the shard map. */
export function canonicalHomeFor(engineTestPath: string): string | undefined {
for (const paths of Object.values(UNIT_TEST_GROUPS)) {
const engineDirs: readonly string[] = paths.filter((path) =>
path.startsWith(`${ENGINE_TESTS_ROOT}/`)
)
if (!engineDirs.some((dir) => engineTestPath.startsWith(`${dir}/`))) continue
return paths.find((path) => !path.startsWith(`${ENGINE_TESTS_ROOT}/`))
}
return undefined
}
export function parseBaseline(text: string): string[] {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
}
export function formatBaseline(files: readonly string[]): string {
const header = [
'# Unit tests still under tests/engine, the migration debt from',
"# packages/docs/development/testing.md. New tests go to the owner's canonical",
'# home; remove a line here when its file moves. Regenerate with',
'# `bun tools/unit-tests/src/check-homes.ts --write`.'
]
return [...header, ...[...files].sort()].join('\n') + '\n'
}
/** Every test file under `tests/engine`, discovered or not, so an unsharded file cannot hide. */
export async function listEngineTests(repoRoot: string): Promise<string[]> {
const files = await Array.fromAsync(
new Bun.Glob(`${ENGINE_TESTS_ROOT}/**/*.test.ts`).scan({ cwd: repoRoot })
)
return files.map((file) => file.split('\\').join('/')).sort()
}
/**
* Every `tests/engine` test file must be in the reviewed baseline, and every baseline entry must
* still exist, so the debt only shrinks: a new test goes to its owner's canonical home from day one.
*/
export async function checkTestHomes(options: TestHomesOptions): Promise<TestHomesResult> {
const baselinePath = resolve(options.repoRoot, options.baselinePath ?? ENGINE_BASELINE_PATH)
if (!existsSync(baselinePath)) {
return { errors: [`Missing engine test baseline at ${baselinePath}`], remaining: 0 }
}
const baseline = new Set(parseBaseline(readFileSync(baselinePath, 'utf8')))
const engineTests = options.engineTests ?? (await listEngineTests(options.repoRoot))
const present = new Set(engineTests)
const errors: string[] = []
for (const file of engineTests) {
if (baseline.has(file)) continue
const home = canonicalHomeFor(file)
errors.push(
`${file} is a new test under ${ENGINE_TESTS_ROOT}; put it under ${home ?? "the owner's canonical home"} instead`
)
}
for (const file of baseline) {
if (!present.has(file)) errors.push(`${file} no longer exists; remove it from the baseline`)
}
return { errors, remaining: [...baseline].filter((file) => present.has(file)).length }
}

View file

@ -0,0 +1,88 @@
import { describe, expect, test } from 'bun:test'
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
canonicalHomeFor,
checkTestHomes,
formatBaseline,
listEngineTests,
parseBaseline
} from '../src/homes'
const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
async function repoWithBaseline(files: readonly string[]) {
const repoRoot = await mkdtemp(join(tmpdir(), 'open-pencil-test-homes-'))
await mkdir(join(repoRoot, 'tools/unit-tests'), { recursive: true })
await writeFile(join(repoRoot, 'tools/unit-tests/engine-baseline.txt'), formatBaseline(files))
return repoRoot
}
describe('canonicalHomeFor', () => {
test("maps an engine directory to its owner's canonical home from the shard map", () => {
expect(canonicalHomeFor('tests/engine/scene-graph/x.test.ts')).toBe(
'packages/scene-graph/tests'
)
expect(canonicalHomeFor('tests/engine/editor/a/b.test.ts')).toBe('packages/core/tests')
expect(canonicalHomeFor('tests/engine/app/x.test.ts')).toBe('tests/app')
expect(canonicalHomeFor('tests/engine/unknown/x.test.ts')).toBeUndefined()
})
})
describe('baseline format', () => {
test('round-trips sorted entries and ignores comments and blank lines', () => {
const text = formatBaseline(['tests/engine/b.test.ts', 'tests/engine/a.test.ts'])
expect(text.startsWith('#')).toBe(true)
expect(parseBaseline(text)).toEqual(['tests/engine/a.test.ts', 'tests/engine/b.test.ts'])
})
})
describe('checkTestHomes', () => {
test('passes when the tree and the baseline agree', async () => {
const files = ['tests/engine/io/a.test.ts']
const repoRoot = await repoWithBaseline(files)
const result = await checkTestHomes({ repoRoot, engineTests: files })
expect(result).toEqual({ errors: [], remaining: 1 })
})
test('rejects a new engine test and points at the canonical home', async () => {
const repoRoot = await repoWithBaseline([])
const result = await checkTestHomes({
repoRoot,
engineTests: ['tests/engine/scene-graph/new.test.ts']
})
expect(result.errors).toEqual([
'tests/engine/scene-graph/new.test.ts is a new test under tests/engine; put it under packages/scene-graph/tests instead'
])
})
test('rejects stale baseline entries so the debt only shrinks', async () => {
const repoRoot = await repoWithBaseline(['tests/engine/io/moved.test.ts'])
const result = await checkTestHomes({ repoRoot, engineTests: [] })
expect(result.errors).toEqual([
'tests/engine/io/moved.test.ts no longer exists; remove it from the baseline'
])
})
test('fails without a baseline file', async () => {
const repoRoot = await mkdtemp(join(tmpdir(), 'open-pencil-test-homes-empty-'))
expect((await checkTestHomes({ repoRoot })).errors).toHaveLength(1)
})
test('the committed baseline matches the repository', async () => {
const result = await checkTestHomes({ repoRoot: REPO_ROOT })
expect(result.errors).toEqual([])
expect(result.remaining).toBe((await listEngineTests(REPO_ROOT)).length)
})
})