* 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
168 lines
5.3 KiB
TypeScript
168 lines
5.3 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
|
|
import type { evaluateDesignJSX as EvaluateDesignJSX } from '@/app/code/sandbox/evaluate'
|
|
|
|
import { CanvasHelper } from '#tests/helpers/canvas'
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/')
|
|
const canvas = new CanvasHelper(page)
|
|
await canvas.waitForInit()
|
|
})
|
|
|
|
async function evaluate(page: Page, source: string, timeoutMs = 1_000) {
|
|
return page.evaluate(
|
|
async ({ source, timeoutMs }) => {
|
|
const modulePath = '/src/app/code/sandbox/evaluate.ts'
|
|
const { evaluateDesignJSX } = (await import(/* @vite-ignore */ modulePath)) as {
|
|
evaluateDesignJSX: typeof EvaluateDesignJSX
|
|
}
|
|
return evaluateDesignJSX(source, { timeoutMs })
|
|
},
|
|
{ source, timeoutMs }
|
|
)
|
|
}
|
|
|
|
test('evaluates JSX into plain inert data', async ({ page }) => {
|
|
const result = await evaluate(
|
|
page,
|
|
'<Frame w={320} fill={solid("#fff")}><Text>Hello</Text></Frame>'
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
roots: [
|
|
{
|
|
type: 'frame',
|
|
props: {
|
|
w: 320,
|
|
fill: { __openPencilHelper: 'solid', args: ['#fff'] }
|
|
},
|
|
children: [{ type: 'text', props: {}, children: ['Hello'] }]
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
test('supports local constants, function components, arrays, and conditionals', async ({
|
|
page
|
|
}) => {
|
|
const result = await evaluate(
|
|
page,
|
|
`const colors = { surface: '#fff' }
|
|
const Card = ({ title, visible }) => (
|
|
<Frame fill={colors.surface}>
|
|
{visible ? <Text>{title}</Text> : null}
|
|
{[1, 2].map((item) => <Text>{item}</Text>)}
|
|
</Frame>
|
|
)
|
|
<Card title="Hello" visible />`
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
roots: [
|
|
{
|
|
type: 'frame',
|
|
props: { fill: '#fff' },
|
|
children: [
|
|
{ type: 'text', props: {}, children: ['Hello'] },
|
|
{ type: 'text', props: {}, children: [1] },
|
|
{ type: 'text', props: {}, children: [2] }
|
|
]
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
test('converts variable helpers without executing host capabilities', async ({ page }) => {
|
|
const result = await evaluate(
|
|
page,
|
|
`const vars = defineVars({ primary: { name: 'Primary', value: '#ff0000' } })
|
|
<Frame fill={vars.primary}><Text color={designVar('text', '#000')}>Hello</Text></Frame>`
|
|
)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.roots[0]).toMatchObject({
|
|
type: 'frame',
|
|
props: {
|
|
fill: { __openPencilHelper: 'designVar' }
|
|
}
|
|
})
|
|
})
|
|
|
|
test('supports multiple roots through fragments', async ({ page }) => {
|
|
const result = await evaluate(page, '<><Rectangle name="One" /><Ellipse name="Two" /></>')
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
roots: [
|
|
{ type: 'rectangle', props: { name: 'One' }, children: [] },
|
|
{ type: 'ellipse', props: { name: 'Two' }, children: [] }
|
|
]
|
|
})
|
|
})
|
|
|
|
test('cannot access the parent or application origin', async ({ page }) => {
|
|
const result = await evaluate(page, '<Frame>{String(window.parent.document)}</Frame>')
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) expect(result.error).toMatch(/window is not defined|Can't find variable: window/)
|
|
})
|
|
|
|
test('blocks network requests from evaluated code', async ({ page }) => {
|
|
const requests: string[] = []
|
|
page.on('request', (request) => {
|
|
if (request.url().includes('sandbox-probe.invalid')) requests.push(request.url())
|
|
})
|
|
const result = await evaluate(
|
|
page,
|
|
'<Frame>{fetch("https://sandbox-probe.invalid/leak")}</Frame>'
|
|
)
|
|
expect(result.ok).toBe(false)
|
|
expect(requests).toEqual([])
|
|
})
|
|
|
|
test('terminates accidental infinite loops without freezing the host', async ({ page }) => {
|
|
const started = Date.now()
|
|
const result = await evaluate(page, '(() => { while (true) {} })()', 100)
|
|
expect(result).toEqual({ ok: false, error: 'Design JSX execution timed out.' })
|
|
expect(Date.now() - started).toBeLessThan(2_000)
|
|
await expect(page.getByTestId('editor-root')).toBeVisible()
|
|
})
|
|
|
|
test('rejects oversized source and output', async ({ page }) => {
|
|
const sourceResult = await page.evaluate(async () => {
|
|
const modulePath = '/src/app/code/sandbox/evaluate.ts'
|
|
const { evaluateDesignJSX } = (await import(/* @vite-ignore */ modulePath)) as {
|
|
evaluateDesignJSX: typeof EvaluateDesignJSX
|
|
}
|
|
return evaluateDesignJSX('<Text>too large</Text>', { sourceBytes: 4 })
|
|
})
|
|
expect(sourceResult).toEqual({ ok: false, error: 'Design JSX source is too large.' })
|
|
|
|
const outputResult = await page.evaluate(async () => {
|
|
const modulePath = '/src/app/code/sandbox/evaluate.ts'
|
|
const { evaluateDesignJSX } = (await import(/* @vite-ignore */ modulePath)) as {
|
|
evaluateDesignJSX: typeof EvaluateDesignJSX
|
|
}
|
|
return evaluateDesignJSX('<Text>{"x".repeat(100)}</Text>', { outputBytes: 20 })
|
|
})
|
|
expect(outputResult).toEqual({ ok: false, error: 'Design JSX output is too large.' })
|
|
|
|
const arrayResult = await page.evaluate(async () => {
|
|
const modulePath = '/src/app/code/sandbox/evaluate.ts'
|
|
const { evaluateDesignJSX } = (await import(/* @vite-ignore */ modulePath)) as {
|
|
evaluateDesignJSX: typeof EvaluateDesignJSX
|
|
}
|
|
return evaluateDesignJSX(
|
|
'<Frame>{Array.from({ length: 10 }, (_, i) => <Text>{i}</Text>)}</Frame>',
|
|
{
|
|
arrayLength: 5
|
|
}
|
|
)
|
|
})
|
|
expect(arrayResult).toEqual({
|
|
ok: false,
|
|
error: 'Design JSX output contains an array that is too long.'
|
|
})
|
|
})
|