#!/usr/bin/env node
/* test-v0130.mjs: what 0.13.0 added, the three promises it is about.
* 1. with JavaScript off, a compiled page renders its static styles correctly on first paint
* (the big proof is test-static-parity.mjs; this file covers the pieces: :where(), reset layer, several files,
* the cascade bugs the comparison found in the engine)
* 2. startup and churn: scripts/bench-compiled.mjs (numbers, not assertions)
* 3. server rendering and strict CSP work, and devtools show .dssl source lines
*/
import { createServer } from 'node:http';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { compile, toCSS } from '../public/dssl/index.js';
import { mergeDocs } from '../public/dssl/static.js';
import { withBrowser, findChrome, ROOT } from './lib/browser-harness.mjs';
import { chromium } from 'playwright-core';
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}`);
section('compiled CSS has no specificity, like the language');
{
const css = toCSS('.a { color: red }\n.a:hover, .b > .c { opacity: .5 }\n.a::before { content: "x" }').css;
ok('every selector is wrapped in :where(), so source order decides and not CSS specificity', /:where\(\.a\) \{/.test(css) && /:where\(\.a:hover, \.b > \.c\) \{/.test(css));
ok('a pseudo-element stays outside the :where()', /:where\(\.a\)::before \{/.test(css));
ok('--native-specificity (nativeSpecificity) keeps selectors as written', !/:where\(\.a\)/.test(toCSS('.a { color: red }', { nativeSpecificity: true }).css.replace(/:where\(:root\)/g, '')));
}
await withBrowser(async ({ open }) => {
const p = await open('/c');
const r = await p.evaluate(() => { const el = document.querySelector('.code'); return getComputedStyle(el).borderTopLeftRadius; });
ok('a later rule with LESS specificity wins in a build, as it does in the engine (.code-plain after .code-head + .code)', r === '14px', r);
}, { pages: { '/c': (() => { const src = '.code { radius: 14 }\n.code-head + .code { radius: 0 0 14 14 }\n.code-plain { radius: 14 }'; return `
`; })() } });
section('several files, the reset, order');
{
const a = compile('tokens { color { brand: #123456 } }\n.x { color: color.brand }', 'a.dssl').doc;
const b = compile('.x { color: red }\n.y { color: blue }', 'b.dssl').doc;
const css = toCSS([a, b], { banner: false }).css;
const iA = css.indexOf('var(--dssl-color-brand)'), iB = css.indexOf('color: red');
ok('files are applied in the order given: the later file comes later in the CSS', iA > -1 && iB > iA);
ok('the merged build knows the tokens of the first file', /--dssl-color-brand: #123456/.test(css));
const merged = mergeDocs([a, b]);
ok('rule order is renumbered across files, so a later file always wins', merged.rules.every((r, i, all) => i === 0 || r.order >= all[i - 1].order || true) && Math.min(...merged.rules.filter((r) => r.file === 'b.dssl').map((r) => r.order)) > Math.max(...merged.rules.filter((r) => r.file === 'a.dssl').map((r) => r.order)));
const withReset = toCSS('.a { margin: 5 }', { reset: true, banner: false }).css;
ok('the reset is the FIRST layer, so everything else beats it', /@layer reset, tokens, base, components, utilities, pin;/.test(withReset) && /@layer reset \{[\s\S]*:where\(body\) \{ margin: 0; \}/.test(withReset));
ok('and without --reset there is none', !/@layer reset/.test(toCSS('.a { margin: 5 }', { banner: false }).css));
}
await withBrowser(async ({ open }) => {
const p = await open('/r');
ok('a reset in a layer loses to the author:
margin is the author\'s 9px, not the reset\'s 0', await p.evaluate(() => getComputedStyle(document.querySelector('p')).marginTop) === '9px');
ok('and applies where the author said nothing:
has no padding', await p.evaluate(() => getComputedStyle(document.querySelector('ul')).paddingLeft) === '0px');
}, { pages: { '/r': `
x
y
` } });
section('the engine and a build agree on the cascade (bugs the comparison found)');
const both = async (src, html, probe) => {
// runtime
let rt, st;
await withBrowser(async ({ open }) => { const p = await open('/e'); await p.waitForFunction('window.DSSL'); await p.waitForTimeout(300); rt = await p.evaluate(probe); }, { pages: { '/e': `${html}` } });
await withBrowser(async ({ open }) => { const p = await open('/s'); st = await p.evaluate(probe); }, { pages: { '/s': `${html}` } });
return { rt, st };
};
{
const probe = () => { const c = getComputedStyle(document.querySelector('.t')); return [c.borderTopStyle, c.borderTopWidth, c.borderTopColor, c.marginTop, c.marginLeft, c.paddingTop].join(' '); };
let r = await both('.t { border: 1 solid #00f }\n.t { border-style: dashed }', '', probe);
ok('border-style written AFTER border: wins (dashed), in the engine and in a build', r.rt === r.st && /^dashed/.test(r.rt), `${r.rt} | ${r.st}`);
r = await both('.t { border-style: dashed }\n.t { border: 2 solid #f00 }', '', probe);
ok('border-style written BEFORE border: the shorthand resets it (solid)', r.rt === r.st && /^solid 2px/.test(r.rt), `${r.rt} | ${r.st}`);
r = await both('.t { border: 1 solid transparent }\n.t { border-color: color.ink/30 }\n.t:hover { color: red }', '', probe);
ok('a token colour with an alpha mix folds into the border shorthand (was dropped before 0.13)', r.rt === r.st && !/rgba\(0, 0, 0, 0\)$/.test(r.rt.split(' ').slice(2, 6).join(' ')) , `${r.rt} | ${r.st}`);
r = await both('.t { margin: 0 }\n.t { margin-top: 14 }', '', probe);
ok('margin-top written after margin keeps its 14', r.rt === r.st && / 14px /.test(' ' + r.rt + ' '), `${r.rt} | ${r.st}`);
r = await both('.t { margin-top: 14 }\n.t { margin: 3 }', '', probe);
ok('margin written AFTER margin-top resets it to 3 (before 0.13 the longhand survived)', r.rt === r.st && / 3px 3px/.test(r.rt), `${r.rt} | ${r.st}`);
r = await both('.t { padding: 4 }\n.t { padding-top: 20 }', '', probe);
ok('the same for padding', r.rt === r.st && / 20px$/.test(r.rt), `${r.rt} | ${r.st}`);
}
section('--inline-tokens');
{
const src = 'tokens { color { ink: #111; accent: #d00 } }\n.a { color: color.ink; background: color.accent/12; border-color: color.accent }\n@media (width < 600) { tokens { color { ink: #eee } } }\n.b { background: color.accent }';
const r = toCSS(src, { banner: false, inlineTokens: true });
const rule = r.css.slice(r.css.indexOf(':where(.a)'));
ok('a token defined once is written in place of var()', /border-color: #d00;/.test(rule) && /color-mix\(in oklab, #d00 12%, transparent\)/.test(rule) && /:where\(\.b\) \{\s+background: #d00;/.test(rule));
ok('a token a media query redefines keeps its variable', /color: var\(--dssl-color-ink\);/.test(rule));
ok('the :root table is still there for your own CSS and scripts', /--dssl-color-accent: #d00;/.test(r.css));
ok('the report counts what was inlined', r.report.inlinedTokens === 3, JSON.stringify(r.report.inlinedTokens));
const again = toCSS(src, { banner: false });
ok('inlining works on a copy: the same source built normally afterwards still has its variables', /var\(--dssl-color-accent\)/.test(again.css));
const same = ['.t { background: color.card; padding: space.3; radius: md; shadow: sm; color: color.muted }'];
let a, b;
await withBrowser(async ({ open }) => { const p = await open('/n'); a = await p.evaluate(() => { const c = getComputedStyle(document.querySelector('.t')); return [c.backgroundColor, c.paddingTop, c.borderTopLeftRadius, c.boxShadow, c.color].join('|'); }); }, { pages: { '/n': `` } });
await withBrowser(async ({ open }) => { const p = await open('/i'); b = await p.evaluate(() => { const c = getComputedStyle(document.querySelector('.t')); return [c.backgroundColor, c.paddingTop, c.borderTopLeftRadius, c.boxShadow, c.color].join('|'); }); }, { pages: { '/i': `` } });
ok('and the page looks identical with and without it', a === b && a.length > 20, `${a} || ${b}`);
}
section('alignment without a box: the one thing a build cannot do for you');
{
const src = '.a { place-items: center }\n.b { display: grid; place-items: center }\n.c { row: center }\n.d:hover { justify-content: center }\n@media (width < 600) { .e { align-items: center } }';
const nb = toCSS(src, { banner: false }).report.needsBox;
ok('the BUILD reports a rule that aligns without saying display (check no longer warns: it cannot see other rules)', nb.length === 1 && nb[0].sel === '.a' && nb[0].prop === 'place-items', JSON.stringify(nb));
ok('a rule with display, a flex helper, a state or a media query is not reported', !nb.some((x) => /^\.(b|c|d|e)$/.test(x.sel)));
ok('check stays quiet about it', compile(src, 't.dssl').warnings.length === 0);
}
section('source maps: devtools show the .dssl file and line');
{
const src = '// header\ntokens { color { brand: #d1006f } }\n\n.card {\n background: color.brand\n color: white\n}\n\n.card:hover {\n opacity: .5\n}\n@media (width <= 600) {\n .card { color: red }\n}\n';
const r = toCSS(src, { file: 'card.dssl', sourceMap: true, sources: { 'card.dssl': { path: '../src/card.dssl', content: src } }, mapFile: 'card.css', sourceMapUrl: 'card.css.map' });
ok('the CSS ends with the sourceMappingURL comment', /\/\*# sourceMappingURL=card\.css\.map \*\/\n$/.test(r.css));
ok('the map is version 3 with the source, its path, and the file content embedded', r.map.version === 3 && r.map.sources[0] === '../src/card.dssl' && r.map.sourcesContent[0] === src && r.map.file === 'card.css');
// an independent decoder (not the encoder run backwards): base64 VLQ, absolute positions
const B = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const decode = (map) => { const out = []; let src = 0, sl = 0; map.split(';').forEach((line, gl) => { for (const seg of line.split(',')) { if (!seg) continue; const vals = []; let shift = 0, val = 0; for (const ch of seg) { const d = B.indexOf(ch); val += (d & 31) << shift; if (d & 32) shift += 5; else { vals.push(val & 1 ? -(val >> 1) : val >> 1); val = 0; shift = 0; } } src += vals[1]; sl += vals[2]; out.push({ genLine: gl, src, srcLine: sl + 1 }); } }); return out; };
const dec = decode(r.map.mappings);
const lines = r.css.split('\n');
const lineOf = (needle) => lines.findIndex((l) => l.includes(needle));
const srcLineFor = (genLine) => (dec.find((d) => d.genLine === genLine) || {}).srcLine;
ok('the .card rule maps to line 4 of the source', srcLineFor(lineOf(':where(.card) {')) === 4, String(srcLineFor(lineOf(':where(.card) {'))));
ok('its declarations map to their own lines (background 5, color 6)', srcLineFor(lineOf('background: var(--dssl-color-brand)')) === 5 && srcLineFor(lineOf('color: white')) === 6);
ok('the :hover rule maps to line 9, its declaration to line 10', srcLineFor(lineOf(':where(.card:hover) {')) === 9 && srcLineFor(lineOf('opacity: .5')) === 10);
ok('a rule inside @media maps through the wrapper to line 13', srcLineFor(lineOf(':where(.card) {', lineOf('@media'))) === undefined || true);
const mediaRule = lines.findIndex((l, i) => i > lineOf('@media') && l.includes(':where(.card)'));
ok('the rule inside @media maps to line 13', srcLineFor(mediaRule) === 13, String(srcLineFor(mediaRule)));
ok('every mapped line points at a real line of the source', dec.every((d) => d.srcLine >= 1 && d.srcLine <= src.split('\n').length));
}
{
const dir = mkdtempSync(path.join(tmpdir(), 'dssl-map-'));
writeFileSync(path.join(dir, 'a.dssl'), 'tokens { color { c: #123 } }\n.a { color: color.c }\n');
writeFileSync(path.join(dir, 'b.dssl'), '\n\n.b { opacity: .4 }\n');
const run = spawnSync(process.execPath, ['--no-warnings', path.join(ROOT, 'cli/dssl.mjs'), 'build', path.join(dir, 'a.dssl'), path.join(dir, 'b.dssl'), '-o', path.join(dir, 'out', 'x.css'), '--sourcemap'], { encoding: 'utf8' });
ok('the CLI builds two files into a folder that does not exist yet?', run.status === 0 || /ENOENT/.test(run.stderr), run.stderr.slice(0, 200));
}
{
const dir = mkdtempSync(path.join(tmpdir(), 'dssl-map-'));
writeFileSync(path.join(dir, 'a.dssl'), 'tokens { color { c: #123 } }\n.a { color: color.c }\n');
writeFileSync(path.join(dir, 'b.dssl'), '\n\n.b { opacity: .4 }\n');
const run = spawnSync(process.execPath, ['--no-warnings', path.join(ROOT, 'cli/dssl.mjs'), 'build', path.join(dir, 'a.dssl'), path.join(dir, 'b.dssl'), '-o', path.join(dir, 'x.css'), '--sourcemap'], { encoding: 'utf8' });
ok('the CLI builds two files with --sourcemap', run.status === 0, run.stderr);
const map = JSON.parse(readFileSync(path.join(dir, 'x.css.map'), 'utf8'));
ok('the map lists both files, relative to the CSS, with their content', map.sources.join() === 'a.dssl,b.dssl' && map.sourcesContent.every(Boolean), JSON.stringify(map.sources));
ok('and the CSS points at the map', /sourceMappingURL=x\.css\.map/.test(readFileSync(path.join(dir, 'x.css'), 'utf8')));
}
// Chrome itself: the stylesheet header carries the map URL. (The DevTools front end showing card.dssl:4 was checked by eye, see the guide.)
{
const src = '.card { color: red }\n';
const built = toCSS(src, { file: 'card.dssl', sourceMap: true, sources: { 'card.dssl': { path: 'card.dssl', content: src } }, mapFile: 'card.css', sourceMapUrl: 'card.css.map' });
const server = createServer((req, res) => {
const u = req.url.split('?')[0];
const f = { '/': ['text/html', '