test(dom-css): add DOM/CSS package-local suite
This commit is contained in:
parent
5b61c1bf46
commit
e16caea67d
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
95
packages/dom-css/tests/conversion.test.ts
Normal file
95
packages/dom-css/tests/conversion.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
146
packages/dom-css/tests/helpers.ts
Normal file
146
packages/dom-css/tests/helpers.ts
Normal 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
|
||||
66
packages/dom-css/tests/runtime.test.ts
Normal file
66
packages/dom-css/tests/runtime.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
74
packages/dom-css/tests/tailwind.test.ts
Normal file
74
packages/dom-css/tests/tailwind.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue