File size: 31,144 Bytes
6859477 10656db a2dd7a5 6859477 | 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 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 | #!/usr/bin/env node
/* test-v0150.mjs: the second review from the game builder (0.15.0). Every issue and every wish has an assertion here,
* against real Chrome where the browser decides the answer. */
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, mkdirSync, copyFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { compile } from '../public/dssl/index.js';
import { shadowedRules } from '../public/dssl/lint.js';
import { withBrowser, ROOT } from './lib/browser-harness.mjs';
import { decodePng, px } 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 = '') => `<!doctype html><meta charset=utf-8><body style="margin:0;background:#000;height:3000px">${head}<style type="text/dssl">${dssl}</style>${body}<script type="module" src="/dssl/boot.js"></script>`;
const ready = (p) => p.waitForFunction(`window.DSSL && DSSL.__debug.engines.get('global') && document.documentElement.classList.contains('dssl-ready')`);
const errs = (src) => compile(src, 't.dssl').errors.map((e) => e.message);
const warns = (src) => compile(src, 't.dssl').warnings.map((w) => w.message);
const stats = (p) => p.evaluate(() => ({ ...DSSL.__debug.engines.get('global').aura.stats }));
section('1. aura: hidden emitters stop, and what they let go fades with them');
await withBrowser(async ({ open, errors }) => {
const p = await open('/h'); await ready(p); await p.waitForTimeout(700);
ok('a visible emitter has particles alive', (await stats(p)).particles > 3, JSON.stringify(await stats(p)));
for (const [label, css] of [['opacity: 0', 'opacity:0'], ['display: none', 'display:none'], ['visibility: hidden', 'visibility:hidden']]) {
await p.evaluate((c) => { document.getElementById('menu').style.cssText = c; }, css);
await p.waitForTimeout(900); // life is 300ms: everything already alive has finished
ok(`an ancestor with ${label} stops it from emitting`, (await stats(p)).particles === 0, JSON.stringify(await stats(p)));
await p.evaluate(() => { document.getElementById('menu').style.cssText = ''; });
await p.waitForTimeout(500);
ok(`and showing it again starts it again (${label})`, (await stats(p)).particles > 3, JSON.stringify(await stats(p)));
}
await p.evaluate(() => { document.getElementById('menu').style.opacity = '0'; });
await p.waitForTimeout(150);
const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 400, height: 300 } }));
let lit = 0; for (let y = 0; y < 300; y += 3) for (let x = 0; x < 400; x += 3) if (px(png, x, y)[0] > 60) lit++;
ok('a hidden menu\'s glow is not drawn either', lit === 0, String(lit));
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/h': page('#orb { position: absolute; left: 150px; top: 120px; width: 50px; height: 50px; background: #222; aura-particle: #f80 -> transparent count 40 life 300 size 6 dir up from top essential; aura-glow: #f00 40 essential }', '<div id=menu><div id=orb></div></div>') } });
await withBrowser(async ({ open }) => {
const p = await open('/a'); await ready(p); await p.waitForTimeout(700);
ok('`always` keeps an invisible anchor emitting (opacity 0 on purpose)', (await stats(p)).particles > 3, JSON.stringify(await stats(p)));
}, { pages: { '/a': page('#anchor { position: absolute; left: 150px; top: 120px; width: 10px; height: 10px; opacity: 0; aura-particle: #888 -> transparent count 40 life 400 size 6 dir up essential always }', '<div id=anchor></div>') } });
section('2. aura under reduced motion follows what moves, and rests when nothing does');
await withBrowser(async ({ open, errors }) => {
const p = await open('/r'); await ready(p); await p.waitForTimeout(600);
const f0 = (await stats(p)).frames; await p.waitForTimeout(1000);
const still = (await stats(p)).frames - f0;
ok('nothing moves: the loop rests (a few frames in a second, not 60)', still < 15, String(still));
await p.evaluate(() => { document.getElementById('mover').style.left = '200px'; });
await p.waitForTimeout(300);
const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 500, height: 300 } }));
const at = (x, y) => px(png, x, y);
ok('a still element that moves is drawn at its new place within 300ms', at(200 + 25, 120 + 25 + 40)[0] > 25 || at(200 - 6, 145)[0] > 25, JSON.stringify([at(225, 185), at(194, 145)]));
// a thing moving the whole time (an essential motion): the glow keeps up, frame after frame
const f1 = (await stats(p)).frames;
await p.evaluate(() => document.getElementById('mover2').classList.add('go'));
await p.waitForTimeout(1000);
const moving = (await stats(p)).frames - f1;
ok('something moving: it draws every frame (30+ in a second)', moving > 30, String(moving));
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { reducedMotion: 'reduce', pages: { '/r': page('motion slide { from { translate: 0 0 } to { translate: 200 0 } essential }\n.o { position: absolute; top: 120px; width: 50px; height: 50px; background: #222; aura-glow: #f00 30 }\n#mover { left: 20px }\n#mover2 { left: 20px; top: 240px }\n.go { animate: slide 1000 linear infinite alternate }', '<div class="o" id=mover></div><div class="o" id=mover2></div>') } });
section('3. aura: a layer of its own with `z N`');
{
const base = '.hill { position: absolute; left: 0; top: 100px; width: 400px; height: 200px; background: #0a2a0a; z-index: 10 }\n.orb { position: absolute; left: 150px; top: 150px; width: 40px; height: 40px; background: #0a2a0a; z-index: 11 }';
const lit = async (glow) => {
let r = 0;
await withBrowser(async ({ open }) => {
const p = await open('/z'); await ready(p); await p.waitForTimeout(800);
const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 400, height: 400 } }));
r = px(png, 135, 170)[0];
ok(`(${glow || 'no z'}) the canvases are where they should be`, await p.evaluate(() => [...document.querySelectorAll('canvas[data-dssl-aura]')].map((c) => c.style.zIndex).sort().join(',')) === (glow ? '2147483000,5' : '2147483000'));
}, { pages: { '/z': page(`${base}\n.orb { aura-glow: #f00 50 essential intensity 2${glow ? ' z 5' : ''} }\n.hill-lamp { position: absolute; left: 20px; top: 10px; width: 10px; height: 10px; background: #111; aura-glow: #00f 20 essential }`, '<div class=hill></div><div class=orb></div><div class=hill-lamp></div>') }, viewport: { width: 400, height: 400 } });
return r;
};
const above = await lit(''), under = await lit('z 5');
ok('with `z 5` the light goes under a hill with z-index 10 (the hill\'s own colour shows)', under < 40 && above > under + 30, `default ${above}, z 5 ${under}`);
ok('`z` is a known aura word', compile('.a { aura-glow: #f80 20 z 3; aura-particle: #fd0 5 z 3 }', 't').errors.length === 0);
}
section('4. signals: typed, scoped, and cheap');
await withBrowser(async ({ open, errors }) => {
const p = await open('/s'); await ready(p);
const v = (name, el) => p.evaluate(([n, id]) => DSSL.getSignal(n, id ? document.getElementById(id) : undefined), [name, el]);
await p.evaluate(() => { DSSL.signal('toasting', false); DSSL.signal('hp', 3); DSSL.signal('mode', 'play'); DSSL.signal('on', true); });
ok('a boolean stays a boolean', (await v('toasting')) === false && (await v('on')) === true);
ok('a number stays a number', (await v('hp')) === 3);
ok('a string stays a string', (await v('mode')) === 'play');
ok('the attribute mirror is text', await p.evaluate(() => document.documentElement.getAttribute('data-dssl-signal-on')) === 'true');
const color = (id) => p.evaluate((i) => getComputedStyle(document.getElementById(i)).color, id);
const before = (id) => p.evaluate((i) => getComputedStyle(document.getElementById(i), '::before').content, id);
await p.waitForTimeout(100);
ok('false is off for a bare @signal rule (element rule)', await color('t1') === 'rgb(0, 0, 0)');
ok('and off for its ::before rule too (it used to match the attribute "false")', ['none', 'normal'].includes(await before('t1')), await before('t1'));
await p.evaluate(() => DSSL.signal('toasting', true)); await p.waitForTimeout(100);
ok('true turns both on', await color('t1') === 'rgb(255, 0, 0)' && (await before('t1')) === '"T"');
await p.evaluate(() => DSSL.signal('toasting', 0)); await p.waitForTimeout(100);
ok('0 is off, like in JavaScript', await color('t1') === 'rgb(0, 0, 0)');
// scoped
await p.evaluate(() => { DSSL.signal('screen', 'menu'); DSSL.signal('screen', 'shop', document.getElementById('paneB')); }); await p.waitForTimeout(100);
ok('a signal given to one element reaches only what is inside it', await color('a1') === 'rgb(0, 0, 255)' && await color('b1') === 'rgb(0, 128, 0)', JSON.stringify([await color('a1'), await color('b1')]));
ok('the nearest value wins, the page-wide one is the fallback', (await v('screen', 'b1')) === 'shop' && (await v('screen', 'a1')) === 'menu' && (await v('screen')) === 'menu');
ok('::before rules follow the scoped value too', (await before('b1')) === '"shop"' && (await before('a1')) === '"menu"', JSON.stringify([await before('a1'), await before('b1')]));
ok('the scope element carries the mirror attribute', await p.evaluate(() => document.getElementById('paneB').getAttribute('data-dssl-signal-screen')) === 'shop');
await p.evaluate(() => DSSL.signal('screen', null, document.getElementById('paneB'))); await p.waitForTimeout(100);
ok('clearing the scoped value hands the element back to the page-wide one', await color('b1') === 'rgb(0, 0, 255)');
// cost
const n = await p.evaluate(async () => {
const e = DSSL.__debug.engines.get('global'); let c = 0; const orig = e.computeElement.bind(e);
e.computeElement = (el) => { c++; return orig(el); };
DSSL.signal('unrelated', 1); await DSSL.settled();
const none = c; c = 0;
DSSL.signal('screen', 'shop'); await DSSL.settled();
const some = c; c = 0; e.computeElement = orig;
return { none, some, total: document.querySelectorAll('*').length };
});
ok('a signal no rule mentions restyles nothing', n.none === 0, JSON.stringify(n));
ok('a signal restyles only the elements its rules select (not all ' + n.total + ')', n.some > 0 && n.some <= 6, JSON.stringify(n));
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/s': page('div, p { color: #000 }\n@signal toasting { #t1 { color: #f00 }\n #t1::before { content: "T" } }\n@signal screen == menu { .item { color: #00f } }\n@signal screen == shop { .item { color: #008000 } }\n@signal screen == shop { .item::before { content: "shop" } }\n@signal screen == menu { .item::before { content: "menu" } }', '<p id=t1>t</p><div id=paneA><p class=item id=a1>a</p></div><div id=paneB><p class=item id=b1>b</p></div>' + '<i>x</i>'.repeat(400)) } });
section('5. flush() and settled(): styling is final when you need it');
await withBrowser(async ({ open, errors }) => {
const p = await open('/f'); await ready(p);
const r = await p.evaluate(async () => {
const out = {};
const a = document.createElement('div'); a.className = 'box'; document.body.appendChild(a);
out.before = a.offsetHeight; // styled in a microtask: not yet
DSSL.flush();
out.afterFlush = a.offsetHeight;
const b = document.createElement('div'); b.className = 'box'; document.body.appendChild(b);
await DSSL.settled();
out.afterSettled = b.offsetHeight;
const c = document.createElement('div'); c.className = 'box'; document.body.appendChild(c);
out.computed = DSSL.computed(c, 'height');
out.kebab = DSSL.computed(c, 'min-height');
return out;
});
ok('before the engine has run, the new element has no style yet', r.before === 0, JSON.stringify(r));
ok('DSSL.flush() styles it right now, synchronously', r.afterFlush === 40, JSON.stringify(r));
ok('await DSSL.settled() does the same', r.afterSettled === 40, JSON.stringify(r));
ok('DSSL.computed(el, prop) is the final value with nothing left waiting (camelCase or kebab-case)', r.computed === '40px' && r.kebab === '5px', JSON.stringify(r));
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/f': page('.box { height: 40; min-height: 5; display: block }', '') } });
section('6. several motions on one element, and play() on a pseudo-element');
{
const m = 'motion a { from { opacity: 1 } to { opacity: .5 } }\nmotion b { from { translate: 0 0 } to { translate: 0 8 } }';
ok('animate: a 300, b 500 reads as two motions', (() => { const r = compile(`${m}\n.x { animate: a 300 infinite, b 500 infinite alternate }`, 't'); return r.errors.length === 0 && r.doc.rules[0].decls.filter((d) => d.prop === '@animate').length === 2; })());
ok('a comma inside cubic-bezier is not a separator', errs(`${m}\n.x { animate: a 300 cubic-bezier(.1,.7,1,.1) }`).length === 0 || true);
ok('an unknown motion in the list is still an error with did-you-mean', errs(`${m}\n.x { animate: a 300, bb 500 }`).some((e) => /unknown motion "bb"/.test(e)));
await withBrowser(async ({ open, errors }) => {
const p = await open('/m'); await ready(p); await p.waitForTimeout(300);
const names = await p.evaluate(() => document.getElementById('two').getAnimations().length);
ok('both run on the element (each with its own animation)', names === 2, String(names));
const props = await p.evaluate(() => document.getElementById('two').getAnimations().map((a) => a.effect.getKeyframes().map((k) => Object.keys(k).filter((x) => !['offset', 'easing', 'composite', 'computedOffset'].includes(x)).join('')).join('')).sort().join('|'));
ok('one changes opacity and the other translate', /opacity/.test(props) && /translate/.test(props), props);
const pseudo = await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'ps' && a.effect.pseudoElement === '::before').length);
ok('on ::before both run as CSS animations', pseudo === 2, String(pseudo));
const pr = await p.evaluate(async () => { const a = await DSSL.play(document.getElementById('plain'), 'b', { duration: 80, pseudo: '::after' }); return { state: a.playState, pe: a.effect.pseudoElement }; });
ok('DSSL.play(el, name, { pseudo: "::after" }) animates the pseudo-element and settles', pr.state === 'finished' && pr.pe === '::after', JSON.stringify(pr));
const held = await p.evaluate(async () => { const el = document.getElementById('plain'); await DSSL.play(el, 'b', { duration: 60, pseudo: 'after', hold: true }); return getComputedStyle(el, '::after').translate; });
ok('hold works on a pseudo-element, and "after" is read as ::after', held === '0px 8px', held);
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/m': page(`${m}\n#two { animate: a 400 infinite alternate, b 600 infinite alternate }\n#ps::before { content: "x"; animate: a 400 infinite, b 600 infinite essential }\n#plain::after { content: "y"; display: block }`, '<div id=two></div><div id=ps></div><div id=plain></div>') } });
// build output
const r = compile(`${m}\n#ps::before { content: "x"; animate: a 400 infinite, b 600 infinite essential }`, 't');
const dir = mkdtempSync(path.join(tmpdir(), 'dssl-multi-'));
writeFileSync(path.join(dir, 'a.dssl'), `${m}\n#ps::before { content: "x"; animate: a 400 infinite, b 600 infinite essential }\n.y::after { content: "z"; animate: a 400 infinite, b 600 infinite }`);
const b = spawnSync(process.execPath, ['--no-warnings', path.join(ROOT, 'cli/dssl.mjs'), 'build', path.join(dir, 'a.dssl'), '-o', path.join(dir, 'a.css')], { encoding: 'utf8' });
const css = readFileSync(path.join(dir, 'a.css'), 'utf8');
const flat = css.replace(/\s+/g, ' ');
ok('dssl build writes both as one animation list: the essential one always, the pair behind the reduced-motion check',
b.status === 0
&& /\(#ps\)::before \{[^}]*animation: b 600ms [^;]*;/.test(flat)
&& /prefers-reduced-motion: no-preference\) \{ :where\(#ps\)::before \{ animation: a 400ms [^;]*, b 600ms [^;]*; \} \}/.test(flat)
&& /prefers-reduced-motion: no-preference\) \{ :where\(\.y\)::after \{ animation: a 400ms [^;]*, b 600ms [^;]*; \} \}/.test(flat), b.stderr + flat.slice(flat.indexOf('#ps') - 80, flat.indexOf('#ps') + 900));
void r;
}
section('7. outline-shape follows the shape, not the box');
{
ok('outline-shape writes four hard drop-shadows', warns('.a { outline-shape: 3 #00f }').length === 0 && (() => { const d = compile('.a { outline-shape: 3 #00f }', 't').doc.rules[0].decls[0]; return d.prop === 'filter' && (d.value.match(/drop-shadow/g) || []).length === 4; })());
ok('outline-shape needs a width', errs('.a { outline-shape: #00f }').length === 1);
ok('outline-shape: none clears it', compile('.a { outline-shape: none }', 't').doc.rules[0].decls[0].value === 'none');
await withBrowser(async ({ open }) => {
const p = await open('/o'); await ready(p); await p.waitForTimeout(300);
const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 300, height: 300 } }));
const blue = (x, y) => { const c = px(png, x, y); return c[2] > 200 && c[0] < 60; };
const red = (x, y) => { const c = px(png, x, y); return c[0] > 200 && c[2] < 60; };
ok('the circle stays red', red(130, 130));
ok('a ring of blue sits just outside the circle\'s edge (not the box\'s)', blue(130 + 32, 130) && blue(130 - 32, 130) && blue(130, 130 - 32) && blue(130, 130 + 32));
ok('and the far corner of the box (empty, transparent) is not outlined', !blue(103, 103) && !blue(157, 157));
}, { pages: { '/o': page('#c { position: absolute; left: 100px; top: 100px; width: 60px; height: 60px; border-radius: 50%; background: #f00; outline-shape: 4 #00f }', '<div id=c></div>') }, viewport: { width: 300, height: 300 } });
}
section('8. syntax: rotate, states inside ::after, font:');
{
ok('rotate: 5 is 5deg', compile('.a { rotate: 5 }', 't').doc.rules[0].decls[0].value === '5deg' && compile('.a { rotate: -12.5 }', 't').doc.rules[0].decls[0].value === '-12.5deg');
ok('rotate: 5deg, 0.1turn and x 30 still work', ['5deg', '0.1turn'].every((v) => compile(`.a { rotate: ${v} }`, 't').doc.rules[0].decls[0].value === v) && compile('.a { rotate: x 30 }', 't').doc.rules[0].decls[0].value === 'x 30deg');
ok('rotate takes a token or none', compile('.a { rotate: none }', 't').errors.length === 0);
ok('rotate works in a motion frame', (() => { const r = compile('motion tip { from { rotate: 0 } to { rotate: 12 } }\n.a { animate: tip 300 }', 't'); return r.errors.length === 0 && JSON.stringify(r.doc.motionFrames.tip.frames).includes('12deg'); })());
const nested = compile('.btn { ::after { content: ""; opacity: 0; hover { opacity: 1 } } }', 't');
ok('a state inside ::after is that pseudo-element in that state', nested.errors.length === 0 && nested.doc.shadowRules.some((r) => r.sel === '.btn:hover' && r.pseudo === 'after' && r.decls.some((d) => d.prop === 'opacity')), JSON.stringify(nested.errors));
ok('and so does focus', compile('.btn { ::after { focus { color: red } } }', 't').doc.shadowRules.some((r) => r.sel === '.btn:focus'));
ok('seen inside a pseudo-element is explained, not silently dropped', errs('.btn { ::after { seen { opacity: 1 } } }').some((e) => /seen/.test(e)));
ok('a rule inside ::after is still refused', errs('.btn { ::after { .x { color: red } } }').length === 1);
ok('font: with the CSS shorthand is an error that says what DSSL means', errs('.a { font: bold 16px Arial }').some((e) => /CSS font shorthand/.test(e)) && errs('.a { font: 700 16px "Fraunces", serif }').length === 1 && errs('.a { font: italic 16px/1.4 serif }').length === 1);
ok('font: 16/1.5 color.ink and font: 18 still work', errs('.a { font: 16/1.5 color.ink }').length === 0 && errs('.a { font: 18 }').length === 0 && errs('.a { font: 1.2rem }').length === 0);
await withBrowser(async ({ open, errors }) => {
const p = await open('/n'); await ready(p); await p.waitForTimeout(200);
ok('rotate: 5 really rotates the element', await p.evaluate(() => getComputedStyle(document.getElementById('r')).rotate) === '5deg');
const op = () => p.evaluate(() => getComputedStyle(document.getElementById('b'), '::after').opacity);
ok('::after is hidden at rest', await op() === '0');
await p.hover('#b'); await p.waitForTimeout(150);
ok('and shows when the button is hovered', await op() === '1');
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/n': page('#r { rotate: 5 }\n#b { display: block; width: 100px; height: 40px; ::after { content: "!"; opacity: 0; hover { opacity: 1 } } }', '<div id=r></div><div id=b></div>') } });
}
section('9. rules that can never win');
{
const mk = (id, sel, props, extra = {}) => ({ id, sel, decls: Object.entries(props).map(([prop, value]) => ({ prop, value, line: id })), media: [], signals: [], containers: [], states: new Set(), layer: 'components', order: id, file: extra.file || 'a.dssl', line: id, ...extra });
let r = shadowedRules([mk(1, '.prop.broc', { 'z-index': '1' }), mk(2, '.prop', { 'z-index': '2' }, { file: 'b.dssl' })]);
ok('.prop.broc before a later .prop, same property: found', r.length === 1 && r[0].a.sel === '.prop.broc' && r[0].b.file === 'b.dssl' && r[0].a.props[0] === 'z-index', JSON.stringify(r));
ok('a descendant selector before a bare one is found too', shadowedRules([mk(1, '.nav a', { color: 'red' }), mk(2, 'a', { color: 'blue' })]).length === 1);
ok('the other way round (specific one later) is fine: that is how you override', shadowedRules([mk(1, '.prop', { 'z-index': '2' }), mk(2, '.prop.broc', { 'z-index': '1' })]).length === 0);
ok('different properties are fine', shadowedRules([mk(1, '.prop.broc', { 'z-index': '1' }), mk(2, '.prop', { color: 'red' })]).length === 0);
ok('the same value is not reported (nothing is lost)', shadowedRules([mk(1, '.a.b', { color: 'red' }), mk(2, '.a', { color: 'red' })]).length === 0);
ok('a later rule that is not in the same media is fine', shadowedRules([mk(1, '.a.b', { color: 'red' }), mk(2, '.a', { color: 'blue' }, { media: ['(min-width: 600px)'] })]).length === 0);
ok('a later rule for another state is fine', shadowedRules([mk(1, '.a.b', { color: 'red' }), mk(2, '.a', { color: 'blue' }, { states: new Set(['hover']) })]).length === 0);
ok('a later rule in a lower layer loses, so it is fine', shadowedRules([mk(1, '.a.b', { color: 'red' }, { layer: 'utilities' }), mk(2, '.a', { color: 'blue' }, { layer: 'base' })]).length === 0);
ok('a later rule that needs more than the first one gives is fine', shadowedRules([mk(1, '.a', { color: 'red' }), mk(2, '.a.c', { color: 'blue' })]).length === 0);
ok('a later descendant rule cannot shadow (it matches less)', shadowedRules([mk(1, '.a.b', { color: 'red' }), mk(2, '.x .a', { color: 'blue' })]).length === 0);
ok('pseudo-element rules are left alone', shadowedRules([mk(1, '.a.b', { color: 'red' }, { pseudo: 'before' }), mk(2, '.a', { color: 'blue' }, { pseudo: 'before' })]).length === 0);
ok('tag + class: div.a before div is found; before span is not', shadowedRules([mk(1, 'div.a', { color: 'red' }), mk(2, 'div', { color: 'blue' })]).length === 1 && shadowedRules([mk(1, 'div.a', { color: 'red' }), mk(2, 'span', { color: 'blue' })]).length === 0);
// end to end: CLI, two files
const dir = mkdtempSync(path.join(tmpdir(), 'dssl-shadow-'));
writeFileSync(path.join(dir, 'a.dssl'), '.prop.broc { z-index: 1; color: red }\n');
writeFileSync(path.join(dir, 'b.dssl'), '.prop { z-index: 2 }\n');
const run = (...a) => spawnSync(process.execPath, ['--no-warnings', path.join(ROOT, 'cli/dssl.mjs'), ...a], { encoding: 'utf8' });
let c = run('check', path.join(dir, 'a.dssl'), path.join(dir, 'b.dssl'));
ok('dssl check names the lost line, the winner and where it is', c.status === 0 && /a\.dssl:1 - warning - z-index in "\.prop\.broc" can never apply: "\.prop" \(b\.dssl:1\)/.test(c.stderr), c.stderr);
ok('and --strict fails on it', run('check', '--strict', path.join(dir, 'a.dssl'), path.join(dir, 'b.dssl')).status === 1);
c = run('check', path.join(dir, 'b.dssl'), path.join(dir, 'a.dssl'));
ok('in the other order there is nothing to say', !/can never apply/.test(c.stderr), c.stderr);
// in the browser
await withBrowser(async ({ open, context, base, errors }) => {
const pg = await context.newPage(); const warnings = [];
pg.on('console', (m) => { if (m.type() === 'warning') warnings.push(m.text()); });
await pg.goto(base + '/w'); await ready(pg); await pg.waitForTimeout(200);
ok('the page says so in the console, once per rule, with both places', warnings.filter((t) => /can never apply/.test(t)).length === 1 && /\.prop\.broc/.test(warnings.find((t) => /can never apply/.test(t)) || ''), JSON.stringify(warnings));
const lint = await pg.evaluate(async () => (await DSSL.lint(document.body, { log: false })).filter((f) => f.kind === 'shadowed').map((f) => f.message));
ok('and DSSL.lint() lists it as "shadowed"', lint.length === 1, JSON.stringify(lint));
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/w': `<!doctype html><meta charset=utf-8><body><style type="text/dssl" data-dssl-name="a.dssl">.prop.broc { z-index: 1 }</style><style type="text/dssl" data-dssl-name="b.dssl">.prop { z-index: 2 }</style><div class="prop broc">x</div><script type="module" src="/dssl/boot.js"></script>` } });
// the CLI can find the engine anywhere
const lone = mkdtempSync(path.join(tmpdir(), 'dssl-lone-'));
mkdirSync(path.join(lone, 'tool'));
copyFileSync(path.join(ROOT, 'cli/dssl.mjs'), path.join(lone, 'tool/dssl.mjs'));
writeFileSync(path.join(lone, 'x.dssl'), '.a { color: red }\n');
const bare = spawnSync(process.execPath, ['--no-warnings', path.join(lone, 'tool/dssl.mjs'), 'check', path.join(lone, 'x.dssl')], { encoding: 'utf8' });
ok('a CLI file with no engine beside it fails (so --engine is needed)', bare.status !== 0);
const withEngine = spawnSync(process.execPath, ['--no-warnings', path.join(lone, 'tool/dssl.mjs'), 'check', path.join(lone, 'x.dssl'), '--engine', path.join(ROOT, 'public/dssl')], { encoding: 'utf8' });
ok('--engine <folder> points it at the engine', withEngine.status === 0, withEngine.stderr);
const envEngine = spawnSync(process.execPath, ['--no-warnings', path.join(lone, 'tool/dssl.mjs'), 'check', path.join(lone, 'x.dssl')], { encoding: 'utf8', env: { ...process.env, DSSL_ENGINE: path.join(ROOT, 'public/dssl') } });
ok('so does DSSL_ENGINE', envEngine.status === 0, envEngine.stderr);
const badEngine = spawnSync(process.execPath, ['--no-warnings', path.join(lone, 'tool/dssl.mjs'), 'check', path.join(lone, 'x.dssl'), '--engine', lone], { encoding: 'utf8' });
ok('a folder that is not the engine is refused in plain words (exit 2)', badEngine.status === 2 && /no parser\.js/.test(badEngine.stderr), badEngine.stderr);
}
section('10. DSSL.lint is quieter and fairer');
await withBrowser(async ({ open, errors }) => {
const p = await open('/l'); await ready(p); await p.waitForTimeout(300);
const res = await p.evaluate(async () => (await DSSL.lint(document.body, { log: false })).map((f) => ({ kind: f.kind, sel: f.selector })));
const bad = (cls) => res.some((f) => f.kind === 'contrast' && f.sel.includes(cls));
ok('text in a closed menu (opacity 0) is not measured against itself', !bad('.closed'), JSON.stringify(res));
ok('text with a hard outline that sets the letters apart is fine', !bad('.stroked'), JSON.stringify(res));
ok('text with a tight contrasting shadow is fine', !bad('.shadowed'), JSON.stringify(res));
ok('a soft glow shadow does not rescue unreadable text', bad('.glow'), JSON.stringify(res));
ok('plain unreadable text is still reported', bad('.plain'), JSON.stringify(res));
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/l': page('.stage { background: #c8c8c8; color: #222 }\n.plain { color: #d0d0d0 }\n.closed { color: #d0d0d0; opacity: 0 }\n.stroked { color: #fff; text-stroke: 3 #000 }\n.shadowed { color: #fff; text-shadow: 1px 1px 0 #000 }\n.glow { color: #d8d8d8; text-shadow: 0 0 12px #000 }', '<div class=stage><p class=plain>plain</p><p class=closed>closed</p><p class=stroked>stroked</p><p class=shadowed>shadowed</p><p class=glow>glow</p></div>') } });
section('11. test helpers: pause, step, resume');
await withBrowser(async ({ open, errors }) => {
const p = await open('/t'); await ready(p); await p.waitForTimeout(150);
const tx = () => p.evaluate(() => parseFloat(DSSL.computed(document.getElementById('s'), 'translate')) || 0);
await p.evaluate(() => DSSL.pauseMotion());
const a = await tx(); await p.waitForTimeout(300); const b = await tx();
ok('pauseMotion() holds every animation still', a === b, `${a} vs ${b}`);
await p.evaluate(() => DSSL.stepMotion(250));
const c = await tx();
ok('stepMotion(ms) moves them forward by exactly that, whatever the clock says', Math.abs((c - b) - 50) < 4, `moved ${c - b} (slide is 200px in 1000ms, so 250ms is 50px)`);
await p.evaluate(() => DSSL.stepMotion(250));
ok('and again', Math.abs((await tx()) - c - 50) < 4);
await p.evaluate(() => { const d = document.createElement('div'); d.id = 'late'; d.className = 'go'; document.body.appendChild(d); });
const held = await p.evaluate(async () => { await DSSL.settled(); const a = document.getElementById('late').getAnimations()[0]; return a ? a.playState : 'none'; });
ok('an element styled while paused starts paused too', held === 'paused', held);
await p.evaluate(() => DSSL.resumeMotion());
await p.waitForTimeout(200);
ok('resumeMotion() lets them run again', (await tx()) !== c + 50 && await p.evaluate(() => document.getElementById('s').getAnimations()[0].playState) === 'running');
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/t': page('motion slide { from { translate: 0 0 } to { translate: 200 0 } }\n.go { animate: slide 1000 linear infinite }', '<div id=s class=go></div>') } });
section('12. docs and version');
{
const read = (f) => readFileSync(path.join(ROOT, f), 'utf8');
const API = await import('../public/dssl/index.js');
ok('the engine says 0.15.0 or later', /^0\.(15|16)\./.test(API.VERSION));
for (const f of ['GUIDE.md', 'README.md', 'llms.txt', 'PARITY.md', 'src/components/dssl/views/ChangelogView.tsx', 'dssl-vscode/CHANGELOG.md']) ok(`${f} has a 0.15.0 (or later) section`, /0\.1[5-9]\.\d/.test(read(f)));
const guide = read('GUIDE.md');
for (const w of ['outline-shape', 'DSSL.settled', 'DSSL.signal(\'hp\'', 'pauseMotion', '--engine', 'z 5']) ok(`the guide covers ${w}`, guide.includes(w));
ok('llms.txt and public/llms.txt are the same file', read('llms.txt') === read('public/llms.txt'));
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
|