2026-06-03 11:40:35 +00:00
|
|
|
import { describe, expect, it } from 'bun:test'
|
|
|
|
|
|
2026-07-04 16:37:58 +00:00
|
|
|
import {
|
|
|
|
|
createCSSRuntime,
|
|
|
|
|
createHeadlessCSSRuntime,
|
|
|
|
|
exportHTMLBundle,
|
|
|
|
|
serializeHTML
|
|
|
|
|
} from '../src/index'
|
2026-06-03 11:40:35 +00:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
|
refactor!: move shared primitives below dom-css and core (#771)
* refactor!: move shared primitives below dom-css and core
dom-css depended on core for color conversion, base64 helpers, text
direction, and web-font assets, so core could not use dom-css and every
caller special-cased HTML and Tailwind output.
Color conversion and management, base64 helpers, and text/layout
direction now live in scene-graph under `color`, `bytes`, and
`text-direction`. dom-css takes web-font resolution as an injected
`fonts` option and owns the font face types, so it depends only on
scene-graph and core can depend on it.
BREAKING CHANGE: `@open-pencil/core/color` and `@open-pencil/core/bytes`
are removed, and the direction helpers are no longer exported from
`@open-pencil/core/text`; import them from `@open-pencil/scene-graph`
subpaths. `exportHTMLBundle` takes a font resolver in `fonts` instead of
`'assets'`.
* fix(tools): import color parsing from scene-graph in visual bisect
* fix(mcp): declare the scene-graph dependency
MCP imports `@open-pencil/scene-graph/bytes` since base64 helpers moved
there, but only reached scene-graph through core, so isolated installs
and package checks depended on transitive resolution.
* refactor!: use js-base64 directly instead of a base64 wrapper
Base64 helpers had moved into scene-graph only to sit below dom-css,
but they are a thin wrapper over js-base64 and unrelated to the graph;
fig already called js-base64 directly.
Callers use js-base64 and check `isValid` where input comes from outside
(clipboard, imported HTML, tool arguments, the plugin API). A new
`open-pencil/no-hand-rolled-base64` lint rule rejects atob, btoa, and
Buffer Base64 conversions, and AGENTS.md records the convention.
BREAKING CHANGE: `@open-pencil/core/bytes` is removed; use `js-base64`.
* fix(dom-css): keep images with invalid Base64 inline in HTML export
`exportHTMLBundle` accepts documents parsed from outside HTML, and
js-base64 drops characters it cannot decode, so extracting an invalid
image data URL wrote different bytes. Such images now stay inline.
2026-09-26 07:39:11 +00:00
|
|
|
it('keeps images with invalid Base64 inline instead of extracting them', async () => {
|
|
|
|
|
const src = 'data:image/png;base64,A!QID'
|
|
|
|
|
const bundle = await exportHTMLBundle(
|
|
|
|
|
{
|
|
|
|
|
type: 'document',
|
|
|
|
|
children: [{ type: 'element', tagName: 'img', attrs: { src }, children: [] }]
|
|
|
|
|
},
|
|
|
|
|
{ html: 'standalone', assets: 'external' }
|
|
|
|
|
)
|
|
|
|
|
const page = bundle.files.find((file) => file.path === bundle.entrypoint)
|
|
|
|
|
|
|
|
|
|
expect(bundle.files.some((file) => file.path.includes('/images/'))).toBe(false)
|
|
|
|
|
expect(String(page?.content)).toContain(src)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-04 10:01:59 +00:00
|
|
|
it('serializes inline styles as Tailwind classes when requested', () => {
|
|
|
|
|
const html = serializeHTML(
|
|
|
|
|
{
|
|
|
|
|
type: 'document',
|
|
|
|
|
children: [
|
|
|
|
|
{
|
|
|
|
|
type: 'element',
|
|
|
|
|
tagName: 'section',
|
|
|
|
|
attrs: { class: 'card' },
|
|
|
|
|
inlineStyle: {
|
|
|
|
|
display: 'flex',
|
|
|
|
|
padding: '16px',
|
|
|
|
|
gap: '8px',
|
|
|
|
|
'background-color': 'white'
|
|
|
|
|
},
|
|
|
|
|
children: [{ type: 'text', text: 'OpenPencil' }]
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
{ style: 'tailwind' }
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(html).toBe('<section class="card flex p-4 gap-2 bg-white">OpenPencil</section>')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-04 16:37:58 +00:00
|
|
|
it('exports standalone HTML documents when requested', async () => {
|
|
|
|
|
const bundle = await exportHTMLBundle(cardDocument, { html: 'standalone' })
|
|
|
|
|
const html = String(bundle.files[0]?.content)
|
2026-07-04 11:54:58 +00:00
|
|
|
|
|
|
|
|
expect(html).toContain('<!doctype html>')
|
|
|
|
|
expect(html).toContain('data-open-pencil-html="standalone"')
|
|
|
|
|
expect(html).toContain('OpenPencil')
|
|
|
|
|
expect(html).not.toContain('@tailwindcss/browser@4')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-04 16:37:58 +00:00
|
|
|
it('precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
|
|
|
|
|
const bundle = await exportHTMLBundle(cardDocument, { html: 'standalone', style: 'tailwind' })
|
|
|
|
|
const html = String(bundle.files[0]?.content)
|
2026-07-04 11:54:58 +00:00
|
|
|
|
2026-07-04 16:37:58 +00:00
|
|
|
expect(html).toContain('<style>')
|
|
|
|
|
expect(html).not.toContain('@tailwindcss/browser@4')
|
2026-07-04 11:54:58 +00:00
|
|
|
})
|
|
|
|
|
|
2026-06-03 11:40:35 +00:00
|
|
|
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' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-06 16:32:18 +00:00
|
|
|
it('parses inline style values with embedded semicolons', () => {
|
|
|
|
|
const runtime = createHeadlessCSSRuntime()
|
|
|
|
|
const document = runtime.parseHTML(
|
|
|
|
|
'<section style="background-image: url(\'data:image/svg+xml;utf8,<svg></svg>\'); width: 320px">OpenPencil</section>'
|
|
|
|
|
)
|
|
|
|
|
const section = document.children[0]
|
|
|
|
|
|
|
|
|
|
expect(section?.type).toBe('element')
|
|
|
|
|
if (section?.type !== 'element') return
|
|
|
|
|
expect(section.inlineStyle?.['background-image']).toBe(
|
|
|
|
|
"url('data:image/svg+xml;utf8,<svg></svg>')"
|
|
|
|
|
)
|
|
|
|
|
expect(section.inlineStyle?.width).toBe('320px')
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-03 11:40:35 +00:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|