File size: 8,826 Bytes
9da4a9b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | #!/usr/bin/env node
/* test-native-spec.mjs: keep native/spec honest.
*
* 1. every ```dssl block in the spec compiles with no errors, and every ```dssl error block has at least one
* 2. the numbers and strings the prose promises are what the engine produces
* 3. the divergences of chapter 12 stay fixed
* 4. the generated appendices and data file are up to date
*
* Exit code is 0 only when every check passed. */
import { readFileSync, readdirSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { parse } from '../public/dssl/parser.js';
import { resolve, DEFAULT_TOKENS } from '../public/dssl/resolver.js';
const root = fileURLToPath(new URL('../', import.meta.url));
const specDir = join(root, 'native/spec');
let pass = 0, fail = 0;
const ok = (name) => { pass++; };
const bad = (name, why) => { fail++; console.error('FAIL ' + name + (why ? '\n ' + why : '')); };
function run(src, file = 'spec.dssl') {
const p = parse(src, file);
const d = resolve(p.ast, file, { ...DEFAULT_TOKENS });
return { doc: d, errors: [...p.errors, ...d.errors], warnings: d.warnings || [] };
}
const rule = (doc, sel) => doc.rules.find((r) => r.sel === sel);
const val = (doc, sel, prop) => { const r = rule(doc, sel); const d = r && r.decls.find((x) => x.prop === prop); return d ? d.value : undefined; };
/* 1. fenced examples */
const chapters = readdirSync(specDir).filter((f) => /^\d\d-.*\.md$/.test(f)).sort();
let blocks = 0;
for (const f of chapters) {
const text = readFileSync(join(specDir, f), 'utf8');
let i = 0;
for (const m of text.matchAll(/```dssl( error)?\n([\s\S]*?)```/g)) {
i++; blocks++;
const expectError = !!m[1];
const { errors } = run(m[2], `${f} block ${i}`);
if (expectError && errors.length) ok();
else if (expectError) bad(`${f} block ${i}`, 'is marked "error" but compiled cleanly');
else if (errors.length) bad(`${f} block ${i}`, errors.map((e) => `line ${e.line}: ${e.message}`).join('; '));
else ok();
}
}
if (blocks < 20) bad('spec examples', `only ${blocks} fenced blocks found, expected the chapters to carry many more`);
/* 2. promises made in the prose */
const eq = (name, got, want) => (JSON.stringify(got) === JSON.stringify(want) ? ok() : bad(name, `got ${JSON.stringify(got)}, wanted ${JSON.stringify(want)}`));
{ // 3.2 numbers
const { doc } = run('.a { padding: 0; margin: 0 auto; opacity: 0.5; line-height: 1.5; transition-delay: 200 }');
eq('VAL-3 padding', val(doc, '.a', 'padding'), '0px');
eq('VAL-3 margin', val(doc, '.a', 'margin'), '0px auto');
eq('VAL-3 opacity', val(doc, '.a', 'opacity'), '0.5');
eq('VAL-3 line-height', val(doc, '.a', 'line-height'), '1.5');
eq('3.2 time prop', val(doc, '.a', 'transition-delay'), '200ms');
}
{ // 3.3 functions
const { doc } = run('.a { width: calc(100% - 24); background: oklch(62% 0.19 35); grid-template-columns: repeat(4, 1fr); transform: scale(1.2) }');
eq('VAL-7 calc', val(doc, '.a', 'width'), 'calc(100% - 24px)');
eq('VAL-7 oklch', val(doc, '.a', 'background'), 'oklch(62% 0.19 35)');
eq('VAL-6 repeat', val(doc, '.a', 'grid-template-columns'), 'repeat(4, 1fr)');
eq('VAL-7 scale', val(doc, '.a', 'transform'), 'scale(1.2)');
}
{ // 3.4 alpha
const { doc } = run('tokens { color { x: #123456 } }\n.a { color: color.x/50; background: #ff0000/50; border-color: white/12 }');
eq('VAL-8 token alpha', val(doc, '.a', 'color'), 'color-mix(in oklab, var(--dssl-color-x) 50%, transparent)');
eq('VAL-8 hex alpha', val(doc, '.a', 'background'), 'color-mix(in oklab, #ff0000 50%, transparent)');
eq('VAL-8 named alpha', val(doc, '.a', 'border-color'), 'color-mix(in oklab, white 12%, transparent)');
}
{ // 3.7 fluid
const { doc } = run('h1 { font-size: fluid 16 24 }');
eq('VAL-13 fluid', val(doc, 'h1', 'font-size'), 'clamp(16px, calc(12.571px + 0.952vw), 24px)');
}
{ // 5.2 layers
const { doc } = run('.a { color: red }\npin { .a { color: blue } }\n@layer base { .a { color: green } }');
eq('CAS-2/5 layer ranks', doc.rules.filter((r) => r.sel === '.a').map((r) => [r.layer, val(doc.rules.length ? { rules: [r] } : doc, '.a', 'color')]), [['components', 'red'], ['pin', 'blue'], ['base', 'green']]);
}
{ // 5.4 fragments
const { doc } = run('.a {\n color: red\n hover { color: blue }\n padding: 4\n}');
eq('CAS-12 fragments', doc.rules.filter((r) => r.sel.startsWith('.a')).map((r) => [r.sel, r.decls[0].prop]), [['.a', 'color'], ['.a:hover', 'color'], ['.a', 'padding']]);
const orders = doc.rules.filter((r) => r.sel.startsWith('.a')).map((r) => r.order);
eq('CAS-11 orders 1,2,3', orders, [1, 2, 3]);
eq('TOK-9 registry order 0', doc.registryRule.order, 0);
}
{ // 5.5 media
const { doc } = run('.a { @media 768 { padding: 24 } @media <= 480 { padding: 12 } @media 480..960 { gap: 4 } @media dark { gap: 1 } }');
eq('CAS-19 media', doc.rules.filter((r) => r.sel === '.a').map((r) => r.media), [['(min-width: 768px)'], ['(max-width: 480px)'], ['(min-width: 480px) and (max-width: 960px)'], ['(prefers-color-scheme: dark)']]);
}
{ // 5.9 signals
const { doc } = run('.a { @signal theme == dark { color: red } @signal screen in (a, b) { color: blue } @signal on { color: green } }');
eq('CAS-37 signals', doc.rules.filter((r) => r.sel === '.a').map((r) => r.signals[0]), [
{ name: 'theme', op: '==', value: 'dark' }, { name: 'screen', op: 'in', value: 'a,b', values: ['a', 'b'] }, { name: 'on', op: 'truthy', value: null }]);
}
{ // 6.3 row
const { doc } = run('.toolbar { row: gap 8 between center }');
eq('PRP-8 row', rule(doc, '.toolbar').decls.map((d) => d.prop + ':' + d.value), ['display:flex', 'flex-direction:row', 'gap:8px', 'justify-content:space-between', 'align-items:center']);
}
{ // 4.3 conditional tokens
const { doc } = run('tokens { color { bg: white } }\n@media dark { tokens { color { bg: black } } }\n.a { background: color.bg }');
const toks = doc.rules.filter((r) => r.layer === 'tokens');
eq('TOK-8 two token rules', toks.map((r) => [r.media, r.decls[0].value]), [[[], 'white'], [['(prefers-color-scheme: dark)'], 'black']]);
eq('TOK-9 registry carries the last definition', doc.registryRule.decls.find((d) => d.prop === '--dssl-color-bg').value, 'black');
}
{ // 3.8, 6.1
const { errors } = run('.a { color: red !important }');
eq('VAL-15 important', errors.length === 1 && /^!important does not exist in DSSL/.test(errors[0].message), true);
}
/* 3. the divergences of chapter 12 stay fixed (0.16.3). D-6 is open and needs a browser test, so it is not checked here. */
{ // D-1, SYN-6
const { errors, doc } = run('.a { background: url(http://x.com/a.png) }');
eq('D-1 unquoted url keeps its //', [errors.length, val(doc, '.a', 'background')], [0, 'url(http://x.com/a.png)']);
}
{ // D-2, TOK-12
const { errors } = run('tokens { color { ink2: #111 } }\n.a { color: color.ink2.bogus }');
eq('D-2 a typo in a token path is an error', errors.some((e) => /unknown value "color.ink2.bogus"/.test(e.message)), true);
}
{ // D-3, CAS-2
const { errors, warnings } = run('@layer weird { .a { color: red } }');
eq('D-3 unknown layer warns', [errors.length, warnings.some((w) => /^unknown layer "weird", treated as "components"/.test(w.message))], [0, true]);
}
{ // D-4, TOK-18
const { doc, errors, warnings } = run('.a { radius: xl }');
eq('D-4 radius xl warns, same output', [errors.length, warnings.some((w) => /^radius "xl" is not defined/.test(w.message)), val(doc, '.a', 'border-radius')], [0, true, 'var(--dssl-radius-xl)']);
}
{ // D-5, VAL string rule
const { errors } = run('.a { content: "a\\"b" }');
eq('D-5 an escaped quote stays inside the string', errors.length, 0);
}
{ // D-7, CAS-7, CAS-24
const sels = (src, hit) => run(src).doc.rules.filter((r) => hit.test(r.sel)).map((r) => r.sel);
eq('D-7 parent list', sels('.a, .b { .c { color: red } }', /\.c/), ['.a .c, .b .c']);
eq('D-7 state on a list', sels('.a, .b { hover { color: red } }', /hover/), ['.a:hover, .b:hover']);
eq('D-7 child list', sels('.a { .c, .d { color: red } }', /\.c/), ['.a .c, .a .d']);
}
{ // D-8, CAS-8
const { warnings } = run('.menu { li { color: red } hove { color: red } }');
eq('D-8 li is quiet, hove still warns', [warnings.some((w) => /"li" compiled/.test(w.message)), warnings.some((w) => /"hove" compiled/.test(w.message))], [false, true]);
}
/* 4. generated files */
{
const r = spawnSync(process.execPath, ['--no-warnings', join(root, 'scripts/gen-native-vocab.mjs'), '--check'], { encoding: 'utf8' });
if (r.status === 0) ok(); else bad('generated vocabulary files are up to date', (r.stderr || r.stdout || '').trim());
}
console.log(`${blocks} spec examples checked`);
console.log(`${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
|