* 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.
227 lines
6.9 KiB
TypeScript
227 lines
6.9 KiB
TypeScript
import type { Page, Locator } from '@playwright/test'
|
|
|
|
export class CanvasHelper {
|
|
readonly page: Page
|
|
readonly canvas: Locator
|
|
readonly errors: string[] = []
|
|
|
|
constructor(page: Page) {
|
|
this.page = page
|
|
this.canvas = page.getByTestId('canvas-area')
|
|
page.on('pageerror', (err) => this.errors.push(err.message))
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') this.errors.push(msg.text())
|
|
})
|
|
}
|
|
|
|
assertNoErrors() {
|
|
if (this.errors.length > 0) {
|
|
const messages = this.errors.join('\n')
|
|
this.errors.length = 0
|
|
throw new Error(`Browser errors:\n${messages}`)
|
|
}
|
|
}
|
|
|
|
async waitForRender() {
|
|
await this.page.evaluate(() => new Promise(requestAnimationFrame))
|
|
}
|
|
|
|
async waitForInit() {
|
|
await this.page
|
|
.getByTestId('canvas-element')
|
|
.and(this.page.locator('[data-ready="1"]'))
|
|
.waitFor({ timeout: 30000 })
|
|
await this.page.getByTestId('canvas-loading').waitFor({ state: 'hidden', timeout: 30000 })
|
|
await this.page.locator('#loader').waitFor({ state: 'detached', timeout: 30000 })
|
|
await this.useLightPage()
|
|
}
|
|
|
|
/**
|
|
* Canvas specs draw on Figma's light page. Playwright runs the app in its dark theme, where a new
|
|
* document starts on Figma's dark page, so a fresh document (one empty page on that exact
|
|
* background) is switched back; any other document keeps its own. The switch is setup, not an
|
|
* edit, so the document stays saved and closing it asks nothing.
|
|
* `tests/e2e/pages/theme-background.spec.ts` covers the theme.
|
|
*/
|
|
async useLightPage() {
|
|
await this.page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) return
|
|
const pages = store.graph.getPages()
|
|
const [first] = pages
|
|
const darkDefault = 0x1e / 255
|
|
const { r, g, b } = store.state.pageColor
|
|
const fresh =
|
|
pages.length === 1 &&
|
|
first.childIds.length === 0 &&
|
|
[r, g, b].every((c) => c === darkDefault)
|
|
if (!fresh) return
|
|
store.setPageColor({ r: 0.96, g: 0.96, b: 0.96, a: 1 })
|
|
store.markDocumentSaved()
|
|
})
|
|
}
|
|
|
|
async clearCanvas() {
|
|
await this.selectAll()
|
|
await this.pressKey('Backspace')
|
|
await this.waitForRender()
|
|
}
|
|
|
|
async screenshotCanvas() {
|
|
return this.canvas.screenshot()
|
|
}
|
|
|
|
async screenshotCanvasRegion(width = 900, height = 700) {
|
|
const box = await this.canvas.boundingBox()
|
|
if (!box) throw new Error('Canvas has no bounding box — is it visible?')
|
|
const viewport = this.page.viewportSize()
|
|
if (!viewport) throw new Error('Viewport unavailable')
|
|
return this.page.screenshot({
|
|
clip: {
|
|
x: box.x,
|
|
y: box.y,
|
|
width: Math.min(width, viewport.width - box.x),
|
|
height: Math.min(height, viewport.height - box.y)
|
|
}
|
|
})
|
|
}
|
|
|
|
private async canvasBounds() {
|
|
const b = await this.canvas.boundingBox()
|
|
if (!b) throw new Error('Canvas has no bounding box — is it visible?')
|
|
return b
|
|
}
|
|
|
|
async click(canvasX: number, canvasY: number) {
|
|
const box = await this.canvasBounds()
|
|
await this.page.mouse.click(box.x + canvasX, box.y + canvasY)
|
|
}
|
|
|
|
async drag(fromX: number, fromY: number, toX: number, toY: number, steps = 10) {
|
|
const box = await this.canvasBounds()
|
|
await this.page.mouse.move(box.x + fromX, box.y + fromY)
|
|
await this.page.mouse.down()
|
|
await this.page.mouse.move(box.x + toX, box.y + toY, { steps })
|
|
await this.page.mouse.up()
|
|
}
|
|
|
|
async pressKey(key: string) {
|
|
await this.page.keyboard.press(key)
|
|
}
|
|
|
|
async drawRect(x: number, y: number, width: number, height: number) {
|
|
await this.pressKey('r')
|
|
await this.drag(x, y, x + width, y + height)
|
|
await this.waitForRender()
|
|
}
|
|
|
|
async drawEllipse(x: number, y: number, width: number, height: number) {
|
|
await this.pressKey('o')
|
|
await this.drag(x, y, x + width, y + height)
|
|
await this.waitForRender()
|
|
}
|
|
|
|
async drawSection(x: number, y: number, width: number, height: number) {
|
|
await this.pressKey('s')
|
|
await this.drag(x, y, x + width, y + height)
|
|
await this.waitForRender()
|
|
}
|
|
|
|
async selectTool(
|
|
tool: 'select' | 'frame' | 'section' | 'rectangle' | 'ellipse' | 'text' | 'pen' | 'hand'
|
|
) {
|
|
const keys: Record<string, string> = {
|
|
select: 'v',
|
|
frame: 'f',
|
|
section: 's',
|
|
rectangle: 'r',
|
|
ellipse: 'o',
|
|
text: 't',
|
|
pen: 'p',
|
|
hand: 'h'
|
|
}
|
|
await this.pressKey(keys[tool])
|
|
}
|
|
|
|
async deleteSelection() {
|
|
await this.pressKey('Backspace')
|
|
await this.waitForRender()
|
|
}
|
|
|
|
async undo() {
|
|
await this.pressKey('Meta+z')
|
|
await this.waitForRender()
|
|
}
|
|
|
|
async redo() {
|
|
await this.pressKey('Meta+Shift+z')
|
|
await this.waitForRender()
|
|
}
|
|
|
|
async selectAll() {
|
|
await this.pressKey('Meta+a')
|
|
}
|
|
|
|
async duplicate() {
|
|
await this.pressKey('Meta+d')
|
|
await this.waitForRender()
|
|
}
|
|
|
|
async marquee(x1: number, y1: number, x2: number, y2: number, steps = 10) {
|
|
const box = await this.canvasBounds()
|
|
await this.page.mouse.move(box.x + x1, box.y + y1)
|
|
await this.page.mouse.down()
|
|
await this.page.mouse.move(box.x + x2, box.y + y2, { steps })
|
|
await this.page.mouse.up()
|
|
await this.waitForRender()
|
|
}
|
|
|
|
async hover(x: number, y: number) {
|
|
const box = await this.canvasBounds()
|
|
await this.page.mouse.move(box.x + x, box.y + y)
|
|
await this.waitForRender()
|
|
}
|
|
|
|
/** Point `locator` at the outer NumberField container, not the inner `<input>`. */
|
|
async dragNumberField(locator: Locator, deltaX: number) {
|
|
await locator.scrollIntoViewIfNeeded()
|
|
const box = await locator.boundingBox()
|
|
if (!box) throw new Error('dragNumberField: element has no bounding box')
|
|
const cx = box.x + box.width / 2
|
|
const cy = box.y + box.height / 2
|
|
await this.page.mouse.move(cx, cy)
|
|
await this.page.mouse.down()
|
|
await this.page.mouse.move(cx + deltaX, cy, { steps: 10 })
|
|
await this.page.mouse.up()
|
|
await this.waitForRender()
|
|
}
|
|
|
|
async altDrag(fromX: number, fromY: number, toX: number, toY: number) {
|
|
const box = await this.canvasBounds()
|
|
await this.page.keyboard.down('Alt')
|
|
await this.page.mouse.move(box.x + fromX, box.y + fromY)
|
|
await this.page.mouse.down()
|
|
await this.page.mouse.move(box.x + toX, box.y + toY, { steps: 10 })
|
|
await this.page.mouse.up()
|
|
await this.page.keyboard.up('Alt')
|
|
await this.waitForRender()
|
|
}
|
|
|
|
async shiftDrag(fromX: number, fromY: number, toX: number, toY: number) {
|
|
const box = await this.canvasBounds()
|
|
await this.page.keyboard.down('Shift')
|
|
await this.page.mouse.move(box.x + fromX, box.y + fromY)
|
|
await this.page.mouse.down()
|
|
await this.page.mouse.move(box.x + toX, box.y + toY, { steps: 10 })
|
|
await this.page.mouse.up()
|
|
await this.page.keyboard.up('Shift')
|
|
await this.waitForRender()
|
|
}
|
|
|
|
async dblclick(x: number, y: number) {
|
|
const box = await this.canvasBounds()
|
|
await this.page.mouse.dblclick(box.x + x, box.y + y)
|
|
await this.waitForRender()
|
|
}
|
|
}
|