openpencil/tests/e2e/app/menu.spec.ts
Danila Poyarkov 4eb3449b0c
fix(app): keep a closing tab's canvas off the next document (#958)
* fix(app): keep a closing tab's canvas off the next document

Tab components read the editor through useEditorStore, which resolves
to whichever tab is active at each call. When a tab closed, the next
tab became active before the closing canvas finished, so the canvas
registered its renderers with that document and its cleanup removed
them from the wrong one. Every closed document's renderers, WebGL
canvases, and detached editor DOM stayed in the remaining tab: about
1,200 DOM nodes, 320 listeners, and 2.5 MB of heap per open and close
in a production build.

EditorWorkspace now provides its tab's store to app components as it
already did for the SDK's useEditor, and useEditorStore returns it
inside the tab. App-level menus, shortcuts, and services outside any
tab keep following the active tab. Over eight open and close cycles,
DOM nodes, listeners, and the remaining tab's renderers stay flat.

* fix(app): bind the workspace itself to its tab's store

Vue resolves an injection from the parent, so EditorWorkspace could not
inject the store it provides and its own template kept following the
active tab, reading the next document while its tab closed. It now
takes its tab's store directly.

* refactor(app): pass the workspace its tab as a prop

EditorWorkspace read the active tab once during setup and relied on
WorkspaceView keying it by tab to keep that choice. WorkspaceView now
passes the tab, the workspace uses its store directly, and provides it
to the components below, so the binding is explicit in the template.

* test: keep the light-page setup from leaving documents unsaved

CanvasHelper switches a fresh document to Figma's light page so canvas
specs draw on it in the dark theme. Setting the page color counts as an
edit, so every spec's document started unsaved, and closing a tab asked
to save changes nobody made: both tab-close specs hit that dialog. The
store now exposes markDocumentSaved, which reload and save already use,
and the helper takes the light page as the saved state.

* test: wait for a closing menu before opening the next one

A menu stays in the page while it animates out, and a click on a menubar
trigger then is lost, so menu specs failed depending on which tests ran
before them. They now open menus through one helper that waits for the
previous menu to go and for the named menu to appear.

* test: configure storage through the current settings form

Storage settings moved the connection fields behind the provider row
and save them with one Save button, so the workspace spec timed out
looking for an Endpoint field that was not shown.
2026-10-07 21:09:58 +00:00

178 lines
6.6 KiB
TypeScript

