#!/usr/bin/env node /* test-browser-v090.mjs: the 0.9.0 features in a real browser. * * Aura draws on a canvas, textures are SVG filters, and a slider is vendor * pseudo-elements, so none of it can be checked in Node. This drives headless * Chrome and looks at what was actually drawn. It is written to break things: * removal, hidden elements, off-screen elements, freeze, reduced motion, state * changes, token changes, resize, device pixel ratio, and a stress load. * * node scripts/test-browser-v090.mjs */ import { withBrowser } from './lib/browser-harness.mjs'; import { decodePng, px, dist, stats } from './lib/pixels.mjs'; import { galleryPage } from './texture-gallery.mjs'; import { PRESET_NAMES } from '../public/dssl/texture.js'; 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}`); /** a page with the engine, a dark body and the given DSSL + markup */ const page = (dssl, body, bg = '#000') => `
${body} `; const engine = `DSSL.__debug.engines.get('global')`; const dbg = (p) => p.evaluate(`(() => { const e = ${engine}; return e && e.aura ? e.aura.debug() : null; })()`); const ready = (p) => p.waitForFunction(`window.DSSL && ${engine}`); const shot = async (p, clip) => decodePng(await p.screenshot({ clip })); const alive = async (p) => { const d = await dbg(p); return d ? d.stats.particles : 0; }; /* ------------------------------------------------------------------ * * AURA * ------------------------------------------------------------------ */ section('aura: glow'); await withBrowser(async ({ open, errors }) => { const p = await open('/glow'); await ready(p); await p.waitForTimeout(500); const img = await shot(p, { x: 150, y: 100, width: 420, height: 320 }); // element is at 300..420 x 200..320 on the page, so in the clip at (150..270, 100..220) const inside = px(img, 210, 160); ok('the glow never paints over the element itself', dist(inside, [32, 32, 48]) < 6, `inside pixel ${inside}`); const near = px(img, 150 - 12, 160); ok('the glow lights the area right outside the element', near[0] > 40 && near[0] > near[2] * 1.5, `pixel ${near}`); const far = px(img, 5, 5); ok('and fades to nothing far away', dist(far, [0, 0, 0]) < 8, `pixel ${far}`); const above = px(img, 210, 100 - 12); ok('it surrounds the element on every side, not just one', above[0] > 25 && px(img, 210, 220 + 12)[0] > 25 && px(img, 270 + 12, 160)[0] > 25); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { pages: { '/glow': page('.orb { position: absolute; left: 300px; top: 200px; width: 120px; height: 120px; background: #202030; radius: 20; aura-glow: #ff8800 40 }', '') } }); section('aura: particles'); await withBrowser(async ({ open, errors }) => { const p = await open('/p'); await ready(p); await p.waitForTimeout(1600); const d = await dbg(p); const n = d.stats.particles; ok('particle count settles at about the configured count (40)', n >= 28 && n <= 40, `alive ${n}`); const s = d.emitters[0].layers[0].sample; ok('speed and direction are what was written (up at 60 px/s, no spread)', s.length && s.every((q) => Math.abs(q.vy + 60) < 1.5 && Math.abs(q.vx) < 1.5), JSON.stringify(s[0])); const y1 = s[0].y; await p.waitForTimeout(150); const s2 = (await dbg(p)).emitters[0].layers[0].sample; ok('particles actually move upward over time', s2.some((q) => q.y < y1 - 3), `${y1} -> ${s2.map((q) => q.y.toFixed(0))}`); ok('the loop is running at a healthy frame time', d.stats.frameMs < 10, `${d.stats.frameMs.toFixed(2)}ms`); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { pages: { '/p': page('.src { position: absolute; left: 300px; top: 400px; width: 60px; height: 40px; background: #333; aura-particle: #ffffff count 40 size 4 life 1000 speed 60 dir up spread 0 from bottom seed 3 }', '') } }); section('aura: behind'); for (const behind of [true, false]) { await withBrowser(async ({ open }) => { const p = await open('/b'); await ready(p); await p.waitForTimeout(1200); const img = await shot(p, { x: 280, y: 180, width: 200, height: 160 }); // element interior is (300..420) x (200..320) minus the clip origin (280,180): (20..140) x (20..140) const inner = stats(img, 24, 24, 90, 90, 90); if (behind) ok('behind: nothing is drawn over the element\'s face', inner.lit === 0, `lit ${inner.lit}`); else ok('control: without behind the particles DO cover the face', inner.lit > 20, `lit ${inner.lit}`); }, { pages: { '/b': page(`.src { position: absolute; left: 300px; top: 200px; width: 120px; height: 120px; background: #111; aura-particle: #ffffff count 120 size 22 life 3000 speed 0 from center seed 2 ${behind ? 'behind' : ''} }`, '') } }); } section('aura: lifecycle'); await withBrowser(async ({ open, errors }) => { const p = await open('/l'); await ready(p); await p.waitForTimeout(1200); ok('hidden (display:none) elements emit nothing', (await alive(p)) === 0); await p.evaluate(`document.getElementById('h').className = 'src'`); await p.waitForTimeout(1100); ok('showing it starts the aura', (await alive(p)) > 5, `alive ${await alive(p)}`); // removal by removing the rule's class await p.evaluate(`document.getElementById('h').className = 'src off'`); await p.waitForTimeout(2200); let d = await dbg(p); ok('removing the aura lets what is alive finish, then the emitter is gone', d.emitters.length === 0 && d.stats.particles === 0, JSON.stringify(d.stats)); const f1 = d.stats.frames; await p.waitForTimeout(300); ok('and the animation loop stops instead of spinning forever', (await dbg(p)).stats.frames === f1, 'frames kept increasing'); // delete the element from the DOM mid-flight await p.evaluate(`document.getElementById('h').className = 'src'`); await p.waitForTimeout(700); await p.evaluate(`document.getElementById('h').remove()`); await p.waitForTimeout(700); d = await dbg(p); ok('deleting the element from the DOM drops its emitter', d.emitters.length === 0, JSON.stringify(d.emitters.length)); ok('no page errors through all of that', errors.length === 0, errors.join(' | ')); }, { pages: { '/l': page(`.src { position: absolute; left: 300px; top: 300px; width: 80px; height: 80px; background: #345; aura-particle: #8cf count 40 life 600 speed 40 dir up from top } .hide { display: none } .off { aura: none }`, '') } }); section('aura: freeze and unfreeze'); await withBrowser(async ({ open }) => { const p = await open('/f'); await ready(p); await p.waitForTimeout(900); await p.evaluate('DSSL.freeze()'); await p.waitForTimeout(200); const a = (await dbg(p)).stats.frames; await p.waitForTimeout(300); const b = (await dbg(p)).stats.frames; ok('freeze() stops the aura loop', a === b, `${a} -> ${b}`); const painted = await p.evaluate(`(() => { const c = document.querySelector('canvas[data-dssl-aura]'); const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; let n = 0; for (let i = 3; i < d.length; i += 4) if (d[i]) n++; return n; })()`); ok('and clears the canvas, so a frozen page is still', painted === 0, `${painted} painted pixels`); await p.evaluate('DSSL.unfreeze()'); await p.waitForTimeout(500); const c = (await dbg(p)).stats.frames; await p.waitForTimeout(300); ok('unfreeze() resumes it', (await dbg(p)).stats.frames > c); }, { pages: { '/f': page('.src { position: absolute; left: 300px; top: 400px; width: 80px; height: 40px; background: #345; aura: glow #f80 30, particles #fc4 count 40 life 800 speed 50 dir up from top }', '') } }); section('aura: reduced motion'); await withBrowser(async ({ open, errors }) => { const p = await open('/rm'); await ready(p); await p.waitForTimeout(1500); ok('particles do not move for visitors who ask for reduced motion', (await alive(p)) === 0, `alive ${await alive(p)}`); const img = await shot(p, { x: 230, y: 200, width: 300, height: 200 }); // element is 300..380 x 250..290; in the clip that is (70..150, 50..90); look just left of its middle ok('but the static glow is still drawn', px(img, 58, 70)[0] > 30, `pixel ${px(img, 58, 70)}`); const frames = (await dbg(p)).stats.frames; await p.waitForTimeout(400); ok('and the loop is not spinning for a still picture', (await dbg(p)).stats.frames - frames < 3, 'loop kept running'); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { reducedMotion: 'reduce', pages: { '/rm': page(`.src { position: absolute; left: 300px; top: 250px; width: 80px; height: 40px; background: #345; aura: glow #ff8800 40, particles #fc4 count 50 life 800 speed 50 dir up from top }`, '') } }); await withBrowser(async ({ open }) => { const p = await open('/rme'); await ready(p); await p.waitForTimeout(1200); ok('an `essential` aura ignores the preference', (await alive(p)) > 3, `alive ${await alive(p)}`); }, { reducedMotion: 'reduce', pages: { '/rme': page(`.e { position: absolute; left: 300px; top: 250px; width: 80px; height: 40px; background: #345; aura-particle: #fc4 count 50 life 800 speed 50 dir up from top essential }`, '') } }); section('aura: reduced motion still follows the page'); await withBrowser(async ({ open, errors }) => { const p = await open('/rs'); await ready(p); await p.waitForTimeout(900); const at = async () => { const img = await shot(p, { x: 230, y: 0, width: 300, height: 400 }); let top = -1; for (let y = 0; y < 400; y++) { if (px(img, 58, y)[0] > 40) { top = y; break; } } return top; }; const a = await at(); await p.evaluate('window.scrollTo(0, 60)'); await p.waitForTimeout(400); const b = await at(); ok('after a scroll the still glow has moved with its element (about 60px up)', a > 0 && b > 0 && Math.abs((a - b) - 60) < 6, `${a} -> ${b}`); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { reducedMotion: 'reduce', pages: { '/rs': page('.src { position: absolute; left: 300px; top: 250px; width: 80px; height: 40px; background: #345; aura-glow: #ff8800 40 }', '') } }); section('aura: currentColor, textured particles'); await withBrowser(async ({ open, errors }) => { const p = await open('/cc'); await ready(p); await p.waitForTimeout(900); const img = await shot(p, { x: 230, y: 180, width: 300, height: 200 }); const c = px(img, 58, 90); // middle of the element's left edge (it spans y 70..110 in this clip) ok('a glow with no colour (or currentColor) takes the element\'s text colour (green)', c[1] > 40 && c[1] > c[0] * 2 && c[1] > c[2] * 2, `pixel ${c}`); ok('particles with texture(grain) render without errors', (await alive(p)) > 3); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { pages: { '/cc': page('.src { position: absolute; left: 300px; top: 250px; width: 80px; height: 40px; background: #345; color: #22dd44; aura-glow: currentColor 40, ; }', '').replace(', ; }', ' }').replace('aura-glow: currentColor 40 }', 'aura-glow: currentColor 40\n aura-particle: color currentColor count 30 life 600 speed 30 dir up from top texture(grain scale 2) }') } }); section('aura: states, tokens, slots'); await withBrowser(async ({ open, errors }) => { const p = await open('/s'); await ready(p); await p.waitForTimeout(700); ok('no aura until the pointer is over the element', (await alive(p)) === 0); await p.hover('.btn'); await p.waitForTimeout(900); ok('a hover { } block turns the aura on', (await alive(p)) > 5, `alive ${await alive(p)}`); await p.mouse.move(5, 5); await p.waitForTimeout(1800); ok('and moving away lets it die out', (await alive(p)) === 0, `alive ${await alive(p)}`); // slots stack through the cascade: glow from one rule, particles from another, then glow cleared await p.evaluate(`document.getElementById('slots').className = 'g'`); await p.waitForTimeout(500); let d = await dbg(p); let em = d.emitters.find((e) => e.el.id === 'slots') || d.emitters[0]; ok('aura-glow alone gives a glow and no particles', em && em.glow === 1 && em.layers.length === 0, JSON.stringify(em && { g: em.glow, l: em.layers.length })); await p.evaluate(`document.getElementById('slots').className = 'g p'`); await p.waitForTimeout(500); d = await dbg(p); em = d.emitters[0]; ok('adding a rule with aura-particle keeps the glow and adds the particles', em && em.glow === 1 && em.layers.length === 1, JSON.stringify(em && { g: em.glow, l: em.layers.length })); await p.evaluate(`document.getElementById('slots').className = 'g p noglow'`); await p.waitForTimeout(500); d = await dbg(p); em = d.emitters[0]; ok('aura-glow: none clears only the glow slot', em && em.glow === 0 && em.layers.length === 1, JSON.stringify(em && { g: em.glow, l: em.layers.length })); // a token change retints the aura await p.evaluate(`document.getElementById('slots').className = 'tok'`); await p.waitForTimeout(500); const rect = await p.evaluate(`(() => { const r = document.getElementById('slots').getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; })()`); const clip = { x: rect.x - 60, y: rect.y - 40, width: rect.w + 120, height: rect.h + 80 }; let img = await shot(p, clip); let c = px(img, 40, 60); ok('a glow written as a token colour draws that colour (red)', c[0] > c[2] * 2, `pixel ${c}`); await p.evaluate(`DSSL.apply('tokens { color { aurahue: #0044ff } }', { name: 'retint', replace: true })`); await p.waitForTimeout(500); img = await shot(p, clip); c = px(img, 40, 60); ok('changing the token retints the glow live (now blue)', c[2] > c[0] * 2, `pixel ${c}`); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { pages: { '/s': page(`tokens { color { aurahue: #ff2200 } } .btn { position: absolute; left: 300px; top: 300px; width: 100px; height: 40px; background: #222 hover { aura-particle: #8cf count 40 life 600 speed 40 dir up from top } } #slots { position: absolute; left: 500px; top: 300px; width: 100px; height: 60px; background: #222 } .g { aura-glow: #ff8800 24 } .p { aura-particle: #8cf count 20 life 500 speed 30 dir up from top } .noglow { aura-glow: none } .tok { aura-glow: color.aurahue 30 }`, '') } }); section('aura: viewport, scroll, pixel ratio'); await withBrowser(async ({ open, errors }) => { const p = await open('/v'); await ready(p); await p.waitForTimeout(800); ok('an element far below the fold emits nothing', (await alive(p)) === 0, `alive ${await alive(p)}`); await p.evaluate('window.scrollTo(0, 2900)'); await p.waitForTimeout(900); ok('scrolling it into view starts the aura', (await alive(p)) > 5, `alive ${await alive(p)}`); await p.setViewportSize({ width: 520, height: 420 }); await p.waitForTimeout(400); const dims = await p.evaluate(`(() => { const c = document.querySelector('canvas[data-dssl-aura]'); return [c.width, c.height, innerWidth, innerHeight]; })()`); ok('the canvas follows a window resize', dims[0] === dims[2] && dims[1] === dims[3], JSON.stringify(dims)); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { pages: { '/v': page('.far { position: absolute; left: 300px; top: 3100px; width: 80px; height: 40px; background: #345; aura-particle: #fc4 count 40 life 800 speed 40 dir up from top }', '') } }); await withBrowser(async ({ open }) => { const p = await open('/d'); await ready(p); await p.waitForTimeout(500); const dims = await p.evaluate(`(() => { const c = document.querySelector('canvas[data-dssl-aura]'); return [c.width, innerWidth]; })()`); ok('on a 2x display the canvas is drawn at 2x so glow stays sharp', dims[0] === dims[1] * 2, JSON.stringify(dims)); }, { deviceScaleFactor: 2, pages: { '/d': page('.o { position: absolute; left: 200px; top: 200px; width: 80px; height: 40px; background: #345; aura-glow: #f80 20 }', '') } }); section('aura: range slider thumb anchor'); await withBrowser(async ({ open, errors }) => { const p = await open('/t'); await ready(p); await p.waitForTimeout(700); const meanX = async () => p.evaluate(`(() => { const L = ${engine}.aura.emitters.get(document.getElementById('r')).layers[0].parts; return L.length ? L.reduce((a, q) => a + q.x, 0) / L.length : -1; })()`); await p.fill('#r', '10'); await p.waitForTimeout(1100); const low = await meanX(); await p.fill('#r', '90'); await p.waitForTimeout(1500); const high = await meanX(); ok('`from thumb` follows the value: low at 10, high at 90', low > 0 && high > low + 150, `${low.toFixed(0)} vs ${high.toFixed(0)}`); ok('and lands within the slider\'s own width (100..400)', low >= 100 && high <= 400, `${low} ${high}`); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { pages: { '/t': page('#r { position: absolute; left: 100px; top: 300px; width: 300px; aura-particle: #7fe -> transparent count 30 life 400 speed 10 dir up spread 20 from thumb }', '') } }); section('aura: many glows'); await withBrowser(async ({ open, errors }) => { const p = await open('/glows'); await ready(p); await p.waitForTimeout(2500); const d = await dbg(p); ok('40 pulsing, flickering glows of different sizes cost under 6ms a frame (the blur is cached, not redone)', d.stats.frameMs < 6, `${d.stats.frameMs.toFixed(2)}ms`); ok('no page errors', errors.length === 0, errors.join(' | ')); }, { pages: { '/glows': page('.c { display: inline-block; margin: 14px; background: #223; radius: 6; aura-glow: #ff8800 30 pulse 1200 flicker 0.3 }', '