test(dom-css): add DOM/CSS package-local suite

This commit is contained in:
Danila Poyarkov 2026-06-03 14:40:35 +03:00
parent 5b61c1bf46
commit e16caea67d
7 changed files with 396 additions and 3 deletions

View file

@ -13,8 +13,8 @@
"preview": "vite preview",
"tauri": "tauri",
"lint": "bun run lint:structure && oxlint -c oxlint.json --type-aware --type-check src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/dom-css/src/",
"lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/dom-css/src/ tests/ scripts/ tools/",
"format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/vue/src/ packages/dom-css/src/ tests scripts/ tools/",
"lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/dom-css/src/ packages/dom-css/tests/ tests/ scripts/ tools/",
"format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/vue/src/ packages/dom-css/src/ packages/dom-css/tests/ tests scripts/ tools/",
"format:check": "bun run format && status=$(git status --porcelain -uall) && test -z \"$status\" || (echo \"$status\" && exit 1)",
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:i18n && bun run check:packages && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
"check:i18n": "bun tools/i18n/src/check-locales.ts",

View file

@ -4,6 +4,17 @@ DOM and CSS projection utilities for OpenPencil.
This package is the compatibility layer between OpenPencil's scene graph and DOM-shaped design documents. It is intentionally separate from `@open-pencil/core` so browser/CSS parser integrations can evolve without adding DOM dependencies to the renderer and editor core.
## Testing
This package has a package-local test suite so it can be validated independently from the app shell:
```sh
cd packages/dom-css
bun run test
```
The repository also keeps integration/oracle coverage under `tests/engine/dom-css` and `tests/e2e/dom-css`. The package-local suite focuses on the library's public API, while the repo-level E2E suite verifies browser `getComputedStyle()` parity through Playwright.
## Runtime model
Use the browser runtime as the high-fidelity source of truth whenever a DOM is available. It uses native parsing and `getComputedStyle()` inside an isolated sandbox:

View file

