openpencil/tests/e2e/components/behaviours.spec.ts
Danila Poyarkov 9081c4b1d6
fix(preview): toggle controls both ways and keep islands in step with the canvas (#982)
* fix(preview): keep a control's layer path when its variant switches

Preview finds a control by its layer path, built from layer names. Swapping an instance named after its variant renamed it to the new variant, as Figma does for a real swap, so a switch or checkbox inside a frame lost its path after one click and could not be toggled back. swapInstanceComponent takes keepName, and preview's private graph keeps every instance's name.

* refactor(preview): build preview's private graph with the shared page extractor

Preview copied its island and the components its instances show with its own closure and copier, a second version of the extractor the AI preview, history, and export use. extractPageContext takes componentSets, which brings every variant of an instance's set so it can switch, and shareTables, which reads the document's variables and images instead of copying them for a view rebuilt on each state. The extractor now also indexes instances by component, which syncing and swapping an extracted graph need.

* test: group scene-graph's instance swap test under instances

* fix(preview): move islands with the frames the canvas draws

Islands followed the pane's live pan and zoom, which change on every wheel event, while the canvas draws once per animation frame or later on a slow frame. Panning and zooming fast, the islands ran ahead of the drawing and settled back when it caught up. useCanvas reports each drawn frame's view through onFrame, and the islands follow the scene's last drawn frame, so both move in the same frame.

* test: release the held canvas even when the island sync test fails

* docs: point private document copies at the shared page extractor

The Core guide says previews, history snapshots, and exports copy part of a document with the extractor in io/subgraph.ts and its componentSets and shareTables options, and the preview guide says where an island's private graph comes from.
2026-10-08 17:51:00 +00:00

373 lines
14 KiB
TypeScript

import { colorToCSS } from '@open-pencil/scene-graph/color'
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
import { propertySection } from '#tests/helpers/properties'
const editor = useEditorSetupWithClear('/?test&no-rulers')
/** The live island a previewing canvas runs for a top-level layer. */
function island(layerId: string) {
return editor.page.locator(`[data-island="${layerId}"]`)
}
async function startPreview() {
await editor.page.keyboard.press('ControlOrMeta+Alt+Enter')
await expect(editor.page.getByRole('button', { name: /Leave preview/ })).toBeVisible()
}
/**
* A Switch set whose State=On and State=Off variants differ, and an Off instance below it,
* either on the page or inside a frame, as a settings row holds one.
*/
async function createSwitch({ nested = false } = {}) {
return editor.page.evaluate((inFrame) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const graph = store.graph
const pageId = store.state.currentPageId
const solid = (r: number, g: number, b: number) => [
{ type: 'SOLID' as const, color: { r, g, b, a: 1 }, opacity: 1, visible: true }
]
const set = graph.createNode('COMPONENT_SET', pageId, {
name: 'Switch',
x: 120,
y: 120,
width: 200,
height: 80,
componentPropertyDefinitions: [
{
id: 'state',
name: 'State',
type: 'VARIANT',
defaultValue: 'Off',
variantOptions: ['On', 'Off']
}
]
})
const variant = (value: string, index: number, on: boolean) => {
const component = graph.createNode('COMPONENT', set.id, {
name: `State=${value}`,
x: 20 + index * 90,
y: 20,
width: 56,
height: 32,
cornerRadius: 16,
fills: on ? solid(0.2, 0.6, 1) : solid(0.8, 0.8, 0.85),
componentPropertyValues: { State: value }
})
graph.createNode('ELLIPSE', component.id, {
name: 'Knob',
x: on ? 28 : 4,
y: 4,
width: 24,
height: 24,
fills: solid(1, 1, 1)
})
return component
}
variant('On', 0, true)
const off = variant('Off', 1, false)
const row = inFrame
? graph.createNode('FRAME', pageId, {
name: 'Settings',
x: 120,
y: 260,
width: 200,
height: 60
})
: undefined
const instance = row
? graph.createInstance(off.id, row.id, { x: 12, y: 12 })
: graph.createInstance(off.id, pageId, { x: 120, y: 260 })
if (!instance) throw new Error('Instance not created')
store.select([set.id])
return {
setId: set.id,
offId: off.id,
instanceId: instance.id,
islandId: row?.id ?? instance.id
}
}, nested)
}
/** Adds the Switch behaviour to the selected set, binding its value to State. */
async function addSwitchBehaviour() {
const section = propertySection(editor.page, 'Behaviour')
await section.getByRole('button', { name: 'Add behaviour' }).click()
await editor.page.getByRole('option', { name: /Switch/ }).click()
await section.getByRole('combobox', { name: 'Checked' }).click()
await editor.page.getByRole('option', { name: 'State' }).click()
await expect(section.getByText('Ready', { exact: true })).toBeVisible()
}
test('a Switch behaviour flips in preview and leaves the document alone', async () => {
const ids = await createSwitch()
await editor.canvas.waitForRender()
await addSwitchBehaviour()
await startPreview()
// The instance runs as Reka UI's Switch, drawn by the set's variants.
const control = island(ids.instanceId).getByRole('switch')
await expect(control).toHaveAttribute('aria-checked', 'false')
await control.click()
await expect(control).toHaveAttribute('aria-checked', 'true')
await editor.page.mouse.move(4, 400)
await expect(island(ids.instanceId)).toHaveScreenshot('switch-island-on.png')
await control.press('Space')
await expect(control).toHaveAttribute('aria-checked', 'false')
await control.click()
const document = await editor.page.evaluate((instanceId) => {
const store = window.openPencil?.getStore?.()
return store?.graph.getNode(instanceId)?.componentId
}, ids.instanceId)
expect(document).toBe(ids.offId)
await editor.page.keyboard.press('Escape')
await expect(editor.page.getByRole('button', { name: /Leave preview/ })).toHaveCount(0)
// Editing again: the panels and their Preview button are back.
await expect(editor.page.getByRole('button', { name: /^Preview/ })).toBeVisible()
})
test('a switch inside a frame, named after its variant, toggles both ways in preview', async () => {
// Its layer path includes its name, which a variant switch used to rename to State=On.
const ids = await createSwitch({ nested: true })
await editor.canvas.waitForRender()
await addSwitchBehaviour()
await startPreview()
const control = island(ids.islandId).getByRole('switch')
for (const checked of ['true', 'false', 'true']) {
await control.click()
await expect(control).toHaveAttribute('aria-checked', checked)
}
await editor.page.keyboard.press('Escape')
})
test('preview islands move with the frames the canvas draws, not ahead of them', async () => {
const ids = await createSwitch({ nested: true })
await editor.canvas.waitForRender()
await addSwitchBehaviour()
await startPreview()
await expect(island(ids.islandId).getByRole('switch')).toBeVisible()
const layer = editor.page.getByTestId('play-islands').locator(':scope > div')
const placed = await layer.evaluate((element) => element.style.transform)
// Hold the canvas's drawing, as a slow frame does, and move the view meanwhile.
const held = await editor.page.evaluateHandle(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const preparation = store.preparationController.begin({
kind: 'document-open',
phase: 'materializing'
})
store.pan(80, 40)
return preparation
})
try {
await editor.page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
)
expect(await layer.evaluate((element) => element.style.transform)).toBe(placed)
// Drawing resumes: the islands follow the pan, which the canvas has now drawn.
await held.evaluate((preparation) => preparation.complete())
await expect.poll(() => layer.evaluate((element) => element.style.transform)).not.toBe(placed)
} finally {
// Never leave the canvas held for later tests; completing again does nothing.
await held.evaluate((preparation) => preparation.complete())
await held.dispose()
}
await editor.page.keyboard.press('Escape')
})
test('a Button behaviour maps its states and shows them in preview', async () => {
const ids = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const graph = store.graph
const pageId = store.state.currentPageId
const values = ['Default', 'Hover', 'Pressed', 'Focus', 'Disabled']
const set = graph.createNode('COMPONENT_SET', pageId, {
name: 'Button',
x: 120,
y: 120,
width: 520,
height: 80,
componentPropertyDefinitions: [
{
id: 'interaction',
name: 'Interaction',
type: 'VARIANT',
defaultValue: 'Default',
variantOptions: values
}
]
})
const components = values.map((value, index) =>
graph.createNode('COMPONENT', set.id, {
name: `Interaction=${value}`,
x: 20 + index * 100,
y: 24,
width: 80,
height: 32,
cornerRadius: 8,
fills: [
{
type: 'SOLID' as const,
color: { r: 0.2 + index * 0.15, g: 0.4, b: 0.9 - index * 0.15, a: 1 },
opacity: 1,
visible: true
}
],
componentPropertyValues: { Interaction: value }
})
)
const instance = graph.createInstance(components[0].id, pageId, { x: 120, y: 260 })
if (!instance) throw new Error('Instance not created')
store.select([set.id])
return {
instanceId: instance.id,
byValue: Object.fromEntries(values.map((value, index) => [value, components[index].id]))
}
})
await editor.canvas.waitForRender()
const section = propertySection(editor.page, 'Behaviour')
await section.getByRole('button', { name: 'Add behaviour' }).click()
await editor.page.getByRole('option', { name: /Button/ }).click()
await section.getByRole('combobox', { name: 'States' }).click()
await editor.page.getByRole('option', { name: 'Interaction' }).click()
// Values named like states are mapped to them.
await expect(section.getByRole('combobox', { name: 'Hover state' })).toHaveText(/Hover/)
await expect(section.getByRole('combobox', { name: 'Pressed state' })).toHaveText(/Pressed/)
await expect(section.getByRole('combobox', { name: 'Disabled state' })).toHaveText(/Disabled/)
await startPreview()
// Each state shows its variant's fill: the components are filled by index, as created above.
const fill = (index: number) =>
colorToCSS({ r: 0.2 + index * 0.15, g: 0.4, b: 0.9 - index * 0.15, a: 1 })
const button = island(ids.instanceId).getByRole('button')
await expect(button).toHaveCSS('background-color', fill(0))
await button.hover()
await expect(button).toHaveCSS('background-color', fill(1))
await editor.page.mouse.down()
await expect(button).toHaveCSS('background-color', fill(2))
await editor.page.mouse.up()
await expect(button).toHaveCSS('background-color', fill(1))
await editor.page.mouse.move(4, 400)
await expect(button).toHaveCSS('background-color', fill(0))
await editor.page.keyboard.press('Escape')
await expect(editor.page.getByRole('button', { name: /Leave preview/ })).toHaveCount(0)
})
test('a Text field takes typing in preview without triggering shortcuts', async () => {
const instanceId = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const graph = store.graph
const pageId = store.state.currentPageId
const component = graph.createNode('COMPONENT', pageId, {
name: 'Field',
x: 120,
y: 120,
width: 200,
height: 36,
cornerRadius: 6,
fills: [
{ type: 'SOLID' as const, color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }
],
componentPropertyDefinitions: [
{ id: 'value', name: 'Value', type: 'TEXT', defaultValue: 'Name' }
]
})
graph.createNode('TEXT', component.id, {
name: 'Value',
x: 10,
y: 10,
width: 180,
height: 16,
text: 'Name',
componentPropertyReferences: [{ propertyId: 'value', field: 'TEXT' }]
})
store.setBehaviour(component.id, {
kind: 'textField',
booleans: {},
texts: { value: { propertyId: 'value' } },
numbers: {},
parts: {}
})
const instance = graph.createInstance(component.id, pageId, { x: 120, y: 220 })
if (!instance) throw new Error('Instance not created')
return instance.id
})
await editor.canvas.waitForRender()
await startPreview()
// A real input: typing, the caret, and selection are the browser's.
const input = island(instanceId).locator('input')
await expect(input).toHaveValue('Name')
await input.click()
await editor.page.keyboard.press('End')
// R, T, and V are tool shortcuts while editing.
await editor.page.keyboard.type(' Vera T')
await editor.page.keyboard.press('Home')
await editor.page.keyboard.press('Shift+ArrowRight')
await editor.page.keyboard.type('S')
await expect(input).toHaveValue('Same Vera T')
expect(await editor.page.evaluate(() => window.openPencil?.getStore?.()?.state.activeTool)).toBe(
'SELECT'
)
await editor.page.getByRole('button', { name: /Leave preview/ }).click()
await expect(island(instanceId)).toHaveCount(0)
})
test('a rectangle made a Textarea gets its text layer from the panel and takes typing', async () => {
const componentId = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const component = store.graph.createNode('COMPONENT', store.state.currentPageId, {
name: 'Component',
x: 120,
y: 120,
width: 220,
height: 120,
fills: [
{
type: 'SOLID' as const,
color: { r: 0.85, g: 0.85, b: 0.85, a: 1 },
opacity: 1,
visible: true
}
]
})
store.select([component.id])
return component.id
})
await editor.canvas.waitForRender()
const section = propertySection(editor.page, 'Behaviour')
await section.getByRole('button', { name: 'Add behaviour' }).click()
await editor.page.getByRole('option', { name: /Textarea/ }).click()
await expect(section.getByRole('status')).toHaveText('Still needed: Text.')
await section.getByRole('button', { name: 'Add text layer' }).click()
await expect(section.getByText('Ready', { exact: true })).toBeVisible()
const instanceId = await editor.page.evaluate((id) => {
const store = window.openPencil?.getStore?.()
const instance = store?.graph.createInstance(id, store.state.currentPageId, { x: 120, y: 300 })
return instance?.id ?? ''
}, componentId)
await startPreview()
const field = island(instanceId).locator('textarea')
await field.click()
await editor.page.keyboard.press('End')
await editor.page.keyboard.type(' typed')
await editor.page.keyboard.press('Enter')
await editor.page.keyboard.type('second line')
await expect(field).toHaveValue('Text typed\nsecond line')
})