← back to Particle Text

test/gif.spec.js

70 lines

const { chromium } = require('playwright');
const fs = require('fs');
(async () => {
  const errors = [];
  const browser = await chromium.launch();
  const ctx = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
  const page = await ctx.newPage();
  page.on('console', m => { if (m.type() === 'error') errors.push('console:' + m.text()); });
  page.on('pageerror', e => errors.push('pageerror:' + e.message));
  const base = process.env.BASE || 'http://localhost:4180';
  const log = [], ok = (c, m) => log.push((c ? 'PASS ' : 'FAIL ') + m);

  await page.goto(base, { waitUntil: 'networkidle' });
  ok(await page.locator('#gif').isVisible(), 'GIF button present');
  ok(await page.locator('#code').isVisible(), 'Code button present');

  // --- Code copy ---
  await page.click('#code');
  await page.waitForTimeout(700);
  const clip = await page.evaluate(() => navigator.clipboard.readText());
  ok(/<!DOCTYPE html>/i.test(clip) && /particle/i.test(clip) && clip.length > 3000, 'code copied to clipboard (' + clip.length + ' chars)');

  // --- GIF save ---
  const [dl] = await Promise.all([
    page.waitForEvent('download', { timeout: 20000 }),
    page.click('#gif'),
  ]);
  const fn = dl.suggestedFilename();
  ok(/^starling-.*\.gif$/.test(fn), 'download fires: ' + fn);
  const path = (process.env.SAVEDIR || '.') + '/_gifcheck.gif';
  await dl.saveAs(path);
  const buf = fs.readFileSync(path);
  ok(buf.slice(0, 6).toString('ascii') === 'GIF89a', 'valid GIF89a header');
  const w = buf[6] | (buf[7] << 8), h = buf[8] | (buf[9] << 8);
  ok(w > 0 && h > 0, 'logical screen ' + w + 'x' + h);
  // count graphic-control-extension blocks (0x21 0xF9) == frame count
  let frames = 0;
  for (let i = 0; i + 1 < buf.length; i++) if (buf[i] === 0x21 && buf[i + 1] === 0xF9) frames++;
  ok(frames >= 20, 'animated: ' + frames + ' frames');
  // netscape loop present
  ok(buf.includes(Buffer.from('NETSCAPE2.0')), 'netscape loop extension present');
  ok(buf[buf.length - 1] === 0x3B, 'GIF trailer 0x3B present');
  ok(buf.length > 2000, 'gif size ' + (buf.length / 1024 | 0) + ' KB');

  // --- authoritative decode: let Chromium's own GIF decoder render it ---
  const gifUrl = base + '/_gifcheck.gif';
  const dec = await page.evaluate(async (url) => {
    const im = new Image(); im.src = url; im.style.position = 'fixed'; im.style.left = '-9999px';
    document.body.appendChild(im);           // attach so the animated GIF actually plays
    try { await im.decode(); } catch (e) {}
    if (!im.naturalWidth) return { w: 0, h: 0, nonbg: 0 };
    await new Promise(r => setTimeout(r, 1700)); // let it form the text
    const c = document.createElement('canvas'); c.width = im.naturalWidth; c.height = im.naturalHeight;
    const x = c.getContext('2d'); x.drawImage(im, 0, 0);
    const d = x.getImageData(0, 0, c.width, c.height).data; let nonbg = 0;
    for (let i = 0; i < d.length; i += 4) if (d[i] > 45 || d[i + 1] > 45 || d[i + 2] > 45) nonbg++;
    im.remove();
    return { w: im.naturalWidth, h: im.naturalHeight, nonbg };
  }, gifUrl);
  ok(dec.w === w && dec.h === h, 'Chromium decodes the GIF: ' + dec.w + 'x' + dec.h);
  // correct sparse frame-0 → few real lit px; corrupt LZW → 0 (blank) or tens-of-thousands (noise)
  ok(dec.nonbg > 10 && dec.nonbg < 40000, 'LZW decodes to real content (' + dec.nonbg + ' lit px — sparse, not blank/noise)');

  await browser.close();
  console.log(log.join('\n'));
  console.log('\nconsole/page errors: ' + (errors.length ? '\n' + errors.join('\n') : 'NONE'));
  console.log('gif saved: ' + path);
  process.exit(log.some(l => l.startsWith('FAIL')) || errors.length ? 1 : 0);
})().catch(e => { console.error('HARNESS ERROR', e); process.exit(2); });