openpencil/tests/e2e/code/panel.spec.ts
Danila Poyarkov 6193b6da7e
feat(code): add live JSX and HTML/CSS editing (#525)
* feat(code): isolate Design JSX execution

- Transform JSX with the existing Sucrase dependency and execute it in a disposable opaque-origin iframe worker

- Block ambient network capabilities and enforce source, timeout, output, depth, and element limits

- Validate that only bounded plain structured data returns to the application

* feat(code): add editable Design JSX

- Add a lazy CodeMirror editor with JSX syntax support, completion, diagnostics, and bounded scrolling

- Convert validated sandbox output into trusted Design JSX helpers before rendering

- Apply or insert JSX as one undoable graph transaction while preserving dirty drafts

* feat(code): localize JSX editor actions

- Add translated-message fallbacks for editing, applying, inserting, and draft state

- Document the editable JSX workflow in the unreleased changelog

* fix(code): satisfy typed sandbox validation

- Keep the sandbox document terminator literal and preserve optional selection handling under type-aware lint

* test(code): resolve sandbox probes through app aliases

* fix(code): bound sandbox results before cloning

- Enforce string, array, object, depth, element, and byte limits inside the disposable worker

- Retain host-side validation as a second structured-data boundary

* fix(code): keep JSX and HTML editing modes separate

- Switch generated Tailwind output back to OpenPencil JSX before editing

- Close the JSX editor when opening HTML/CSS import and avoid stacking both editors

* feat(code): support authored Design JSX programs

- Allow local constants, function components, arrays, conditionals, fragments, and multiple roots

- Preserve replacement positions for multiple selected roots and reject mixed-parent or locked selections

- Cover multi-root undo, redo, and all-or-nothing rollback

* feat(code): add explicit JSX view mode

- Let authors leave CodeMirror without applying a draft

- Keep the editable surface and HTML/CSS importer mutually exclusive

* feat(code): diagnose unknown JSX vocabulary

- Warn on OpenPencil elements and properties that are absent from the canonical schema

- Surface diagnostics inline through CodeMirror lint markers

* test(code): accept WebKit isolation diagnostics

- Cover the sandbox architecture against Playwright WebKit

- Accept engine-specific wording while preserving the same unavailable-window assertion

* refactor(code): consolidate Design JSX vocabulary

- Drive renderer warnings, completion, and diagnostics from one supported-property schema

- Recognize locally declared components and add focused schema and transform tests

- Replace CodeMirror basicSetup with an explicit feature set and remove the umbrella package

* fix(code): support Design JSX variable helpers

* refactor(code): unify JSX sandbox validation

* fix(code): account for complete sandbox output

* fix(code): preserve locked JSX descendants

* fix(code): preserve JSX sibling order

* fix(code): recompute JSX parent layouts

* feat(code): add live code previews

* test(code): centralize graph assertions

* fix(code): harden live preview sessions

* docs: describe live code editing

* fix(code): update editor accessibility labels

* fix(code): use theme-aware error colors

* fix(dev): stop Vite disconnect error loops

* fix(code): clarify live preview status

* fix(ui): avoid tooltip attribute warnings

* test(code): assert semantic preview status

* refactor(code): remove obsolete editor messages

* fix(code): harden preview concurrency and isolation

* fix(code): cancel stale reset previews
2026-08-15 09:48:42 +03:00

215 lines
8.7 KiB
TypeScript

import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import {
getCodeNodeSummaries,
getFirstSelectedNodeId,
getUndoLabel,
hasNode,
hasNodeNamed,
waitForNode,
waitForNodeNamed
} from '#tests/helpers/code-panel'
const editor = useEditorSetup()
test.beforeEach(async () => {
await editor.page.reload()
await editor.canvas.waitForInit()
})
function codeTab() {
return editor.page.getByTestId('properties-tab-code')
}
function designTab() {
return editor.page.getByTestId('properties-tab-design')
}
function codePanel() {
return editor.page.getByTestId('code-panel-root')
}
function codeEditor() {
return editor.page.locator('[data-slot="code-editor"] .cm-content')
}
async function openCodePanel() {
await codeTab().click()
await expect(codeEditor()).toBeVisible()
}
async function selectSource(label: string) {
await editor.page.getByTestId('code-panel-source').click()
await editor.page.getByRole('option', { name: label }).click()
}
test('inactive Code tab defers JSX generation for large selections', async () => {
const selectionDuration = await editor.page.evaluate(async () => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const ids: string[] = []
for (let frameIndex = 0; frameIndex < 50; frameIndex++) {
const frame = store.graph.createNode('FRAME', store.state.currentPageId, {
name: `Frame ${frameIndex}`
})
ids.push(frame.id)
for (let childIndex = 0; childIndex < 100; childIndex++) {
store.graph.createNode('RECTANGLE', frame.id, { name: `Child ${childIndex}` })
}
}
const startedAt = performance.now()
store.select(ids)
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
return performance.now() - startedAt
})
expect(selectionDuration).toBeLessThan(1000)
await expect(designTab()).toHaveAttribute('data-state', 'active')
await openCodePanel()
await expect(codeEditor()).toContainText('Frame')
})
test('shows one editor with line numbers and no import form', async () => {
await openCodePanel()
await expect(codePanel().locator('[data-slot="code-editor"]')).toHaveCount(1)
await expect(codePanel().locator('.cm-lineNumbers')).toBeVisible()
await expect(codePanel().locator('textarea')).toHaveCount(0)
await expect(codePanel().getByRole('button', { name: /^Import$/ })).toHaveCount(0)
})
test('desktop exposes one Code panel and one editor', async () => {
await openCodePanel()
await expect(codePanel()).toHaveCount(1)
await expect(codePanel().locator('.cm-editor')).toHaveCount(1)
})
test('live previews Design JSX and keeps one undo transaction', async () => {
await editor.canvas.drawRect(100, 100, 200, 150)
const originalId = await getFirstSelectedNodeId(editor.page)
await openCodePanel()
await codeEditor().fill('<Frame name="Live JSX" w={320} h={180} fill="#ffffff" />')
await waitForNodeNamed(editor.page, 'Live JSX')
await codeEditor().fill('<Frame name="Live JSX final" w={360} h={180} fill="#ffffff" />')
await waitForNodeNamed(editor.page, 'Live JSX final')
await designTab().click()
await editor.page.waitForTimeout(50)
await editor.page.keyboard.press('Meta+z')
await waitForNode(editor.page, originalId)
expect(await hasNode(editor.page, originalId)).toBe(true)
expect(await hasNodeNamed(editor.page, 'Live JSX final')).toBe(false)
})
test('Ctrl+Z edits the CodeMirror draft before touching the canvas transaction', async () => {
await openCodePanel()
const initial = await codeEditor().textContent()
await codeEditor().pressSequentially('\n// local draft')
await expect(codeEditor()).toContainText('local draft')
await codeEditor().press('Control+z')
await expect(codeEditor()).not.toContainText('local draft')
expect(await codeEditor().textContent()).toBe(initial)
})
test('keeps the last valid preview while showing invalid-code diagnostics', async () => {
await openCodePanel()
await codeEditor().fill('<Frame name="Last valid" />')
await waitForNodeNamed(editor.page, 'Last valid')
await codeEditor().fill('<Frame>')
const errorAlert = editor.page.getByTestId('code-panel-error')
await expect(errorAlert).toBeVisible()
await expect(errorAlert).toHaveCSS('color', 'rgb(248, 113, 113)')
await editor.page.evaluate(async () => {
const themeModulePath = '/src/app/shell/theme.ts'
const themeModule = await import(themeModulePath)
themeModule.useAppTheme().setTheme('light')
})
await editor.page.waitForFunction(() => document.documentElement.dataset.theme === 'light')
await expect(errorAlert).toHaveCSS('color', 'rgb(185, 28, 28)')
expect(await hasNodeNamed(editor.page, 'Last valid')).toBe(true)
})
test('Reset restores generated source and original canvas state', async () => {
await editor.canvas.drawRect(100, 100, 200, 150)
const originalId = await getFirstSelectedNodeId(editor.page)
await openCodePanel()
await codeEditor().fill('<Frame name="Reset preview" />')
await waitForNodeNamed(editor.page, 'Reset preview')
await expect(editor.page.getByTestId('code-panel-reset').locator('svg')).toHaveCount(1)
await editor.page.getByTestId('code-panel-reset').click()
await waitForNode(editor.page, originalId)
expect(await hasNode(editor.page, originalId)).toBe(true)
await expect(codeEditor()).toContainText('Rectangle')
})
test('HTML/CSS live preview commits as one undoable session', async () => {
await editor.canvas.drawRect(80, 80, 120, 90)
const originalId = await getFirstSelectedNodeId(editor.page)
await openCodePanel()
await selectSource('HTML/CSS')
await codeEditor().fill('<div style="width: 180px; height: 90px">First</div>')
await expect.poll(() => hasNode(editor.page, originalId)).toBe(false)
await codeEditor().fill('<div style="width: 220px; height: 110px">Final</div>')
await expect(editor.page.getByTestId('code-panel-status')).toContainText('Updated live')
await expect(editor.page.getByTestId('code-panel-status')).toHaveAttribute('data-tone', 'success')
await designTab().click()
await expect.poll(() => getUndoLabel(editor.page)).toBe('Edit HTML/CSS')
await editor.page.keyboard.press('Meta+z')
await waitForNode(editor.page, originalId)
expect(await hasNode(editor.page, originalId)).toBe(true)
await editor.page.keyboard.press('Meta+Shift+z')
await expect.poll(() => hasNode(editor.page, originalId)).toBe(false)
})
test('switching source formats commits the current live session', async () => {
await editor.canvas.drawRect(90, 90, 140, 100)
const originalId = await getFirstSelectedNodeId(editor.page)
await openCodePanel()
await codeEditor().fill('<Frame name="Committed on switch" />')
await waitForNodeNamed(editor.page, 'Committed on switch')
await selectSource('HTML/CSS')
await expect(codeEditor()).toContainText('<style>')
await expect.poll(() => getUndoLabel(editor.page)).toBe('Edit JSX')
await editor.page.keyboard.press('Meta+z')
await waitForNode(editor.page, originalId)
})
test('Reset before debounce cancels the pending preview', async () => {
await editor.canvas.drawRect(100, 100, 200, 150)
const originalId = await getFirstSelectedNodeId(editor.page)
await openCodePanel()
await codeEditor().fill('<Frame name="Stale reset preview" />')
await editor.page.getByTestId('code-panel-reset').click()
await editor.page.waitForTimeout(450)
expect(await hasNode(editor.page, originalId)).toBe(true)
expect(await hasNodeNamed(editor.page, 'Stale reset preview')).toBe(false)
})
test('HTML/CSS uses the same editor and live reloads the canvas', async () => {
await openCodePanel()
await selectSource('HTML/CSS')
await expect(codePanel().locator('[data-slot="code-editor"]')).toHaveCount(1)
await expect(codeEditor()).toHaveAttribute('aria-label', 'HTML and CSS')
await codeEditor().fill(
'<style>.card { width: 240px; height: 120px; background: red; }</style><div class="card">Hello</div>'
)
await expect(editor.page.getByTestId('code-panel-status')).toContainText('Updated live')
const importedNodes = await getCodeNodeSummaries(editor.page)
expect(importedNodes.some((node) => node.type !== 'DOCUMENT' && node.type !== 'PAGE')).toBe(true)
})
test('Tailwind JSX is generated read-only in the same editor', async () => {
await editor.canvas.drawRect(100, 100, 200, 150)
await openCodePanel()
await selectSource('Tailwind JSX')
await expect(editor.page.getByTestId('code-panel-status')).toContainText('Generated, read only')
await expect(codeEditor()).toHaveAttribute('contenteditable', 'false')
})
test('copy button works and shows confirmation', async () => {
await openCodePanel()
await editor.page.getByTestId('code-panel-copy').click()
await expect(editor.page.getByTestId('code-panel-copy')).toContainText('Copied')
})