@ -27,7 +27,8 @@
],
"scripts": {
"build": "bunx tsdown --config tsdown.config.ts",
"prepack": "bun run build"
"prepack": "bun run build",
"test": "bun test tests"
},
"repository": {
"type": "git",

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from 'bun:test'
import {
createHeadlessCSSRuntime,
designDocumentToSceneGraph,
htmlToDesignDocument,
htmlToSceneGraph,
sceneGraphToDesignDocument,
serializeHTML
} from '../src/index'
import { cardCSS, cardHTML, fixtureCSS, fixtureHTML } from './helpers'
describe('@open-pencil/dom-css conversion', () => {
it('converts HTML and CSS to DesignDOM with one API call', async () => {
const document = await htmlToDesignDocument(cardHTML, {
cssText: cardCSS,
runtime: createHeadlessCSSRuntime()
})
const card = document.children[0]
expect(card?.type).toBe('element')
if (card?.type !== 'element') return
expect(card.computedStyle?.width).toBe('320px')
expect(card.computedStyle?.['border-radius']).toBe('16px')
})
it('converts HTML and CSS to a scene graph with one API call', async () => {
const graph = await htmlToSceneGraph(cardHTML, {
cssText: cardCSS,
runtime: createHeadlessCSSRuntime()
})
const page = graph.getPages()[0]
const card = page ? graph.getChildren(page.id)[0] : undefined
expect(card?.type).toBe('FRAME')
if (card?.type !== 'FRAME') return
expect(card.width).toBe(320)
expect(card.height).toBe(180)
expect(card.layoutMode).toBe('VERTICAL')
expect(card.itemSpacing).toBe(12)
expect(card.paddingLeft).toBe(24)
expect(card.cornerRadius).toBe(16)
expect(card.effects[0]?.type).toBe('DROP_SHADOW')
})
it('keeps box-like inline controls as editable frames', async () => {
const graph = await htmlToSceneGraph(fixtureHTML, {
cssText: fixtureCSS,
runtime: createHeadlessCSSRuntime()
})
const page = graph.getPages()[0]
const shell = page ? graph.getChildren(page.id)[0] : undefined
expect(shell?.type).toBe('FRAME')
if (shell?.type !== 'FRAME') return
const [navbar, input] = graph.getChildren(shell.id)
expect(navbar?.type).toBe('FRAME')
expect(input?.type).toBe('FRAME')
if (navbar?.type !== 'FRAME' || input?.type !== 'FRAME') return
expect(navbar.primaryAxisAlign).toBe('SPACE_BETWEEN')
expect(input.width).toBe(312)
expect(input.paddingLeft).toBe(12)
const badge = graph.getChildren(navbar.id)[1]
expect(badge?.type).toBe('FRAME')
if (badge?.type !== 'FRAME') return
expect(badge.cornerRadius).toBe(9999)
expect(graph.getChildren(badge.id)[0]?.type).toBe('TEXT')
})
it('projects scene graph output back into DesignDOM HTML', async () => {
const graph = await htmlToSceneGraph(cardHTML, {
cssText: cardCSS,
runtime: createHeadlessCSSRuntime()
})
const document = sceneGraphToDesignDocument(graph)
const html = serializeHTML(document)
expect(html).toContain('OpenPencil')
expect(html).toContain('box-shadow')
})
it('projects a manually built DesignDOM document into a scene graph', async () => {
const runtime = createHeadlessCSSRuntime()
const document = await runtime.computeStyles(runtime.parseHTML(cardHTML), cardCSS)
const graph = designDocumentToSceneGraph(document)
const page = graph.getPages()[0]
const card = page ? graph.getChildren(page.id)[0] : undefined
expect(card?.type).toBe('FRAME')
if (card?.type !== 'FRAME') return
expect(card.fills[0]?.type).toBe('SOLID')
expect(card.strokes[0]?.weight).toBe(1)
})
})

View file

@ -0,0 +1,146 @@
import { colorToCSS } from '@open-pencil/core/color'
import type { DesignDocument } from '../src/index'
export const TEST_COLORS = {
white: colorToCSS({ r: 1, g: 1, b: 1, a: 1 }),
slate950: colorToCSS({ r: 2 / 255, g: 6 / 255, b: 23 / 255, a: 1 }),
slate900: colorToCSS({ r: 17 / 255, g: 24 / 255, b: 39 / 255, a: 1 }),
slate700: colorToCSS({ r: 31 / 255, g: 41 / 255, b: 55 / 255, a: 1 }),
slate200: colorToCSS({ r: 226 / 255, g: 232 / 255, b: 240 / 255, a: 1 }),
slateShadow: colorToCSS({ r: 15 / 255, g: 23 / 255, b: 42 / 255, a: 0.16 }),
sky100: colorToCSS({ r: 224 / 255, g: 242 / 255, b: 254 / 255, a: 1 }),
sky700: colorToCSS({ r: 3 / 255, g: 105 / 255, b: 161 / 255, a: 1 })
} as const
export const cardDocument: DesignDocument = {
type: 'document',
children: [
{
type: 'element',
tagName: 'article',
attrs: { class: 'card' },
children: [
{
type: 'element',
tagName: 'h1',
attrs: { class: 'title' },
children: [{ type: 'text', text: 'OpenPencil' }]
}
]
}
]
}
export const cardHTML = `
<article class="card">
<h1 class="title">OpenPencil</h1>
<p class="description">Design with code-shaped CSS.</p>
</article>
`
export const cardCSS = `
.card {
display: flex;
flex-direction: column;
gap: 12px;
width: 320px;
height: 180px;
padding: 24px;
border: 1px solid ${TEST_COLORS.slate200};
border-radius: 16px;
background: ${TEST_COLORS.white};
box-shadow: 0px 16px 40px 0px ${TEST_COLORS.slateShadow};
color: ${TEST_COLORS.slate900};
}
.title {
font-size: 24px;
font-weight: 700;
line-height: 32px;
}
.description {
font-size: 14px;
line-height: 20px;
color: ${TEST_COLORS.slate700};
}
`
export const fixtureHTML = `
<section class="shell">
<nav class="navbar">
<span class="brand">OpenPencil</span>
<span class="badge">Beta</span>
</nav>
<input class="input" value="https://openpencil.dev" />
</section>
`
export const fixtureCSS = `
.shell {
display: flex;
flex-direction: column;
gap: 24px;
width: 480px;
padding: 32px;
background: ${TEST_COLORS.white};
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
width: 416px;
height: 48px;
padding: 0 16px;
border: 1px solid ${TEST_COLORS.slate200};
border-radius: 12px;
}
.brand {
font-size: 16px;
font-weight: 700;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
padding: 0 10px;
border-radius: 9999px;
background: ${TEST_COLORS.sky100};
color: ${TEST_COLORS.sky700};
font-size: 12px;
font-weight: 600;
}
.input {
width: 312px;
height: 40px;
padding: 0 12px;
border: 1px solid ${TEST_COLORS.slate200};
border-radius: 8px;
color: ${TEST_COLORS.slate950};
font-size: 14px;
}
`
export const tailwindCardClasses = [
'flex',
'flex-col',
'gap-3',
'w-80',
'h-44',
'p-6',
'rounded-xl',
'bg-white',
'text-slate-900'
] as const
export const tailwindInputClasses = [
'h-10',
'w-80',
'rounded-md',
'border',
'border-slate-300',
'bg-white',
'px-3',
'text-sm',
'text-slate-900'
] as const

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'bun:test'
import { createCSSRuntime, createHeadlessCSSRuntime, serializeHTML } from '../src/index'
import { cardDocument, TEST_COLORS } from './helpers'
describe('@open-pencil/dom-css runtime', () => {
it('serializes DesignDOM as HTML', () => {
expect(serializeHTML(cardDocument)).toContain('<article class="card">')
expect(serializeHTML(cardDocument)).toContain('OpenPencil')
})
it('uses the headless runtime outside browser contexts', () => {
const runtime = createCSSRuntime()
expect(runtime.kind).toBe('headless')
expect(runtime.serializeHTML(cardDocument)).toContain('OpenPencil')
})
it('parses HTML with inline styles', () => {
const runtime = createHeadlessCSSRuntime()
const document = runtime.parseHTML(
'<section class="card" style="width: 320px; color: rgb(17, 24, 39)">OpenPencil</section>'
)
const section = document.children[0]
expect(section?.type).toBe('element')
if (section?.type !== 'element') return
expect(section.tagName).toBe('section')
expect(section.attrs.class).toBe('card')
expect(section.inlineStyle?.width).toBe('320px')
expect(section.inlineStyle?.color).toBe('rgb(17, 24, 39)')
expect(section.children[0]).toEqual({ type: 'text', text: 'OpenPencil' })
})
it('computes selector specificity, inheritance, and shorthands', async () => {
const runtime = createHeadlessCSSRuntime()
const parsed = runtime.parseHTML(`
<article id="hero" class="card featured">
<header><h1 class="title">OpenPencil</h1></header>
</article>
`)
const document = await runtime.computeStyles(
parsed,
`
article { color: ${TEST_COLORS.slate900}; padding: 8px 16px; }
.card { width: 300px; color: ${TEST_COLORS.slate700}; }
article.card > header { gap: 12px; }
.card .title { font-size: 24px; }
#hero { width: 320px; background: white; }
`
)
const card = document.children[0]
expect(card?.type).toBe('element')
if (card?.type !== 'element') return
expect(card.computedStyle?.width).toBe('320px')
expect(card.computedStyle?.color).toBe(TEST_COLORS.slate700)
expect(card.computedStyle?.['padding-right']).toBe('16px')
const header = card.children.find((child) => child.type === 'element')
expect(header?.type).toBe('element')
if (header?.type !== 'element') return
expect(header.computedStyle?.gap).toBe('12px')
expect(header.computedStyle?.color).toBe(TEST_COLORS.slate700)
})
})

View file

@ -0,0 +1,74 @@
import { describe, expect, it } from 'bun:test'
import {
compileTailwindCSS,
createHeadlessCSSRuntime,
designDocumentToSceneGraph,
tailwindHTMLToSceneGraph
} from '../src/index'
import { tailwindCardClasses, tailwindInputClasses } from './helpers'
describe('@open-pencil/dom-css Tailwind', () => {
it('compiles utility candidates through Tailwind', async () => {
const css = await compileTailwindCSS(['flex', 'w-80', 'p-6', 'rounded-xl'])
expect(css).toContain('.flex')
expect(css).toContain('.w-80')
expect(css).toContain('.p-6')
expect(css).toContain('.rounded-xl')
})
it('feeds Tailwind generated CSS through headless style computation', async () => {
const runtime = createHeadlessCSSRuntime()
const classes = [...tailwindCardClasses]
const document = await runtime.computeStyles(
runtime.parseHTML(`<article class="${classes.join(' ')}"><h1>OpenPencil</h1></article>`),
await compileTailwindCSS(classes)
)
const card = document.children[0]
expect(card?.type).toBe('element')
if (card?.type !== 'element') return
expect(card.computedStyle?.width).toBe('320px')
expect(card.computedStyle?.height).toBe('176px')
expect(card.computedStyle?.padding).toBe('24px')
expect(card.computedStyle?.['border-radius']).toBe('0.75rem')
})
it('converts Tailwind HTML to scene graph frames', async () => {
const classes = [...tailwindInputClasses]
const graph = await tailwindHTMLToSceneGraph(
`<input class="${classes.join(' ')}" value="https://openpencil.dev" />`,
classes,
{ runtime: createHeadlessCSSRuntime() }
)
const page = graph.getPages()[0]
const input = page ? graph.getChildren(page.id)[0] : undefined
expect(input?.type).toBe('FRAME')
if (input?.type !== 'FRAME') return
expect(input.width).toBe(320)
expect(input.height).toBe(40)
expect(input.paddingLeft).toBe(12)
expect(input.cornerRadius).toBe(6)
expect(input.strokes[0]?.weight).toBe(1)
})
it('allows callers to compose Tailwind CSS with the lower-level conversion API', async () => {
const runtime = createHeadlessCSSRuntime()
const classes = [...tailwindCardClasses]
const document = await runtime.computeStyles(
runtime.parseHTML(`<article class="${classes.join(' ')}"><h1>OpenPencil</h1></article>`),
await compileTailwindCSS(classes)
)
const graph = designDocumentToSceneGraph(document)
const page = graph.getPages()[0]
const card = page ? graph.getChildren(page.id)[0] : undefined
expect(card?.type).toBe('FRAME')
if (card?.type !== 'FRAME') return
expect(card.width).toBe(320)
expect(card.layoutMode).toBe('VERTICAL')
expect(card.itemSpacing).toBe(12)
})
})