File size: 13,286 Bytes
794b22f | 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 | #!/usr/bin/env node
/* test-designer-browser.mjs: the designer panel (dssl-vscode/media/designer.js) in a real browser.
* VS Code's webview API is replaced by a stub that records every message the panel posts, and
* the controls are driven the way a person would: type in a number box, pick from a menu, click.
* What this cannot cover is VS Code itself (the panel hosting, the editor insert): that is
* covered by test-designer-host.mjs against a fake of the editor API. */
import { withBrowser } from './lib/browser-harness.mjs';
import { decodePng, px, dist } from './lib/pixels.mjs';
import { PRESET_NAMES, GENERATORS } 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}`);
const PAGE = `<!doctype html><meta charset=utf-8><link rel="stylesheet" href="/dssl-vscode/media/designer.css">
<body data-tab="aura"><div id="app"></div>
<script>window.__sent = []; window.acquireVsCodeApi = () => ({ postMessage: (m) => window.__sent.push(m), getState: () => null, setState: (s) => { window.__state = s; } });</script>
<script type="module" src="/dssl-vscode/media/designer.js"></script>`;
/** set a control by its label: number box for sliders, the select/checkbox otherwise */
const rowOf = (p, tab, label) => p.locator(`section.panel:not([hidden]) label.row:has(> .lab:text-is("${label}"))`).first();
const setNum = async (p, label, v, nth = 0) => { const el = p.locator(`section.panel:not([hidden]) label.row:has(> .lab:text-is("${label}")) input.num`).nth(nth); await el.fill(String(v)); await p.waitForTimeout(40); };
const setSel = async (p, label, v) => { await p.locator(`section.panel:not([hidden]) label.row:has(> .lab:text-is("${label}")) select`).first().selectOption(v); await p.waitForTimeout(40); };
const setChk = async (p, label, v, nth = 0) => { const el = p.locator(`section.panel:not([hidden]) label.row:has(> .lab:text-is("${label}")) input[type=checkbox]`).nth(nth); if ((await el.isChecked()) !== v) await el.click(); await p.waitForTimeout(40); };
const code = (p) => p.inputValue('#code');
const status = (p) => p.textContent('.status');
const compiles = async (p) => /^✓/.test((await status(p)) || '');
await withBrowser(async ({ open, errors }) => {
const p = await open('/designer');
await p.waitForSelector('#code'); await p.waitForTimeout(600);
section('the panel');
ok('it loads with three tabs and tells the editor it is ready', (await p.locator('button.tab').count()) === 3 && (await p.evaluate('window.__sent')).some((m) => m.type === 'ready'));
ok('the first thing you see is a compiling aura', (await compiles(p)) && /aura-glow:/.test(await code(p)) && /aura-particle:/.test(await code(p)), await code(p));
ok('the aura preview is really running the engine\'s renderer', (await p.evaluate('window.__designer.aura.manager && window.__designer.aura.manager.stats.frames')) > 5);
section('aura: controls change the code and the preview');
const before = await code(p);
await setNum(p, 'Count', 120);
ok('typing a count changes the code (count 120) and it still compiles', /count 120/.test(await code(p)) && (await compiles(p)), await code(p));
await p.waitForTimeout(1500);
ok('and the preview follows: more particles alive', (await p.evaluate('window.__designer.aura.manager.stats.particles')) > 40);
await setSel(p, 'Shape', 'star'); await setSel(p, 'From', 'bottom');
ok('menus change words in the code', /shape star/.test(await code(p)) && /from bottom/.test(await code(p)));
await setChk(p, 'Behind the element', false);
ok('checkboxes add and remove flags', !/ behind/.test(await code(p)));
await setNum(p, 'Pulse (ms)', 1500); await setNum(p, 'Flicker', 0.4);
ok('glow pulse and flicker appear in the aura-glow line', /aura-glow:.*pulse 1500.*flicker 0\.4/.test(await code(p)), (await code(p)).split('\n')[0]);
await setSel(p, 'Write it as', 'single');
ok('"one aura: line" writes the single form and it compiles', /^aura: glow/.test(await code(p)) && /\n\s+particles/.test(await code(p)) && (await compiles(p)), await code(p));
await setSel(p, 'Write it as', 'separate');
await setChk(p, 'On', false, 0); await setChk(p, 'On', false, 1);
ok('with both layers off it writes aura: none', (await code(p)) === 'aura: none' && (await compiles(p)));
await setChk(p, 'On', true, 0); await setChk(p, 'On', true, 1);
section('aura: colour stops and graphs');
const stops = () => p.locator('.stop').count();
const n0 = await stops();
await p.locator('button.add:has-text("add a colour stop")').click();
ok('"add a colour stop" adds one more colour to the code', (await stops()) === n0 + 1 && (await code(p)).match(/ -> /g).length === n0);
await p.locator('.stop button.mini').first().click();
ok('and × removes one', (await stops()) === n0);
const curve = decodePng(await p.locator('#life-curve').screenshot());
let inked = 0; for (let y = 0; y < curve.height; y += 2) for (let x = 0; x < curve.width; x += 2) if (px(curve, x, y)[3] > 200 && dist(px(curve, x, y), [255, 159, 67]) < 40) inked++;
ok('the life-curve graph draws the opacity line', inked > 15, `${inked} orange pixels`);
await p.waitForTimeout(600);
const live = decodePng(await p.locator('#live-graph').screenshot());
let green = 0; for (let y = 0; y < live.height; y++) for (let x = 0; x < live.width; x++) if (dist(px(live, x, y), [123, 237, 159]) < 50) green++;
ok('the live graph draws the particle count', green > 20, `${green} green pixels`);
section('talking to the editor');
await p.click('#insert');
const sent = await p.evaluate('window.__sent');
const ins = sent.filter((m) => m.type === 'insert').pop();
ok('Insert posts exactly the code in the box', ins && ins.text === (await code(p)) && ins.text.length > 20);
await p.click('#copy');
ok('Copy posts a copy message too (the editor writes the clipboard)', (await p.evaluate('window.__sent')).some((m) => m.type === 'copy' && m.text === ins.text));
await p.click('#load');
ok('Load from selection asks the editor for it', (await p.evaluate('window.__sent')).some((m) => m.type === 'requestSelection'));
await p.evaluate(`window.postMessage({ type: 'selection', text: 'aura-glow: #00ff88 55 pulse 1200 flicker 0.2' }, '*')`); await p.waitForTimeout(300);
ok('a selection from the editor is read back into the controls', /aura-glow: #00ff88 55 pulse 1200 flicker 0\.2/.test(await code(p)) && !/aura-particle/.test(await code(p)), await code(p));
ok('and the number boxes show it', (await rowOf(p, 'aura', 'Radius').locator('input.num').inputValue()) === '55');
await p.evaluate(`window.postMessage({ type: 'selection', text: '.card { color: red }' }, '*')`); await p.waitForTimeout(200);
ok('a selection with nothing to load says so and changes nothing', /Nothing to load/.test(await p.textContent('.flash')) && /55/.test(await code(p)));
await p.evaluate(`window.postMessage({ type: 'selection', text: 'aura-glow: color.accent 24' }, '*')`); await p.waitForTimeout(300);
ok('a token colour is loaded with a plain-spoken note, not an error', /token or variable/.test(await p.textContent('.flash')));
section('texture');
await p.click('button.tab[data-tab=texture]'); await p.waitForTimeout(500);
const tiles = await p.locator('.tile').count();
ok(`every preset and generator has a tile (${PRESET_NAMES.length} + ${GENERATORS.length})`, tiles === PRESET_NAMES.length + GENERATORS.length, tiles);
const unpainted = await p.evaluate(`[...document.querySelectorAll('.tile .sw')].filter((e) => !e.style.backgroundImage || e.style.backgroundImage === 'none').length`);
ok('every tile shows its texture', unpainted === 0, `${unpainted} blank`);
await p.click('.tile[data-name=marble]'); await p.waitForTimeout(200);
ok('clicking a preset writes it', (await code(p)) === 'texture: marble' && (await compiles(p)), await code(p));
ok('and paints the big preview', (await p.evaluate(`document.getElementById('tex-swatch').style.backgroundImage`)).includes('data:image/svg+xml'));
await setNum(p, 'scale', 90); await setNum(p, 'intensity', 0.6);
ok('changing settings writes only what changed', /^texture: marble scale 90 intensity 0\.6$/.test(await code(p)) && (await compiles(p)), await code(p));
await p.locator('button:has-text("stack another")').click(); await p.waitForTimeout(200);
ok('stacking another layer writes a comma list', /^texture: marble scale 90 intensity 0\.6, grain$/.test(await code(p)), await code(p));
await p.click('.tile[data-name=scanlines]'); await p.waitForTimeout(200);
ok('the stacked layer can be changed on its own', /marble scale 90 intensity 0\.6, scanlines$/.test(await code(p)), await code(p));
await p.locator('button:has-text("remove this")').click(); await p.waitForTimeout(200);
ok('and removed', !/scanlines/.test(await code(p)));
await setChk(p, 'own colour', true); await p.waitForTimeout(100);
ok('an own colour appears in the code', /color #000000/.test(await code(p)) && (await compiles(p)), await code(p));
await p.locator('button:has-text("reset this layer")').click(); await p.waitForTimeout(200);
ok('reset puts the layer back to its preset', (await code(p)) === 'texture: marble');
await p.click('.tile[data-name=noise]'); await setNum(p, 'octaves', 5); await setNum(p, 'contour', 0.05);
ok('a bare generator with settings compiles', /^texture: noise.*octaves 5.*contour 0\.05/.test(await code(p)) && (await compiles(p)), await code(p));
await p.evaluate(`window.postMessage({ type: 'selection', text: 'texture: wood scale 9 color #331100' }, '*')`); await p.waitForTimeout(300);
ok('a texture selection switches to the texture tab and loads', /^texture: wood scale 9 color #331100$/.test(await code(p)), await code(p));
section('slider');
await p.click('button.tab[data-tab=slider]'); await p.waitForTimeout(500);
ok('the default slider compiles and the preview is styled with the generated rules', (await compiles(p)) && (await p.evaluate(`document.getElementById('pv-style').textContent`)).includes('#pv::-webkit-slider-thumb'), await code(p));
const box = await p.evaluate(`(() => { const r = document.getElementById('pv').getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; })()`);
const img = decodePng(await p.screenshot({ clip: { x: box.x, y: box.y, width: box.w, height: box.h } }));
ok('the preview slider shows the fill colour left of the thumb (orange) and the track right of it', dist(px(img, box.w * 0.2, box.h / 2), [255, 122, 0]) < 30 && dist(px(img, box.w * 0.9, box.h / 2), [68, 85, 102]) < 30, `${px(img, box.w * 0.2, box.h / 2)} ${px(img, box.w * 0.9, box.h / 2)}`);
await setSel(p, 'Shape', 'diamond'); await setChk(p, 'Ring', true); await setChk(p, 'Ticks', true);
ok('shape, ring and ticks write clauses and still compile', /shape diamond/.test(await code(p)) && /ring/.test(await code(p)) && /ticks/.test(await code(p)) && (await compiles(p)), await code(p));
await setChk(p, 'Show graph', true); await p.waitForTimeout(150);
ok('the graph option writes the bars', /graph \d/.test(await code(p)) && /graph-height/.test(await code(p)) && (await compiles(p)));
const bars0 = (await code(p)).match(/graph ([\d ]+),/)[1];
await p.locator('button:has-text("ramp")').click(); await p.waitForTimeout(150);
ok('the "ramp" button reshapes the bars', (await code(p)).match(/graph ([\d ]+),/)[1] !== bars0);
await p.locator('button:has-text("+ bar")').click(); await p.waitForTimeout(100);
ok('+ bar adds a bar', (await code(p)).match(/graph ([\d ]+),/)[1].split(' ').length === bars0.split(' ').length + 1);
await p.evaluate(`window.postMessage({ type: 'selection', text: 'slider: track 4 #123456 radius 2, fill none, thumb 30 #ffffff, shape pill' }, '*')`); await p.waitForTimeout(300);
ok('a slider selection loads (fill off, pill thumb)', /fill none/.test(await code(p)) && /shape pill/.test(await code(p)) && /thumb 30/.test(await code(p)), await code(p));
section('state and errors');
ok('the panel keeps its state for when the tab is hidden and shown again', (await p.evaluate('window.__state')) && (await p.evaluate('window.__state.tab')) === 'slider');
ok('no page errors anywhere in that session', errors.length === 0, errors.join(' | '));
}, { pages: { '/designer': PAGE }, viewport: { width: 1100, height: 900 } });
// the same panel on a phone-ish narrow width must not break
await withBrowser(async ({ open, errors }) => {
const p = await open('/designer'); await p.waitForSelector('#code'); await p.waitForTimeout(500);
const overflow = await p.evaluate(`document.documentElement.scrollWidth > innerWidth + 1`);
ok('a narrow panel (VS Code side column) does not overflow sideways', !overflow);
ok('and the controls stack into one column', (await p.evaluate(`getComputedStyle(document.querySelector('.cols')).gridTemplateColumns.split(' ').length`)) === 1);
ok('no page errors', errors.length === 0, errors.join(' | '));
}, { pages: { '/designer': PAGE }, viewport: { width: 560, height: 800 } });
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
|