* build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
254 lines
7.7 KiB
TypeScript
254 lines
7.7 KiB
TypeScript
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
|
|
|
|
const editor = useEditorSetup('/demo')
|
|
|
|
function layerRows() {
|
|
return editor.page.locator('[data-node-id]')
|
|
}
|
|
|
|
async function getLayerNames(): Promise<string[]> {
|
|
const rows = layerRows()
|
|
const count = await rows.count()
|
|
const names: string[] = []
|
|
for (let i = 0; i < count; i++) {
|
|
const text = await rows.nth(i).innerText()
|
|
names.push(text.trim())
|
|
}
|
|
return names
|
|
}
|
|
|
|
interface SceneTreeNode {
|
|
name: string
|
|
type: string
|
|
children: SceneTreeNode[]
|
|
}
|
|
|
|
async function getSceneTree(): Promise<SceneTreeNode> {
|
|
return editor.page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
|
|
const nodeTree = (id: string): SceneTreeNode | null => {
|
|
const node = store.graph.getNode(id)
|
|
if (!node) return null
|
|
return {
|
|
name: node.name,
|
|
type: node.type,
|
|
children: node.childIds
|
|
.map((cid: string) => nodeTree(cid))
|
|
.filter((child): child is SceneTreeNode => child !== null)
|
|
}
|
|
}
|
|
const tree = nodeTree(store.state.currentPageId)
|
|
if (!tree) throw new Error('Missing current page tree')
|
|
return tree
|
|
})
|
|
}
|
|
|
|
async function getSelectedCount(): Promise<number> {
|
|
return editor.page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
return store.state.selectedIds.size
|
|
})
|
|
}
|
|
|
|
test('demo layers visible in panel', async () => {
|
|
const names = await getLayerNames()
|
|
expect(names).toContain('Announcement system')
|
|
expect(names).toContain('Components')
|
|
})
|
|
|
|
test('clicking a node inside a frame does not reparent it', async () => {
|
|
const target = await editor.page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
const nodes = [...store.graph.nodes.values()]
|
|
const root = nodes.find((node) => node.name === 'Announcement system')
|
|
const nested = nodes.find((node) => node.name === 'Source components')
|
|
if (!root || !nested?.parentId) throw new Error('Demo announcement structure not found')
|
|
const origin = store.graph.getAbsolutePosition(nested.id)
|
|
return {
|
|
id: nested.id,
|
|
parentId: nested.parentId,
|
|
rootId: root.id,
|
|
x: (origin.x + nested.width / 2) * store.state.zoom + store.state.panX,
|
|
y: (origin.y + nested.height / 2) * store.state.zoom + store.state.panY
|
|
}
|
|
})
|
|
|
|
// Click inside the nested frame hierarchy.
|
|
await editor.canvas.click(target.x, target.y)
|
|
await editor.canvas.waitForRender()
|
|
|
|
const after = await editor.page.evaluate(
|
|
({ id, rootId }) => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
return {
|
|
parentId: store.graph.getNode(id)?.parentId ?? null,
|
|
rootParentId: store.graph.getNode(rootId)?.parentId ?? null,
|
|
pageId: store.state.currentPageId
|
|
}
|
|
},
|
|
{ id: target.id, rootId: target.rootId }
|
|
)
|
|
|
|
// The clicked node and its section must keep their place in the hierarchy.
|
|
expect(after.parentId).toBe(target.parentId)
|
|
expect(after.rootParentId).toBe(after.pageId)
|
|
|
|
editor.canvas.assertNoErrors()
|
|
})
|
|
|
|
test('creating a shape updates layers', async () => {
|
|
const before = await getLayerNames()
|
|
await editor.canvas.drawRect(600, 500, 50, 50)
|
|
const names = await getLayerNames()
|
|
expect(names).toContain('Rectangle')
|
|
expect(names.length).toBe(before.length + 1)
|
|
|
|
await editor.canvas.undo()
|
|
const after = await getLayerNames()
|
|
expect(after.length).toBe(before.length)
|
|
expect(after).not.toContain('Rectangle')
|
|
})
|
|
|
|
test('Shift+A wraps selection in auto-layout frame', async () => {
|
|
// Draw two loose rectangles for this test
|
|
await editor.canvas.drawRect(700, 600, 60, 60)
|
|
await editor.canvas.drawRect(800, 600, 60, 60)
|
|
await editor.canvas.selectAll()
|
|
const count = await getSelectedCount()
|
|
expect(count).toBeGreaterThanOrEqual(2)
|
|
|
|
const before = await getLayerNames()
|
|
|
|
await editor.page.keyboard.press('Shift+A')
|
|
await editor.canvas.waitForRender()
|
|
|
|
const tree = await getSceneTree()
|
|
const autoFrame = tree.children.find((c) => c.name === 'Frame' && c.type === 'FRAME')
|
|
expect(autoFrame).toBeTruthy()
|
|
|
|
const after = await getLayerNames()
|
|
expect(after).not.toEqual(before)
|
|
expect(after).toContain('Frame')
|
|
|
|
editor.canvas.assertNoErrors()
|
|
})
|
|
|
|
test('grouping updates layers', async () => {
|
|
// Undo the auto-layout to restore flat structure
|
|
await editor.canvas.undo()
|
|
await editor.canvas.undo()
|
|
await editor.canvas.waitForRender()
|
|
|
|
// Draw two rects and group them
|
|
await editor.canvas.drawRect(700, 600, 60, 60)
|
|
await editor.canvas.drawRect(800, 600, 60, 60)
|
|
await editor.canvas.selectAll()
|
|
|
|
await editor.page.keyboard.press('Meta+g')
|
|
await editor.canvas.waitForRender()
|
|
|
|
const tree = await getSceneTree()
|
|
const group = tree.children.find((c) => c.name === 'Group' && c.type === 'GROUP')
|
|
expect(group).toBeTruthy()
|
|
|
|
const names = await getLayerNames()
|
|
expect(names).toContain('Group')
|
|
|
|
editor.canvas.assertNoErrors()
|
|
})
|
|
|
|
test('ungrouping updates layers', async () => {
|
|
await editor.page.keyboard.press('Shift+Meta+g')
|
|
await editor.canvas.waitForRender()
|
|
|
|
const names = await getLayerNames()
|
|
expect(names).not.toContain('Group')
|
|
expect(names).toContain('Rectangle')
|
|
|
|
editor.canvas.assertNoErrors()
|
|
})
|
|
|
|
test('double-click layer to rename', async () => {
|
|
await editor.canvas.drawRect(900, 600, 50, 50)
|
|
await editor.canvas.waitForRender()
|
|
|
|
const row = layerRows().filter({ hasText: 'Rectangle' }).first()
|
|
await row.dblclick()
|
|
|
|
const input = editor.page.getByTestId('layers-item-input')
|
|
await expect(input).toBeVisible()
|
|
await input.fill('Renamed Layer')
|
|
await input.press('Enter')
|
|
|
|
await editor.canvas.waitForRender()
|
|
const names = await getLayerNames()
|
|
expect(names).toContain('Renamed Layer')
|
|
|
|
editor.canvas.assertNoErrors()
|
|
})
|
|
|
|
test('clicking outside rename input commits', async () => {
|
|
const row = layerRows().filter({ hasText: 'Renamed Layer' }).first()
|
|
await row.dblclick()
|
|
|
|
const input = editor.page.getByTestId('layers-item-input')
|
|
await expect(input).toBeVisible()
|
|
await input.fill('After Outside Click')
|
|
|
|
await editor.page.mouse.click(500, 400)
|
|
await editor.canvas.waitForRender()
|
|
|
|
await expect(input).not.toBeVisible()
|
|
const names = await getLayerNames()
|
|
expect(names).toContain('After Outside Click')
|
|
|
|
editor.canvas.assertNoErrors()
|
|
})
|
|
|
|
test('clearing a layer name falls back to the default node name', async () => {
|
|
await editor.canvas.drawRect(980, 600, 50, 50)
|
|
await editor.canvas.waitForRender()
|
|
|
|
const row = layerRows().filter({ hasText: 'Rectangle' }).last()
|
|
const countBefore = await layerRows().count()
|
|
|
|
await row.dblclick()
|
|
|
|
const input = editor.page.getByTestId('layers-item-input')
|
|
await expect(input).toBeVisible()
|
|
await input.clear()
|
|
await input.press('Enter')
|
|
|
|
await editor.canvas.waitForRender()
|
|
|
|
const countAfter = await layerRows().count()
|
|
expect(countAfter).toBe(countBefore)
|
|
|
|
const names = await getLayerNames()
|
|
expect(names.filter((name) => name === 'Rectangle').length).toBeGreaterThan(0)
|
|
|
|
editor.canvas.assertNoErrors()
|
|
})
|
|
|
|
test('double-click does not toggle tree expand', async () => {
|
|
const rowCountBefore = await layerRows().count()
|
|
|
|
const containerRow = layerRows().filter({ hasText: 'Components' }).first()
|
|
await containerRow.dblclick()
|
|
await editor.canvas.waitForRender()
|
|
|
|
const input = editor.page.getByTestId('layers-item-input')
|
|
await expect(input).toBeVisible()
|
|
await input.press('Escape')
|
|
|
|
const rowCountAfter = await layerRows().count()
|
|
expect(rowCountAfter).toBe(rowCountBefore)
|
|
|
|
editor.canvas.assertNoErrors()
|
|
})
|