File size: 18,797 Bytes
d9e058a | 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 | #!/usr/bin/env node
/* test-v0120.mjs: what 0.12.0 added.
* texture: symmetrical mirrored tiling, so the repeat hides and every edge continues
* fonts { } Google fonts and your own files, no raw CSS
* gradient: repeating, colour spaces, midpoints, hard bands, mesh, layers
* DSSL.inspect() which rule produced which style, in the page
* DSSL.watch(), dssl dev edit a .dssl file and the open page restyles in place
*/
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { compile, toCSS, VERSION } from '../public/dssl/index.js';
import { parseTexture, buildTexture } from '../public/dssl/texture.js';
import { withBrowser } 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 errs = (src) => compile(src, 't.dssl').errors;
const hooks = (list) => ({ error: (m) => list && list.push(m), color: (w) => w, suggest: () => null });
const rcHex = (c) => { const m = /^#([0-9a-f]{6})$/i.exec(String(c)); return m ? { r: parseInt(m[1].slice(0, 2), 16), g: parseInt(m[1].slice(2, 4), 16), b: parseInt(m[1].slice(4, 6), 16), a: 1 } : null; };
const make = (t, list) => { const L = parseTexture(t, hooks(list)); return L && buildTexture(L, rcHex); };
section('symmetrical: parsing');
{
const bare = parseTexture('oak symmetrical', hooks())[0], x = parseTexture('oak symmetrical x', hooks())[0], y = parseTexture('oak symmetrical y', hooks())[0], b = parseTexture('oak symmetrical both', hooks())[0];
ok('bare symmetrical means both ways', bare.symmetrical === 'both' && b.symmetrical === 'both');
ok('x and y are read', x.symmetrical === 'x' && y.symmetrical === 'y');
ok('it can sit in the middle of the other settings', parseTexture('oak symmetrical scale 8', hooks())[0].scale === 8 && parseTexture('oak symmetrical scale 8', hooks())[0].symmetrical === 'both');
const e = []; parseTexture('oak symmetrical z', hooks(e));
ok('a typo after it is still an error, not a silent "both"', e.length === 1 && /unknown texture setting "z"/.test(e[0]), e.join());
}
section('symmetrical: the tile');
{
const plain = make('oak'), both = make('oak symmetrical'), xo = make('oak symmetrical x'), yo = make('oak symmetrical y');
ok('both ways doubles the tile in both directions', plain.size === '576px 264px' && both.size === '1152px 528px', both.size);
ok('x only doubles the width, y only the height', xo.size === '1152px 264px' && yo.size === '576px 528px', `${xo.size} / ${yo.size}`);
const svg = (t) => decodeURIComponent(t.image.slice(t.image.indexOf(',') + 1, -2));
ok('the mirrored copies are drawn by flipping one copy, not by repeating the data four times', (svg(both).match(/<use /g) || []).length === 4 && (svg(both).match(/<image /g) || []).length === 1);
ok('same seed, same bytes', make('oak symmetrical seed 4').image === make('oak symmetrical seed 4').image);
ok('textures that are plain CSS patterns are left alone', make('lines symmetrical').image === make('lines').image);
ok('it works on noise, planks and several layers', make('noise symmetrical').count === 1 && make('oak-planks symmetrical').count === 1 && make('grain symmetrical, oak symmetrical').count === 2);
ok('stays small: one copy of the image, however many flips', make('oak symmetrical').image.length < make('oak').image.length * 1.15 + 1500, `${make('oak').image.length} -> ${make('oak symmetrical').image.length}`);
}
section('symmetrical: pixels');
const tilePage = (image, w, h, size) => `<!doctype html><style>body{margin:0}div{width:${w}px;height:${h}px;background:${image} 0 0 / ${size} repeat}</style><div></div>`;
await withBrowser(async ({ open, errors }) => {
const t = make('noise symmetrical scale 4 seed 3');
const p = await open('/m'); await p.waitForTimeout(500);
const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 600, height: 300 } }));
const [W, H] = t.size.split(' ').map((v) => parseInt(v));
// noise is per-pixel random, so allow the mirror to land within a pixel either side of where it should
const near = (a, b) => dist(a, b) < 14;
const any = (p, xs, ys) => { for (const dx of [0, -1, 1]) for (const dy of [0, -1, 1]) if (near(p, px(png, xs + dx, ys + dy))) return true; return false; };
let mx = 0, my = 0, both = 0, n = 0;
for (let y = 5; y < Math.min(H - 5, 290); y += 17) for (let x = 5; x < Math.min(W - 5, 590); x += 13) {
n++;
if (any(px(png, x, y), W - 1 - x, y)) mx++;
if (any(px(png, x, y), x, H - 1 - y)) my++;
if (any(px(png, x, y), W - 1 - x, H - 1 - y)) both++;
}
ok('the left half is the right half flipped', mx / n > 0.95, `${mx}/${n}`);
ok('the top half is the bottom half flipped', my / n > 0.95, `${my}/${n}`);
ok('and the opposite corner is both', both / n > 0.95, `${both}/${n}`);
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/m': tilePage(make('noise symmetrical scale 4 seed 3').image, 600, 300, make('noise symmetrical scale 4 seed 3').size) }, viewport: { width: 600, height: 300 } });
await withBrowser(async ({ open }) => {
const t = make('oak symmetrical');
const p = await open('/o'); await p.waitForTimeout(600);
const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 1400, height: 700 } }));
const colDiff = (a, b) => { let s = 0, c = 0; for (let y = 0; y < 520; y += 2) { const u = px(png, a, y), v = px(png, b, y); s += Math.abs(u[0] - v[0]) + Math.abs(u[1] - v[1]) + Math.abs(u[2] - v[2]); c++; } return s / c; };
const typical = [100, 300, 700, 900].map((x) => colDiff(x, x + 1)).reduce((a, b) => a + b) / 4;
ok('across the mirror line (x = 576, the old tile edge) the grain continues', colDiff(575, 576) <= typical * 2 + 3, `${colDiff(575, 576).toFixed(1)} vs ${typical.toFixed(1)}`);
ok('and across the period (x = 1152)', colDiff(1151, 1152) <= typical * 2 + 3, `${colDiff(1151, 1152).toFixed(1)} vs ${typical.toFixed(1)}`);
}, { pages: { '/o': tilePage(make('oak symmetrical').image, 1400, 700, make('oak symmetrical').size) }, viewport: { width: 1400, height: 700 } });
section('fonts');
{
const r = toCSS('fonts {\n Fraunces: 500 700 800\n "Hanken Grotesk": 400 700 italic\n Inter: 100..900 display optional\n Brand: from "/fonts/brand.woff2" 400\n Brand: from "/fonts/brand-bold.woff" 700 italic\n}\n.a { font-family: "Fraunces", serif }');
ok('compiles with no errors', r.errors.length === 0, JSON.stringify(r.errors));
ok('Google families become one @import, first in the CSS (after the banner)', /^\/\*[\s\S]*?\*\/\n@import url\("https:\/\/fonts\.googleapis\.com\/css2\?family=Fraunces:wght@500;700;800&family=Hanken\+Grotesk:ital,wght@0,400;0,700;1,400;1,700&family=Inter:wght@100\.\.900&display=optional"\);\n/.test(r.css), r.css.slice(0, 500));
ok('the @import comes before the @layer statement and every rule', r.css.indexOf('@import') < r.css.indexOf('@layer'));
ok('your own files become @font-face, with the format guessed from the extension', /@font-face \{\s+font-family: "Brand";\s+src: url\("\/fonts\/brand\.woff2"\) format\("woff2"\);\s+font-weight: 400;\s+font-style: normal;/.test(r.css) && /format\("woff"\);\s+font-weight: 700;\s+font-style: italic/.test(r.css));
ok('the report counts the faces as raw CSS', r.report.raws === 2, String(r.report.raws));
ok('a variable range is kept as a range', /Inter:wght@100\.\.900/.test(r.css));
ok('with no weights the family is requested whole, or with its italic', /family=Lora&display=swap/.test(toCSS('fonts { Lora: display swap }').css) && /family=Lora:ital@0;1&display=swap/.test(toCSS('fonts { Lora: italic }').css));
ok('display defaults to swap', /&display=swap"/.test(toCSS('fonts { Lora: 400 }').css));
const bad = (src) => errs(src).map((e) => e.message).join(' | ');
ok('an unknown word is an error with a hint', /unknown font setting "bold"/.test(bad('fonts {\n Lora: 400 bold\n}')));
ok('a missing colon is an error', /expected "Family: weights"/.test(bad('fonts {\n Lora 400\n}')));
ok('one file with two weights is refused', /one font file holds one weight/.test(bad('fonts {\n X: from "a.woff2" 400 700\n}')));
ok('a bad display value is refused', /display needs one of/.test(bad('fonts {\n X: 400 display fast\n}')));
ok('a weight out of range is refused', /is not a font weight/.test(bad('fonts {\n X: 4000\n}')));
ok('inside a rule it is refused', /must be top-level/.test(bad('.a { fonts { X: 400 } }')));
ok('error lines point at the right line', errs('.a { color: red }\n\nfonts {\n Lora 400\n}')[0].line === 4, String(errs('.a { color: red }\n\nfonts {\n Lora 400\n}')[0].line));
ok('comments inside the block are ignored', errs('fonts {\n // the display face\n Lora: 400 // regular\n}').length === 0);
}
section('gradients');
{
const g = (v) => { const r = compile(`.a { gradient: ${v} }`); const d = r.doc.rules[0] && r.doc.rules[0].decls.find((x) => x.prop === 'background-image'); return { v: d && d.value, e: r.errors.map((e) => e.message) }; };
ok('the original sentence still works', g('linear 135, #ff0055 -> #00d4ff').v === 'linear-gradient(135deg, #ff0055, #00d4ff)');
ok('repeating stripes with plain numbers as px', g('repeating linear 45, #fff 0 10, #000 10 20').v === 'repeating-linear-gradient(45deg, #fff 0 10px, #000 10px 20px)');
ok('repeating radial and conic', /^repeating-radial-gradient\(circle, #fff 0 8px/.test(g('repeating radial circle, #fff 0 8, #000 8 16').v) && /^repeating-conic-gradient\(/.test(g('repeating conic, #f00 0 30, #00f 30 60').v));
ok('conic positions are degrees', /#0f0 120deg/.test(g('conic from 45, #f00 0, #0f0 120, #00f 240').v));
ok('the colour space of the blend', g('linear 90 in oklch, #f00 -> #00f').v === 'linear-gradient(90deg in oklch, #f00, #00f)' && g('linear 90 in hsl longer hue, #f00, #00f').v === 'linear-gradient(90deg in hsl longer hue, #f00, #00f)');
ok('a colour space alone, with no angle', g('linear in oklab, #f00 -> #00f').v === 'linear-gradient(180deg in oklab, #f00, #00f)');
ok('a lone position is a midpoint', g('linear 90, #f00 -> 30% -> #00f').v === 'linear-gradient(90deg, #f00, 30%, #00f)');
ok('a midpoint at the edge is an error', g('linear 90, 30% -> #f00 -> #00f').e.length === 1);
ok('hard bands with two positions', g('linear 90, #f00 0 30%, #00f 30% 100%').v === 'linear-gradient(90deg, #f00 0 30%, #00f 30% 100%)');
ok('layers with +, first on top', /^radial-gradient\(at top left, color-mix\([^)]*\), transparent\), linear-gradient\(135deg, #123, #456\)$/.test(g('radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456').v));
const mesh = g('mesh #fff4e6, #ff7a1a at 15% 20%, #c2185b at 80% 30% size 40%').v;
ok('mesh: soft blobs over a base colour', /^radial-gradient\(circle farthest-side at 15% 20%, #ff7a1a, transparent 60%\), radial-gradient\(circle farthest-side at 80% 30%, #c2185b, transparent 40%\), linear-gradient\(#fff4e6, #fff4e6\)$/.test(mesh), mesh);
ok('mesh needs a base and a blob, and a position per blob', g('mesh #fff').e.length === 1 && g('mesh #fff, #f00 at 10%').e.length === 1);
ok('a direction is optional: stops can follow the kind straight away', g('conic, #f00 -> #00f').v === 'conic-gradient(#f00, #00f)' && g('linear #f00 -> #00f').v === 'linear-gradient(180deg, #f00, #00f)' && g('radial #fff -> #000').v === 'radial-gradient(#fff, #000)');
ok('bad colours are still caught inside the new forms', g('repeating linear 45, banana 0 10, #000 10 20').e.length >= 1);
ok('gradient-text takes the layered form too', compile('.a { gradient-text: linear 90, #f00 -> #00f }').errors.length === 0);
}
section('inspector and hot reload (real browser)');
const page = (extra = '') => `<!doctype html><meta charset=utf-8><body style="margin:0"><style type="text/dssl" data-dssl-name="inline.dssl">.box { position: absolute; left: 40px; top: 40px; width: 200px; height: 120px; background: #345; radius: 12 }\n.box { color: #fff }</style><div class="box">hello</div>${extra}<script type="module" src="/dssl/boot.js"></script>`;
const ready = (p) => p.waitForFunction(`window.DSSL && DSSL.__debug.engines.get('global')`);
await withBrowser(async ({ open, errors }) => {
const p = await open('/i'); await ready(p); await p.waitForTimeout(300);
ok('the dev tools are not loaded for a normal visitor', await p.evaluate(() => !document.getElementById('dssl-dev-host')));
await p.evaluate(() => DSSL.inspect()); await p.waitForTimeout(200);
ok('DSSL.inspect() opens the panel', await p.evaluate(() => !!document.getElementById('dssl-dev-host')));
await p.mouse.move(100, 80); await p.waitForTimeout(300);
const text = await p.evaluate(() => document.getElementById('dssl-dev-host').shadowRoot.querySelector('.body').innerText);
ok('hovering shows the element, the winning declarations and where they came from', /div\.box/.test(text) && /background: #345/.test(text) && /inline\.dssl:1/.test(text) && /color: #fff/.test(text), text.slice(0, 300));
ok('and the rules in cascade order', /\.box[\s\S]*\.box/.test(text.split('rules, in cascade order')[1] || ''));
const box = await p.evaluate(() => { const b = document.getElementById('dssl-dev-host').shadowRoot.querySelector('.box'); return { w: parseFloat(b.style.width), h: parseFloat(b.style.height) }; });
ok('the element under the pointer is outlined', Math.abs(box.w - 200) < 2 && Math.abs(box.h - 120) < 2, JSON.stringify(box));
await p.mouse.click(100, 80); await p.mouse.move(600, 500); await p.waitForTimeout(200);
ok('clicking pins it, so moving away keeps its rules on screen', await p.evaluate(() => /div\.box/.test(document.getElementById('dssl-dev-host').shadowRoot.querySelector('.body').innerText)));
await p.keyboard.press('Escape'); await p.waitForTimeout(100);
ok('Escape releases the pin, then closes', await p.evaluate(() => !!document.getElementById('dssl-dev-host'))); // first Esc only released
await p.keyboard.press('Escape'); await p.waitForTimeout(100);
ok('the second Escape closes it and leaves nothing behind', await p.evaluate(() => !document.getElementById('dssl-dev-host')));
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/i': page() }, viewport: { width: 800, height: 600 } });
{
const files = { '/s.dssl': '.w { background: #f00; width: 80px; height: 40px }' };
const pages = { '/w': `<!doctype html><meta charset=utf-8><body><div class="w">x</div><script type="module" src="/dssl/boot.js" data-watch></script><link rel="stylesheet/dssl" href="/s.dssl">`, '/s.dssl': files['/s.dssl'] };
await withBrowser(async ({ open, errors }) => {
const p = await open('/w'); await ready(p); await p.waitForTimeout(500);
await p.evaluate(() => { window.__marker = 'same page'; });
const bg = () => p.evaluate(() => getComputedStyle(document.querySelector('.w')).backgroundColor);
ok('data-watch turns hot reload on from the script tag', (await bg()) === 'rgb(255, 0, 0)');
pages['/s.dssl'] = '.w { background: #0a0; width: 80px; height: 40px }';
await p.waitForFunction(`getComputedStyle(document.querySelector('.w')).backgroundColor === 'rgb(0, 170, 0)'`, null, { timeout: 5000 });
ok('editing the file restyles the page', (await bg()) === 'rgb(0, 170, 0)');
ok('without reloading it (state survives)', (await p.evaluate(() => window.__marker)) === 'same page');
ok('and says so', /reloaded s\.dssl/.test(await p.evaluate(() => document.querySelector('div[role=status]') && document.querySelector('div[role=status]').textContent) || ''));
pages['/s.dssl'] = '.w { backgrond: #00f; width: 80px }';
await p.waitForFunction(`document.querySelector('div[role=status]') && /error/.test(document.querySelector('div[role=status]').textContent)`, null, { timeout: 5000 });
const toast = await p.evaluate(() => document.querySelector('div[role=status]').textContent);
ok('a mistake in the file shows the error and line, on the page', /1 error, line 1: unknown property "backgrond"/.test(toast), toast);
pages['/s.dssl'] = '.w { background: #00f; width: 80px; height: 40px }';
await p.waitForFunction(`getComputedStyle(document.querySelector('.w')).backgroundColor === 'rgb(0, 0, 255)'`, null, { timeout: 5000 });
ok('fixing it recovers', true);
await p.evaluate(() => DSSL.watch(false)); const before = await bg();
pages['/s.dssl'] = '.w { background: #fff; width: 80px; height: 40px }'; await p.waitForTimeout(1800);
ok('DSSL.watch(false) stops following', (await bg()) === before);
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages, viewport: { width: 400, height: 300 } });
}
section('dssl dev');
{
const dir = mkdtempSync(join(tmpdir(), 'dssl-dev-'));
writeFileSync(join(dir, 'index.html'), '<!doctype html><body><div class="x">hi</div><script type="module" src="/dssl/boot.js"></script><link rel="stylesheet/dssl" href="s.dssl"></body>');
writeFileSync(join(dir, 's.dssl'), '.x { color: red }');
const port = 4200 + Math.floor(Math.random() * 500);
const child = spawn(process.execPath, ['--no-warnings', new URL('../cli/dssl.mjs', import.meta.url).pathname, 'dev', dir, '--port', String(port)], { stdio: ['ignore', 'pipe', 'pipe'] });
let out = ''; child.stdout.on('data', (d) => { out += d; });
for (let i = 0; i < 40 && !/localhost/.test(out); i++) await new Promise((r) => setTimeout(r, 100));
try {
const res = await fetch(`http://localhost:${port}/`);
const html = await res.text();
ok('serves the page and adds DSSL.watch() to it', res.status === 200 && /DSSL\.watch\(\)/.test(html) && /<\/script><\/body>$/.test(html.trim()));
ok('never caches, so a reload always sees the latest file', res.headers.get('cache-control') === 'no-store');
ok('serves the engine from the install when the folder has none', (await fetch(`http://localhost:${port}/dssl/boot.js`)).status === 200);
ok('serves your .dssl files untouched', (await (await fetch(`http://localhost:${port}/s.dssl`)).text()) === '.x { color: red }');
ok('refuses to leave the folder', (await fetch(`http://localhost:${port}/..%2f..%2fetc%2fpasswd`)).status !== 200);
ok('404 for a missing file', (await fetch(`http://localhost:${port}/nope.css`)).status === 404);
} finally { child.kill(); }
}
section('versions');
ok('the engine says 0.12.x or later', /^0\.(1[2-9]|[2-9]\d)\./.test(VERSION), VERSION);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
|