← 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); });