chore(tests): guard component and cache assertions

- Remove non-null assertions from component, visibility, scene cache, and redo sequence tests
- Add explicit node, store, renderer, and canvas bounds guards
- Validate check plus affected engine and E2E coverage
This commit is contained in:
Danila Poyarkov 2026-05-06 11:07:47 +03:00
parent f7b20a8f38
commit 86cb3970ff
4 changed files with 90 additions and 57 deletions

View file

@ -1,5 +1,6 @@
import { expect, test, type Page } from '@playwright/test'
import { expectDefined } from '#tests/helpers/assert'
import { CanvasHelper } from '#tests/helpers/canvas'
let page: Page
@ -61,9 +62,10 @@ test('create component from selection (⌘⌥K)', async () => {
const ids = await getSelectedIds()
expect(ids).toHaveLength(1)
const node = await getNodeById(ids[0])
expect(node!.type).toBe('COMPONENT')
componentId = ids[0]
const selectedId = expectDefined(ids[0], 'selected component id')
const node = await getNodeById(selectedId)
expect(node?.type).toBe('COMPONENT')
componentId = selectedId
})
test('component shows purple label in design panel', async () => {
@ -94,21 +96,26 @@ test('create instance from component (context menu)', async () => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
store.createInstanceFromComponent(compId, 300, 100)
}, comp!.id)
}, expectDefined(comp, 'component').id)
await canvas.waitForRender()
const updated = await getPageChildren()
const instance = updated.find((c) => c.type === 'INSTANCE')
expect(instance).toBeTruthy()
expect(instance!.componentId).toBe(comp!.id)
expect(instance?.componentId).toBe(expectDefined(comp, 'component').id)
})
test('instance shows INSTANCE type in design panel', async () => {
const children = await getPageChildren()
const instance = children.find((c) => c.type === 'INSTANCE')!
const instance = expectDefined(
children.find((c) => c.type === 'INSTANCE'),
'instance node'
)
await page.evaluate((id) => {
window.openPencil?.store!.select([id])
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
store.select([id])
}, instance.id)
await canvas.waitForRender()
@ -129,7 +136,9 @@ test('instance has "Detach" button', async () => {
test('modifying component propagates to instance', async () => {
// Select the component
await page.evaluate((id) => {
window.openPencil?.store!.select([id])
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
store.select([id])
}, componentId)
await canvas.waitForRender()
@ -157,7 +166,10 @@ test('modifying component propagates to instance', async () => {
// Check instance got the same fill
const children = await getPageChildren()
const instance = children.find((c) => c.type === 'INSTANCE')!
const instance = expectDefined(
children.find((c) => c.type === 'INSTANCE'),
'instance node'
)
const instanceNode = await page.evaluate((id) => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
@ -171,21 +183,28 @@ test('modifying component propagates to instance', async () => {
test('detach instance converts to frame', async () => {
const children = await getPageChildren()
const instance = children.find((c) => c.type === 'INSTANCE')!
const instance = expectDefined(
children.find((c) => c.type === 'INSTANCE'),
'instance node'
)
await page.evaluate((id) => {
window.openPencil?.store!.select([id])
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
store.select([id])
}, instance.id)
await canvas.waitForRender()
await page.evaluate(() => {
window.openPencil?.store!.detachInstance()
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
store.detachInstance()
})
await canvas.waitForRender()
const ids = await getSelectedIds()
const detached = await getNodeById(ids[0])
expect(detached!.type).toBe('FRAME')
const detached = await getNodeById(expectDefined(ids[0], 'detached selected id'))
expect(detached?.type).toBe('FRAME')
canvas.assertNoErrors()
})

View file

@ -1,5 +1,6 @@
import { expect, test, type Page } from '@playwright/test'
import { expectDefined } from '#tests/helpers/assert'
import { CanvasHelper } from '#tests/helpers/canvas'
let page: Page
@ -40,21 +41,21 @@ test('fill visibility supports repeat click and undo redo', async () => {
const fillButton = page.locator('[data-test-id="fill-visibility-0"]')
await expect(fillButton).toBeVisible()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(true)
expect(expectDefined(await getSelectedNode(), 'selected node').fills[0]?.visible).toBe(true)
await fillButton.click()
await canvas.waitForRender()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(false)
expect(expectDefined(await getSelectedNode(), 'selected node').fills[0]?.visible).toBe(false)
await fillButton.click()
await canvas.waitForRender()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(true)
expect(expectDefined(await getSelectedNode(), 'selected node').fills[0]?.visible).toBe(true)
await canvas.undo()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(false)
expect(expectDefined(await getSelectedNode(), 'selected node').fills[0]?.visible).toBe(false)
await canvas.redo()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(true)
expect(expectDefined(await getSelectedNode(), 'selected node').fills[0]?.visible).toBe(true)
})
test('stroke visibility supports repeat click and undo redo', async () => {
@ -63,39 +64,39 @@ test('stroke visibility supports repeat click and undo redo', async () => {
const strokeButton = page.locator('[data-test-id="stroke-visibility-0"]')
await expect(strokeButton).toBeVisible()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true)
expect(expectDefined(await getSelectedNode(), 'selected node').strokes[0]?.visible).toBe(true)
await strokeButton.click()
await canvas.waitForRender()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(false)
expect(expectDefined(await getSelectedNode(), 'selected node').strokes[0]?.visible).toBe(false)
await strokeButton.click()
await canvas.waitForRender()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true)
expect(expectDefined(await getSelectedNode(), 'selected node').strokes[0]?.visible).toBe(true)
await canvas.undo()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(false)
expect(expectDefined(await getSelectedNode(), 'selected node').strokes[0]?.visible).toBe(false)
await canvas.redo()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true)
expect(expectDefined(await getSelectedNode(), 'selected node').strokes[0]?.visible).toBe(true)
})
test('appearance visibility supports repeat click and undo redo in one step', async () => {
const visibilityButton = page.locator('[data-test-id="appearance-visibility"]')
await expect(visibilityButton).toBeVisible()
expect((await getSelectedNode())!.visible).toBe(true)
expect(expectDefined(await getSelectedNode(), 'selected node').visible).toBe(true)
await visibilityButton.click()
await canvas.waitForRender()
expect((await getSelectedNode())!.visible).toBe(false)
expect(expectDefined(await getSelectedNode(), 'selected node').visible).toBe(false)
await visibilityButton.click()
await canvas.waitForRender()
expect((await getSelectedNode())!.visible).toBe(true)
expect(expectDefined(await getSelectedNode(), 'selected node').visible).toBe(true)
await canvas.undo()
expect((await getSelectedNode())!.visible).toBe(false)
expect(expectDefined(await getSelectedNode(), 'selected node').visible).toBe(false)
await canvas.undo()
expect((await getSelectedNode())!.visible).toBe(true)
expect(expectDefined(await getSelectedNode(), 'selected node').visible).toBe(true)
})

View file

@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test'
import { expectDefined } from '#tests/helpers/assert'
import { CanvasHelper } from '#tests/helpers/canvas'
test.describe('SkPicture scene caching', () => {
@ -78,7 +79,9 @@ test.describe('SkPicture scene caching', () => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
// Simulate what loadFonts() does: invalidate the cached picture
store.renderer!.invalidateScenePicture()
const renderer = store.renderer
if (!renderer) throw new Error('OpenPencil renderer not initialized')
renderer.invalidateScenePicture()
store.requestRender()
})
await helper.waitForRender()
@ -98,7 +101,8 @@ test.describe('SkPicture scene caching', () => {
await helper.page.evaluate(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
const pg = store.graph.getNode(store.state.currentPageId)!
const pg = store.graph.getNode(store.state.currentPageId)
if (!pg) throw new Error(`Page ${store.state.currentPageId} not found`)
const frame = pg.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME')
store.setHoveredNode(frame ?? null)
})
@ -137,7 +141,8 @@ test.describe('SkPicture scene caching', () => {
await helper.page.evaluate(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
const page = store.graph.getNode(store.state.currentPageId)!
const page = store.graph.getNode(store.state.currentPageId)
if (!page) throw new Error(`Page ${store.state.currentPageId} not found`)
const frame = page.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME')
store.setHoveredNode(frame ?? null)
})
@ -162,7 +167,8 @@ test.describe('SkPicture scene caching', () => {
await helper.page.evaluate(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
const page = store.graph.getNode(store.state.currentPageId)!
const page = store.graph.getNode(store.state.currentPageId)
if (!page) throw new Error(`Page ${store.state.currentPageId} not found`)
const frame = page.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME')
for (let i = 0; i < 10; i++) {
@ -186,15 +192,19 @@ test.describe('SkPicture scene caching', () => {
await helper.waitForRender()
const baseline = await helper.screenshotCanvas()
const box = await helper.canvas.boundingBox()
const box = expectDefined(await helper.canvas.boundingBox(), 'canvas bounds')
// Move mouse over the frame (at 200, 150 — center of the 300x200 frame at 50,50)
await helper.page.mouse.move(box!.x + 200, box!.y + 150)
await helper.page.mouse.move(box.x + 200, box.y + 150)
await helper.waitForRender()
await helper.page.waitForTimeout(100)
// Move mouse to empty area (far from any node)
await helper.page.mouse.move(box!.x + 800, box!.y + 600)
await helper.page.evaluate(() => window.openPencil?.store!.setHoveredNode(null))
await helper.page.mouse.move(box.x + 800, box.y + 600)
await helper.page.evaluate(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
store.setHoveredNode(null)
})
await helper.waitForRender()
await helper.page.waitForTimeout(100)
@ -208,7 +218,8 @@ test.describe('SkPicture scene caching', () => {
await helper.page.evaluate(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
const page = store.graph.getNode(store.state.currentPageId)!
const page = store.graph.getNode(store.state.currentPageId)
if (!page) throw new Error(`Page ${store.state.currentPageId} not found`)
const frame = page.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME')
if (frame) store.graph.updateNode(frame, { width: 310 })
store.requestRender()
@ -219,7 +230,8 @@ test.describe('SkPicture scene caching', () => {
await helper.page.evaluate(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
const page = store.graph.getNode(store.state.currentPageId)!
const page = store.graph.getNode(store.state.currentPageId)
if (!page) throw new Error(`Page ${store.state.currentPageId} not found`)
const frame = page.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME')
store.setHoveredNode(frame ?? null)
})

View file

@ -1,5 +1,6 @@
import { describe, test, expect } from 'bun:test'
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
import { createHistoryFrame, setupEditorPage } from '#tests/helpers/editor-history'
describe('undo/redo multi-step sequences', () => {
@ -8,7 +9,7 @@ describe('undo/redo multi-step sequences', () => {
const frame = createHistoryFrame(editor, pageId, { x: 100, y: 100 })
editor.select([frame.id])
const createSnapshot = structuredClone(editor.graph.getNode(frame.id)!)
const createSnapshot = structuredClone(getNodeOrThrow(editor.graph, frame.id))
editor.pushUndoEntry({
label: 'Create',
forward: () => {
@ -21,13 +22,13 @@ describe('undo/redo multi-step sequences', () => {
editor.graph.updateNode(frame.id, { x: 300, y: 50 })
editor.commitMove(new Map([[frame.id, { x: 100, y: 100 }]]))
expect(editor.graph.getNode(frame.id)!.x).toBe(300)
expect(editor.graph.getNode(frame.id)!.y).toBe(50)
expect(getNodeOrThrow(editor.graph, frame.id).x).toBe(300)
expect(getNodeOrThrow(editor.graph, frame.id).y).toBe(50)
editor.duplicateSelected()
const dupIds = [...editor.state.selectedIds]
expect(dupIds).toHaveLength(1)
const dupId = dupIds[0]
const dupId = expectDefined(dupIds[0], 'duplicate id')
expect(dupId).not.toBe(frame.id)
editor.graph.updateNode(dupId, { x: 500, y: 200 })
@ -35,7 +36,7 @@ describe('undo/redo multi-step sequences', () => {
// Undo move copy
editor.undo.undo()
expect(editor.graph.getNode(dupId)!.x).toBe(320)
expect(getNodeOrThrow(editor.graph, dupId).x).toBe(320)
// Undo duplicate
editor.undo.undo()
@ -43,7 +44,7 @@ describe('undo/redo multi-step sequences', () => {
// Undo move
editor.undo.undo()
expect(editor.graph.getNode(frame.id)!.x).toBe(100)
expect(getNodeOrThrow(editor.graph, frame.id).x).toBe(100)
// Undo create
editor.undo.undo()
@ -52,11 +53,11 @@ describe('undo/redo multi-step sequences', () => {
// Redo create
editor.undo.redo()
expect(editor.graph.getNode(frame.id)).not.toBeUndefined()
expect(editor.graph.getNode(frame.id)!.x).toBe(100)
expect(getNodeOrThrow(editor.graph, frame.id).x).toBe(100)
// Redo move
editor.undo.redo()
expect(editor.graph.getNode(frame.id)!.x).toBe(300)
expect(getNodeOrThrow(editor.graph, frame.id).x).toBe(300)
// Redo duplicate — must recreate with SAME ID
editor.undo.redo()
@ -64,8 +65,8 @@ describe('undo/redo multi-step sequences', () => {
// Redo move copy — must find the node by same ID
editor.undo.redo()
expect(editor.graph.getNode(dupId)!.x).toBe(500)
expect(editor.graph.getNode(dupId)!.y).toBe(200)
expect(getNodeOrThrow(editor.graph, dupId).x).toBe(500)
expect(getNodeOrThrow(editor.graph, dupId).y).toBe(200)
})
test('duplicate with children preserves subtree on redo', () => {
@ -84,11 +85,11 @@ describe('undo/redo multi-step sequences', () => {
editor.select([frame.id])
editor.duplicateSelected()
const dupFrameId = [...editor.state.selectedIds][0]
const dupChildren = editor.graph.getNode(dupFrameId)!.childIds
const dupFrameId = expectDefined([...editor.state.selectedIds][0], 'duplicated frame id')
const dupChildren = getNodeOrThrow(editor.graph, dupFrameId).childIds
expect(dupChildren).toHaveLength(1)
const dupTextId = dupChildren[0]
expect(editor.graph.getNode(dupTextId)!.text).toBe('Hello')
const dupTextId = expectDefined(dupChildren[0], 'duplicated text id')
expect(getNodeOrThrow(editor.graph, dupTextId).text).toBe('Hello')
// Undo
editor.undo.undo()
@ -99,8 +100,8 @@ describe('undo/redo multi-step sequences', () => {
editor.undo.redo()
expect(editor.graph.getNode(dupFrameId)).not.toBeUndefined()
expect(editor.graph.getNode(dupTextId)).not.toBeUndefined()
expect(editor.graph.getNode(dupFrameId)!.childIds).toContain(dupTextId)
expect(editor.graph.getNode(dupTextId)!.text).toBe('Hello')
expect(getNodeOrThrow(editor.graph, dupFrameId).childIds).toContain(dupTextId)
expect(getNodeOrThrow(editor.graph, dupTextId).text).toBe('Hello')
})
test('page snapshot restore preserves node IDs', () => {
@ -118,7 +119,7 @@ describe('undo/redo multi-step sequences', () => {
expect(editor.graph.getNode(frame.id)).not.toBeUndefined()
expect(editor.graph.getNode(child.id)).not.toBeUndefined()
expect(editor.graph.getNode(frame.id)!.childIds).toEqual([child.id])
expect(getNodeOrThrow(editor.graph, frame.id).childIds).toEqual([child.id])
})
test('delete frame with children → undo restores subtree', () => {
@ -142,6 +143,6 @@ describe('undo/redo multi-step sequences', () => {
editor.undo.undo()
expect(editor.graph.getNode(frame.id)).not.toBeUndefined()
expect(editor.graph.getNode(child.id)).not.toBeUndefined()
expect(editor.graph.getNode(frame.id)!.childIds).toContain(child.id)
expect(getNodeOrThrow(editor.graph, frame.id).childIds).toContain(child.id)
})
})