#!/usr/bin/env node /* test-v0140.mjs: the fixes and features from a review by someone building a game on DSSL (0.14.0). * Every item they listed has an assertion here, run against real Chrome where the browser decides the answer. */ import { spawnSync } from 'node:child_process'; import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { compile } from '../public/dssl/index.js'; import * as API from '../public/dssl/index.js'; import { withBrowser, ROOT } from './lib/browser-harness.mjs'; import { decodePng, px, dist } from './lib/pixels.mjs'; let pass = 0, fail = 0; const ok = (name, cond, extra) => { if (cond) { pass++; console.log(` ok ${name}`); } else { fail++; console.log(`FAIL ${name}${extra !== undefined ? '; ' + extra : ''}`); } }; const section = (t) => console.log(`\n== ${t}`); const page = (dssl, body = '', head = '') => `
${head}${body}`; const ready = (p) => p.waitForFunction(`window.DSSL && DSSL.__debug.engines.get('global') && document.documentElement.classList.contains('dssl-ready')`); const eng = `DSSL.__debug.engines.get('global')`; const errs = (src) => compile(src, 't.dssl').errors.map((e) => e.message); const warns = (src) => compile(src, 't.dssl').warnings.map((w) => w.message); section('1. reduced motion is never silent'); { const MOTION = 'motion bob { from { translate: 0 0 } to { translate: 0 10px } }\nmotion boing { from { translate: 0 0 } to { translate: 0 10px } essential }'; await withBrowser(async ({ open, errors }) => { const logs = []; const p = await open('/r'); p.on('console', (m) => logs.push(m.text())); await ready(p); await p.waitForTimeout(400); ok('with reduced motion on, a plain animate: does not run', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'plain').length) === 0); ok('a motion marked essential does', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'ess').length) === 1); ok('so does one marked essential on the animate: line', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'line').length) === 1); const told = await p.evaluate(() => 0); void told; await p.evaluate(() => DSSL.refresh()); await p.waitForTimeout(100); // the notice came when the engine skipped it, before the listener above was attached: reload and listen from the start const q = await open('/r'); const l2 = []; q.on('console', (m) => l2.push(m.text())); await q.reload(); await ready(q); await q.waitForTimeout(300); ok('and it SAYS so, once, in plain words, with the way out', l2.filter((t) => /reduced motion/.test(t)).length === 1 && /essential/.test(l2.find((t) => /reduced motion/.test(t)) || '') && /data-dssl-motion/.test(l2.find((t) => /reduced motion/.test(t)) || ''), JSON.stringify(l2)); await p.evaluate(() => DSSL.motion('full')); await p.waitForTimeout(300); ok('DSSL.motion("full") runs every motion whatever the system asks for', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'plain').length) === 1); await p.evaluate(() => DSSL.motion('reduced')); await p.waitForTimeout(300); ok('"reduced" holds the plain ones still again', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'plain').length) === 0); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { pages: { '/r': page(`${MOTION}\n#plain { animate: bob 800 infinite alternate }\n#ess { animate: boing 800 infinite alternate }\n#line { animate: bob 800 infinite alternate essential }`, '') }, reducedMotion: 'reduce' }); await withBrowser(async ({ open }) => { const p = await open('/f'); await ready(p); await p.waitForTimeout(300); ok(' in the markup overrides the system from the first frame', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'plain').length) === 1); }, { pages: { '/f': page(`${MOTION}\n#plain { animate: bob 800 infinite alternate }`, '').replace('document.documentElement.setAttribute("data-dssl-motion","full") compile(src, 't').doc.rules.map((r) => r.decls.find((d) => d.prop === '@animate')).filter(Boolean).map((d) => d.value)[0]; const s = spec('motion w { from { opacity: 0 } to { opacity: 1 } }\n.x { animate: w 300 delay 100 jitter 200 iterations 3 essential }'); ok('animate: reads delay, jitter, iterations and essential', s.delay === 100 && s.jitter === 200 && s.iterations === 3 && s.essential === true, JSON.stringify(s)); ok('a bare number after the duration is still the delay', spec('motion w { from { opacity: 0 } to { opacity: 1 } }\n.x { animate: w 300 250 }').delay === 250); ok('delay without a number is an error that shows the form', errs('motion w { from { opacity: 0 } to { opacity: 1 } }\n.x { animate: w 300 delay }').some((m) => /delay needs a number/.test(m))); const fr = compile('motion w { 0%, 100% { opacity: 1 } 40% { opacity: 0 } }', 't').doc.motionFrames.w.frames.map((f) => f.offset); ok('keyframes accept 0%, 40% and a list, next to from, to and at 40%', JSON.stringify(fr) === '[0,40,100]', JSON.stringify(fr)); ok('a keyframe past 100% is refused', errs('motion w { 120% { opacity: 1 } }\n.x { animate: w 1 }').some((m) => /bad keyframe selector/.test(m))); const w = warns('motion w { from { opacity: 0 } to { opacity: 1 } }\n.y { animate: w 400; animation-delay: 200ms }'); ok('animation-delay on a rule that uses animate: gets a warning that names the replacement', w.length === 1 && /does nothing here/.test(w[0]), JSON.stringify(w)); ok('but not when the rule has a CSS animation to tune', warns('.y { animation: foo 400ms; animation-delay: 200ms }').filter((m) => /does nothing/.test(m)).length === 0); await withBrowser(async ({ open, errors }) => { const p = await open('/m'); await ready(p); await p.waitForTimeout(300); ok('a motion defined in one stylesheet runs from another (shared registry)', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'b').length) === 1); const e = await p.evaluate(() => DSSL.errors().map((x) => x.message)); ok('with no unknown-motion error', !e.some((m) => /unknown motion/.test(m)), JSON.stringify(e)); ok('the pseudo-element animation really runs (a CSS animation, in the engine\'s stylesheet)', await p.evaluate(() => document.getAnimations().some((a) => a.effect.target.id === 'c' && a.effect.pseudoElement === '::before'))); ok('every motion is also a real @keyframes, so animation: bob 300ms works too', await p.evaluate(() => document.getAnimations().some((a) => a.effect.target.id === 'css' && a.animationName === 'bob'))); // play() const t0 = Date.now(); const r = await p.evaluate(async () => { const el = document.getElementById('c'); const a = await DSSL.play(el, 'bob', { duration: 120 }); return { state: a.playState, id: a.effect.target.id }; }); ok('DSSL.play returns a promise that settles when the motion ends', r.state === 'finished' && r.id === 'c' && Date.now() - t0 >= 100, JSON.stringify(r)); const held = await p.evaluate(async () => { const el = document.getElementById('c'); await DSSL.play(el, 'bob', { duration: 80, hold: true }); return getComputedStyle(el).translate; }); ok('hold: true keeps the final frame', held === '0px 10px', held); const unheld = await p.evaluate(async () => { const el = document.body.appendChild(document.createElement('div')); await DSSL.play(el, 'bob', { duration: 80 }); return getComputedStyle(el).translate; }); ok('without it the element snaps back', unheld === 'none' || unheld === '0px', unheld); const bad = await p.evaluate(async () => { try { await DSSL.play(document.body, 'bobb'); return 'resolved'; } catch (e) { return e.message; } }); ok('an unknown motion rejects, with did-you-mean', /unknown motion "bobb"/.test(bad) && /did you mean "bob"/.test(bad), bad); const cancelled = await p.evaluate(async () => { const el = document.getElementById('c'); const pr = DSSL.play(el, 'bob', { duration: 5000 }); el.getAnimations().forEach((a) => a.cancel()); const a = await pr; return a.playState; }); ok('a cancelled animation settles the promise too, so awaiting never hangs', cancelled === 'idle'); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { pages: { '/m': `` }, reducedMotion: 'no-preference' }); await withBrowser(async ({ open }) => { const p = await open('/j'); await ready(p); await p.waitForTimeout(300); const delays = await p.evaluate(() => [...document.getAnimations()].map((a) => a.effect.getTiming().delay)); ok('jitter gives every element its own delay (not one shared value)', new Set(delays).size > 3 && delays.every((d) => d >= 0 && d <= 600), JSON.stringify(delays)); await p.reload(); await ready(p); await p.waitForTimeout(300); const again = await p.evaluate(() => [...document.getAnimations()].map((a) => a.effect.getTiming().delay)); ok('and the same page gets the same delays every time', JSON.stringify(delays) === JSON.stringify(again)); }, { pages: { '/j': page('motion bob { from { translate: 0 0 } to { translate: 0 10px } }\n.coin { animate: bob 800 infinite alternate jitter 600 }', Array.from({ length: 8 }, () => '').join('')) } }); } section('3. values and gradients'); { const decls = (src) => { const r = compile(src, 't'); return { e: r.errors.map((x) => x.message), d: r.doc.rules.flatMap((x) => x.decls.filter((y) => !y.prop.startsWith('--')).map((y) => `${y.prop}: ${y.value}`)) }; }; let r = decls('tokens { shadow { pop: 0 4 0 #000 } radius { pill: 99 } }\n.a { shadow: pop; radius: pill }'); ok('shadow: pop and radius: pill work for your own tokens', r.e.length === 0 && r.d.includes('box-shadow: var(--dssl-shadow-pop)') && r.d.includes('border-radius: var(--dssl-radius-pill)'), JSON.stringify(r)); ok('and the built-in names still do', decls('.a { shadow: sm; radius: lg }').d.join().includes('--dssl-shadow-sm')); ok('a name that is not a token is still an error, not a CSS keyword guess', decls('.a { shadow: nothing }').e.length === 1); r = decls('.a { paint-order: stroke fill; background: conic-gradient(from 0deg, red, blue) }'); ok('paint-order: stroke fill and conic-gradient(from 0deg, ...) are accepted', r.e.length === 0, JSON.stringify(r.e)); r = decls('.a { text-stroke: 6 color.ink }'); ok('text-stroke writes the outline and paint-order', r.e.length === 0 && r.d.some((x) => /^-webkit-text-stroke: 6px /.test(x)) && r.d.includes('paint-order: stroke fill'), JSON.stringify(r)); ok('text-stroke inside keeps a centred stroke; none clears it', !decls('.a { text-stroke: 2 #fff inside }').d.includes('paint-order: stroke fill') && decls('.a { text-stroke: none }').d.includes('-webkit-text-stroke: 0')); ok('text-stroke needs a width', decls('.a { text-stroke: color.ink }').e.length === 1); r = decls('.t { gradient: linear 180, #e33 -> #a11 + radial-gradient(circle, #fff4 20%, transparent 21%) tile 24 offset 4 4 no-repeat }'); ok('gradient: layers a sentence and a plain CSS gradient, with tile, offset and no-repeat for the second', r.e.length === 0 && r.d[0].startsWith('background-image: linear-gradient(180deg, #e33, #a11), radial-gradient(circle') && r.d.includes('background-size: auto, 24px 24px') && r.d.includes('background-position: 0 0, 4px 4px') && r.d.includes('background-repeat: repeat, no-repeat'), JSON.stringify(r)); ok('a layer with no tile leaves size alone', !decls('.t { gradient: linear 90, #f00 -> #00f }').d.some((x) => /background-size/.test(x))); const w = warns('.u { gradient: linear 90, #f00 -> #00f; background-image: radial-gradient(red, blue) }'); ok('gradient: and background-image: in one rule is a warning that says how to layer', w.length === 1 && /sets the background image twice/.test(w[0]), JSON.stringify(w)); ok('gradient: then a background shorthand with an image warns too', warns('.u { gradient: linear 90, #f00 -> #00f; background: url(x.png) }').some((m) => /twice/.test(m))); ok('a rule with just one of them does not', warns('.u { gradient: linear 90, #f00 -> #00f; background-color: #fff }').length === 0); } section('4. your inline styles win'); await withBrowser(async ({ open, errors }) => { const p = await open('/i'); await ready(p); await p.waitForTimeout(300); const get = (sel, prop) => p.evaluate(([s, pr]) => getComputedStyle(document.querySelector(s))[pr], [sel, prop]); ok('a style="" attribute beats a rule that sets the same property', await get('#attr', 'left') === '33px'); ok('and the rule still sets what the attribute did not say', await get('#attr', 'top') === '5px'); await p.evaluate(() => { document.querySelector('#js').style.left = '71px'; DSSL.refresh(); }); await p.waitForTimeout(150); ok('a script that moves an element after the engine styled it keeps winning, even through a refresh', await get('#js', 'left') === '71px'); ok('a margin-top written inline survives a rule\'s margin shorthand', await get('#short', 'marginTop') === '14px' && await get('#short', 'marginLeft') === '0px'); await p.evaluate(() => { document.querySelector('#js').style.left = ''; DSSL.refresh(); }); await p.waitForTimeout(150); ok('clearing it hands the property back to the rule', await get('#js', 'left') === '0px'); const logs = await p.evaluate(() => window.__logs.join('|')); ok('no "folded border-color into border" noise', !/folded/.test(logs), logs); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { pages: { '/i': page('div { position: absolute }\n#attr { left: 0; top: 5px }\n#js { left: 0; top: 40px }\n#short { margin: 0 }\n#brd { border: 1 solid #fff; border-color: color.ink }\n#brd.on { border-color: red }', '', '') } }); section('5. static subtrees'); await withBrowser(async ({ open, errors }) => { const p = await open('/s'); await ready(p); await p.evaluate(() => { const e = DSSL.__debug.engines.get('global'); window.__n = 0; const orig = e.touched.add.bind(e.touched); e.touched.add = (el) => { window.__n++; return orig(el); }; }); await p.evaluate(() => { document.getElementById('live').classList.add('hot'); }); await p.waitForTimeout(300); const liveN = await p.evaluate(() => window.__n); await p.evaluate(() => { window.__n = 0; }); await p.evaluate(() => { DSSL.freeze(document.getElementById('world')); document.getElementById('world').classList.add('hot'); }); await p.waitForTimeout(300); const staticN = await p.evaluate(() => window.__n); ok('toggling a class on a normal subtree restyles it (a lot of elements)', liveN > 200, String(liveN)); ok('on a frozen subtree it restyles almost nothing (the root and no more)', staticN < 20, String(staticN)); ok('and the frozen children keep the look they had', await p.evaluate(() => getComputedStyle(document.querySelector('#world i')).color) === 'rgb(255, 0, 0)'); await p.evaluate(() => DSSL.refresh(document.getElementById('world'))); await p.waitForTimeout(150); ok('DSSL.refresh(el) restyles it on demand', await p.evaluate(() => getComputedStyle(document.querySelector('#world i')).color) === 'rgb(0, 128, 0)'); await p.evaluate(() => { DSSL.unfreeze(document.getElementById('world')); document.getElementById('world').classList.remove('hot'); }); await p.waitForTimeout(250); ok('unfreeze(el) makes it live again', await p.evaluate(() => getComputedStyle(document.querySelector('#world i')).color) === 'rgb(255, 0, 0)'); ok('DSSL.freeze() with no argument still parks the whole page', await p.evaluate(() => { const r = DSSL.freeze(); DSSL.unfreeze(); return r !== undefined; })); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { pages: { '/s': page('i { color: red }\n.hot i { color: green }', `cream on cream
fine
none
'); r = run('check', path.join(dir, 'empty.html')); ok('a page with no DSSL says so (exit 2)', r.status === 2 && /no /.test(r.stderr), r.stderr); writeFileSync(path.join(dir, 'missing.html'), ''); r = run('check', path.join(dir, 'missing.html')); ok('a link to a file that is not there is reported with where it looked', r.status === 2 && /not on disk/.test(r.stderr), r.stderr); } section('10. the docs match the code'); { const read = (f) => readFileSync(path.join(ROOT, f), 'utf8'); const V = API.VERSION; const guide = read('GUIDE.md'), readme = read('README.md'), llms = read('llms.txt'), usage = read('engine-package/USAGE.md'); ok('llms.txt names the engine version', new RegExp(`Version: ${V.replace(/\./g, '\\.')} \\(engine\\)`).test(llms), llms.match(/Version: [\d.]+/)?.[0]); ok('the guide\'s API block shows the real version, and calls it a string', guide.includes(`DSSL.version // "${V}"`), guide.match(/DSSL\.version.*/)?.[0]); ok('the README\'s API block does too', readme.includes(`DSSL.version // "${V}"`)); ok('README sections say the current version, not an old one', readme.includes(`What works today (v${V})`) && readme.includes(`v${V}, experimental`)); ok('llms.txt and public/llms.txt are the same file', llms === read('public/llms.txt')); const keys = Object.keys(API.DSSL).filter((k) => !k.startsWith('__')); for (const [label, text] of [['the guide', guide], ['the README', readme], ['USAGE.md', usage], ['llms.txt', llms]]) { const missing = keys.filter((k) => !new RegExp(`DSSL\\.${k}\\b|\\b${k}\\(`).test(text)); ok(`${label} documents every function on DSSL (${keys.length})`, missing.length === 0, 'missing: ' + missing.join(', ')); } ok('DSSL.errors is documented as a function and DSSL.version as a string', /DSSL\.errors\(\)/.test(usage) && /DSSL\.version`? .*string|a string/.test(usage)); ok('the guide does not call version() a function', !/DSSL\.version\(\)/.test(guide)); const files = readdirSync(path.join(ROOT, 'public/dssl')).filter((f) => f.endsWith('.js')); const table = guide.split('\n').find((l) => l.startsWith('| `public/dssl/`')) || ''; ok(`the guide's engine file table names all ${files.length} modules, so it cannot say "six" again`, files.every((f) => table.includes('`' + f + '`')), files.filter((f) => !table.includes('`' + f + '`')).join(', ')); ok('the docs site does not say the engine is two files', !/engine is two static files/.test(read('src/components/dssl/views/DocsView.tsx'))); } console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0);