import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
const editor = useEditorSetup()
/**
* Opens a top-level menu. A menu that just closed stays in the page while it animates out, and a
* click on a trigger then is lost, so this waits for it to go before clicking.
*/
async function openMenu(name: string) {
await expect(editor.page.getByRole('menu')).toHaveCount(0)
await editor.page.getByRole('menubar').getByRole('menuitem', { name, exact: true }).click()
const menu = editor.page.getByRole('menu', { name })
await expect(menu).toBeVisible()
return menu
}
test('menu bar is visible in browser mode', async () => {
const menubar = editor.page.locator('[role="menubar"]')
await expect(menubar).toBeVisible()
})
test('menu bar has all top-level menus', async () => {
const triggers = editor.page.locator('[role="menubar"] [role="menuitem"]')
const labels = await triggers.allTextContents()
expect(labels).toEqual(['File', 'Edit', 'View', 'Object', 'Text', 'Arrange'])
})
test('File menu opens and shows items', async () => {
const menu = await openMenu('File')
const items = await menu.locator('[role="menuitem"]').allTextContents()
expect(items.some((t) => t.includes('Open'))).toBe(true)
expect(items.some((t) => t.includes('Open storage workspace'))).toBe(true)
expect(items.some((t) => t.includes('Save'))).toBe(true)
expect(items.some((t) => t.includes('Save as'))).toBe(true)
await editor.page.keyboard.press('Escape')
})
test('Edit menu shows Undo/Redo/Delete', async () => {
const menu = await openMenu('Edit')
const items = await menu.locator('[role="menuitem"]').allTextContents()
expect(items.some((t) => t.includes('Undo'))).toBe(true)
expect(items.some((t) => t.includes('Redo'))).toBe(true)
expect(items.some((t) => t.includes('Delete'))).toBe(true)
expect(items.some((t) => t.includes('Select all'))).toBe(true)
await editor.page.keyboard.press('Escape')
})
test('View menu shows zoom options', async () => {
const menu = await openMenu('View')
const items = await menu.locator('[role="menuitem"]').allTextContents()
expect(items.some((t) => t.includes('Zoom to fit'))).toBe(true)
expect(items.some((t) => t.includes('Zoom in'))).toBe(true)
expect(items.some((t) => t.includes('Zoom out'))).toBe(true)
await editor.page.keyboard.press('Escape')
})
test('Object menu shows Group/Ungroup/Component', async () => {
const menu = await openMenu('Object')
const items = await menu.locator('[role="menuitem"]').allTextContents()
expect(items.some((t) => t.includes('Group'))).toBe(true)
expect(items.some((t) => t.includes('Ungroup'))).toBe(true)
expect(items.some((t) => t.includes('Create component'))).toBe(true)
expect(items.some((t) => t.includes('Bring to front'))).toBe(true)
expect(items.some((t) => t.includes('Send to back'))).toBe(true)
await editor.page.keyboard.press('Escape')
})
function getStoreStateNumber(key: 'selectedIds' | 'zoom') {
return editor.page.evaluate((stateKey) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
if (stateKey === 'selectedIds') return store.state.selectedIds.size
return store.state.zoom
}, key)
}
test('Move to page is disabled without a selection', async () => {
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.addPage('Second page')
store.switchPage(store.graph.getPages()[0].id)
})
await openMenu('Object')
const moveToPage = editor.page.getByRole('menuitem', { name: 'Move to page' })
await expect(moveToPage).toHaveAttribute('data-disabled')
await editor.page.keyboard.press('Escape')
await editor.canvas.drawRect(200, 200, 100, 100)
await openMenu('Object')
await expect(editor.page.getByRole('menuitem', { name: 'Move to page' })).not.toHaveAttribute(
'data-disabled'
)
await editor.page.keyboard.press('Escape')
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
for (const id of store.state.selectedIds) store.graph.deleteNode(id)
store.clearSelection()
store.undo.clear()
})
})
test('Undo via Edit menu works', async () => {
await editor.canvas.drawRect(200, 200, 100, 100)
const beforeUndo = await getStoreStateNumber('selectedIds')
expect(beforeUndo).toBe(1)
await openMenu('Edit')
const undoItem = editor.page.getByRole('menuitem', { name: /^Undo\b/ })
await expect(undoItem).toBeEnabled()
await undoItem.click()
await editor.canvas.waitForRender()
const afterUndo = await getStoreStateNumber('selectedIds')
expect(afterUndo).toBe(0)
await openMenu('Edit')
await expect(editor.page.getByRole('menuitem', { name: /^Redo\b/ })).toBeEnabled()
await editor.page.keyboard.press('Escape')
})
test('Duplicate via Edit menu works', async () => {
await editor.canvas.drawRect(300, 300, 80, 80)
const countBefore = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return store.graph.getChildren(store.state.currentPageId).length
})
const editMenu = await openMenu('Edit')
await editMenu.locator('[role="menuitem"]', { hasText: 'Duplicate' }).click()
await editor.canvas.waitForRender()
const countAfter = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return store.graph.getChildren(store.state.currentPageId).length
})
expect(countAfter).toBe(countBefore + 1)
})
test('Zoom to fit via View menu works', async () => {
const zoomInMenu = await openMenu('View')
await zoomInMenu.locator('[role="menuitem"]', { hasText: 'Zoom in' }).click()
await editor.canvas.waitForRender()
const zoomBefore = await getStoreStateNumber('zoom')
expect(zoomBefore).toBeGreaterThan(1)
const zoomToFitMenu = await openMenu('View')
await zoomToFitMenu.locator('[role="menuitem"]', { hasText: 'Zoom to fit' }).click()
await editor.canvas.waitForRender()
const zoomAfter = await getStoreStateNumber('zoom')
expect(zoomAfter).not.toBe(zoomBefore)
})
test('Open storage workspace navigates from the File menu', async () => {
await editor.page.getByRole('menuitem', { name: 'File', exact: true }).click()
await editor.page.getByRole('menuitem', { name: 'Open storage workspace…' }).click()
await expect(editor.page).toHaveURL(/\/$/)
await expect(editor.page.getByTestId('recent-files-home')).toBeVisible()
await expect(editor.page.getByRole('heading', { name: 'Storage workspace' })).toBeVisible()
})