From e16caea67d26d5d8d62d41fb10f4b6cac9775e4c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 3 Jun 2026 14:40:35 +0300 Subject: [PATCH] test(dom-css): add DOM/CSS package-local suite --- package.json | 4 +- packages/dom-css/README.md | 11 ++ packages/dom-css/package.json | 3 +- packages/dom-css/tests/conversion.test.ts | 95 ++++++++++++++ packages/dom-css/tests/helpers.ts | 146 ++++++++++++++++++++++ packages/dom-css/tests/runtime.test.ts | 66 ++++++++++ packages/dom-css/tests/tailwind.test.ts | 74 +++++++++++ 7 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 packages/dom-css/tests/conversion.test.ts create mode 100644 packages/dom-css/tests/helpers.ts create mode 100644 packages/dom-css/tests/runtime.test.ts create mode 100644 packages/dom-css/tests/tailwind.test.ts diff --git a/package.json b/package.json index e851f5f45..ce25eb300 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/dom-css/README.md b/packages/dom-css/README.md index b31815d90..f341c2573 100644 --- a/packages/dom-css/README.md +++ b/packages/dom-css/README.md @@ -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: diff --git a/packages/dom-css/package.json b/packages/dom-css/package.json index 7a3dd7983..5768dedf5 100644 --- a/packages/dom-css/package.json +++ b/packages/dom-css/package.json @@ -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", diff --git a/packages/dom-css/tests/conversion.test.ts b/packages/dom-css/tests/conversion.test.ts new file mode 100644 index 000000000..67d95b7c9 --- /dev/null +++ b/packages/dom-css/tests/conversion.test.ts @@ -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) + }) +}) diff --git a/packages/dom-css/tests/helpers.ts b/packages/dom-css/tests/helpers.ts new file mode 100644 index 000000000..71d7757ec --- /dev/null +++ b/packages/dom-css/tests/helpers.ts @@ -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 = ` +
+

OpenPencil

+

Design with code-shaped CSS.

+
+` + +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 = ` +
+ + +
+` + +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 diff --git a/packages/dom-css/tests/runtime.test.ts b/packages/dom-css/tests/runtime.test.ts new file mode 100644 index 000000000..671243031 --- /dev/null +++ b/packages/dom-css/tests/runtime.test.ts @@ -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('
') + 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( + '
OpenPencil
' + ) + 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(` + + `) + 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) + }) +}) diff --git a/packages/dom-css/tests/tailwind.test.ts b/packages/dom-css/tests/tailwind.test.ts new file mode 100644 index 000000000..3a4b15d2f --- /dev/null +++ b/packages/dom-css/tests/tailwind.test.ts @@ -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(`

OpenPencil

`), + 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( + ``, + 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(`

OpenPencil

`), + 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) + }) +})