← back to Graphics Agentabrams

tests/e2e.mjs

193 lines

// Headless E2E for the graphics hub. Needs a static server (fetch of graphics.json
// fails on file://), so it spins one up on an ephemeral port.
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import path from 'path';
import http from 'http';
import fs from 'fs';

const require = createRequire(import.meta.url);
let chromium;
try {
  ({ chromium } = require('/Users/macstudio3/.npm-global/lib/node_modules/playwright'));
} catch {
  ({ chromium } = require('playwright'));
}

const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const MIME = { '.html': 'text/html', '.json': 'application/json', '.png': 'image/png', '.aa': 'text/html' };
const server = http.createServer((req, res) => {
  let p = decodeURIComponent(req.url.split('?')[0].split('#')[0]);
  if (p.endsWith('/')) p += 'index.html';
  const f = path.join(root, p);
  if (!f.startsWith(root) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) {
    res.writeHead(404); res.end('nf'); return;
  }
  res.writeHead(200, { 'Content-Type': MIME[path.extname(f)] || 'text/plain' });
  fs.createReadStream(f).pipe(res);
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${server.address().port}/`;

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
const errors = [];
page.on('pageerror', e => errors.push('pageerror: ' + e.message));
page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); });

const results = [];
const check = (name, ok) => { results.push({ name, ok }); console.log((ok ? 'PASS' : 'FAIL') + '  ' + name); };

await page.goto(url);
await page.waitForSelector('.item');
check('graphic list renders from manifest', await page.locator('.item').count() >= 1);
check('Spirograph.aa listed', (await page.locator('.item .fname').first().textContent()) === 'Spirograph.aa');
check('first graphic auto-selected', await page.locator('.item.active').count() === 1);

const frame = page.frameLocator('#frame');
await frame.locator('#canvas').waitFor({ timeout: 15000 });
check('graphic iframe loads the canvas', await frame.locator('#canvas').count() === 1);

const spiro = page.frames().find(f => f.url().includes('spirograph'));
// canvas has a non-zero backing store (hi-DPI scaled)
const dims = await spiro.evaluate(() => { const c = document.getElementById('canvas'); return { w: c.width, h: c.height }; });
check('canvas backing store sized (hi-DPI)', dims.w >= 760 && dims.h >= 760);

// the animation actually draws — trail pixel count grows after a moment
const drew = await spiro.evaluate(async () => {
  const c = document.getElementById('canvas');
  const g = c.getContext('2d');
  const count = () => { const d = g.getImageData(0, 0, c.width, c.height).data; let n = 0; for (let i = 3; i < d.length; i += 4) if (d[i] > 8) n++; return n; };
  const before = count();
  await new Promise(r => setTimeout(r, 700));
  return count() - before;
});
check('spirograph animates (pixels drawn)', drew > 500);

// controls exist and update
const hasControls = await spiro.evaluate(() => ['R','r','d','speed','lw','colormode','play','random','clear','save']
  .every(id => document.getElementById(id)));
check('all controls present', hasControls);

// ink-color row hidden in non-solid mode (regression: .ctrl display:flex vs [hidden])
const inkHidden = await spiro.evaluate(() => {
  const row = document.getElementById('solidRow');
  return getComputedStyle(row).display === 'none';
});
check('ink-color row hidden outside Solid mode', inkHidden);

// switching to Solid reveals the ink row
const inkShown = await spiro.evaluate(() => {
  const sel = document.getElementById('colormode');
  sel.value = 'solid'; sel.dispatchEvent(new Event('input'));
  return getComputedStyle(document.getElementById('solidRow')).display !== 'none';
});
check('ink-color row shown in Solid mode', inkShown);

// let it run so the screenshot shows a real curve
await spiro.evaluate(() => new Promise(r => setTimeout(r, 2500)));

// pause button toggles running state
const paused = await spiro.evaluate(() => { const b = document.getElementById('play'); b.click(); return b.textContent.includes('Play'); });
check('pause toggles play/pause label', paused);

// deep link
await page.goto(url + '#spirograph');
await page.waitForSelector('.item.active');
check('hash deep-link selects graphic', await page.locator('.item.active').count() === 1);

// ---- Living Fractal Tree ----
check('Living Fractal Tree listed', await page.locator('.item .fname', { hasText: 'LivingFractalTree.aa' }).count() === 1);

await page.goto(url + '#living-fractal-tree');
await page.waitForSelector('.item.active');
const treeFrame = page.frameLocator('#frame');
await treeFrame.locator('#c').waitFor({ timeout: 15000 });
check('tree iframe loads its canvas', await treeFrame.locator('#c').count() === 1);

const tree = page.frames().find(f => f.url().includes('graphics/living-fractal-tree/'));
const treeDims = await tree.evaluate(() => { const c = document.getElementById('c'); return { w: c.width, h: c.height }; });
check('tree canvas backing store sized (hi-DPI)', treeDims.w >= 760 && treeDims.h >= 760);

// the tree actually draws — coloured (non-background) pixels appear as it grows
const treeDrew = await tree.evaluate(async () => {
  const c = document.getElementById('c');
  const g = c.getContext('2d');
  await new Promise(r => setTimeout(r, 2500));   // let it grow in
  const d = g.getImageData(0, 0, c.width, c.height).data;
  let n = 0; for (let i = 3; i < d.length; i += 4) if (d[i] > 8) n++;
  return n;
});
check('tree animates and grows (pixels drawn)', treeDrew > 5000);

// tree controls present
const treeControls = await tree.evaluate(() => ['depth','wind','seasonBtn','autoBtn','regrowBtn']
  .every(id => document.getElementById(id)));
check('tree controls present', treeControls);

// ---- Flow Field ----
check('Flow Field listed', await page.locator('.item .fname', { hasText: 'FlowField.aa' }).count() === 1);

await page.goto(url + '#flowfield');
await page.waitForSelector('.item.active');
const ffFrame = page.frameLocator('#frame');
await ffFrame.locator('#canvas').waitFor({ timeout: 15000 });
check('flow field iframe loads its canvas', await ffFrame.locator('#canvas').count() === 1);

const ff = page.frames().find(f => f.url().includes('graphics/flowfield/'));
const ffDims = await ff.evaluate(() => { const c = document.getElementById('canvas'); return { w: c.width, h: c.height }; });
check('flow field canvas backing store sized (hi-DPI)', ffDims.w >= 760 && ffDims.h >= 760);

// paints coloured trails (the original artifact's bug: all-white / blank)
const ffPaint = await ff.evaluate(async () => {
  await new Promise(r => setTimeout(r, 1200));   // let trails accumulate
  const c = document.getElementById('canvas');
  const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
  let colored = 0, white = 0;
  for (let i = 0; i < d.length; i += 4 * 53) {
    const mx = Math.max(d[i], d[i+1], d[i+2]), mn = Math.min(d[i], d[i+1], d[i+2]);
    if (mx > 40 && mx - mn > 25) colored++;
    if (d[i] > 235 && d[i+1] > 235 && d[i+2] > 235) white++;
  }
  return { colored, white };
});
check('flow field paints coloured trails (not white)', ffPaint.colored > 1000 && ffPaint.white === 0);

// arrow keys pan the field
const ffPan = await ff.evaluate(async () => {
  window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
  await new Promise(r => setTimeout(r, 500));
  window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' }));
  return typeof fieldOX !== 'undefined' ? fieldOX : null;
});
check('flow field arrow keys pan the field', ffPan !== null && Math.abs(ffPan) > 1);

// pointer (mouse) interaction ramps up influence
const ffPtr = await ff.evaluate(async () => {
  const c = document.getElementById('canvas');
  c.dispatchEvent(new MouseEvent('mousedown', { clientX: 500, clientY: 300, bubbles: true }));
  c.dispatchEvent(new MouseEvent('mousemove', { clientX: 500, clientY: 300, bubbles: true }));
  await new Promise(r => setTimeout(r, 350));
  return { strength: pointer.strength, active: pointer.active };
});
check('flow field pointer interaction active', ffPtr.active === true && ffPtr.strength > 0.1);

// controls present
const ffControls = await ff.evaluate(() => ['btn-reseed','btn-save','btn-pause','btn-clear','btn-mode','s-count','s-scale','s-speed']
  .every(id => document.getElementById(id)));
check('flow field controls present', ffControls);

await page.goto(url + '#flowfield');
await page.waitForSelector('.item.active');
await page.frameLocator('#frame').locator('#canvas').waitFor({ timeout: 15000 });
await page.waitForTimeout(1500);
await page.screenshot({ path: path.join(root, 'tests', 'screenshot.png') });
check('no console/page errors', errors.length === 0);
if (errors.length) console.log(errors.join('\n'));

await browser.close();
server.close();
const failed = results.filter(r => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} passed`);
process.exit(failed.length ? 1 : 0);