import {chromium} from '@playwright/test'; import fs from 'node:fs';import path from 'node:path';import {pathToFileURL} from 'node:url';import assert from 'node:assert/strict';import crypto from 'node:crypto';import vm from 'node:vm'; const root=path.resolve(import.meta.dirname,'..'),read=p=>fs.readFileSync(path.join(root,p),'utf8'); const manifest=JSON.parse(read('dist/animejs-gallery-manifest.json')),partial=process.argv.includes('--allow-partial'); assert.ok(manifest.complete||partial,'Incomplete release catalog: '+manifest.missingGroups.join(', ')+'. Use --allow-partial only for development.'); const sandbox={window:{}};vm.runInNewContext(read('assets/animejs/catalog-data.js'),sandbox); const catalog=JSON.parse(JSON.stringify(sandbox.window.animeExamples)),ids=catalog.map(c=>c.id),groups=[...new Set(catalog.map(c=>c.group))]; assert.deepEqual(ids,manifest.ids);assert.deepEqual(groups,manifest.groups);assert.equal(manifest.examples,ids.length); assert.deepEqual(manifest.missingGroups,manifest.requiredGroups.filter(g=>!groups.includes(g))); assert.deepEqual(manifest.missingIds,manifest.requiredIds.filter(id=>!ids.includes(id))); assert.equal(manifest.complete,manifest.missingGroups.length===0&&manifest.missingIds.length===0); if(!partial){for(const id of manifest.requiredIds)assert.ok(ids.includes(id),'required release ID '+id);for(const group of manifest.requiredGroups)assert.ok(catalog.filter(c=>c.group===group).length>=2,'release group '+group);} assert.equal(Object.keys(manifest.files).length,ids.length+groups.length+1); for(const base of ['assets/animejs','dist/'+manifest.package])assert.deepEqual(fs.readdirSync(path.join(root,base),{recursive:true}).filter(p=>p.endsWith('.html')).sort(),Object.keys(manifest.files).sort(),base+' exact HTML inventory'); for(const [file,hash] of Object.entries(manifest.inputs))assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root,file))).digest('hex'),hash,'Stale build input: '+file); for(const [file,hash] of Object.entries(manifest.files))assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root,'dist',manifest.package,file))).digest('hex'),hash); // Bind generated data to authored records, not merely to its own manifest. const original={window:{}};vm.runInNewContext(read('assets/animejs/examples.js'),original); for(const c of original.window.animeExamples){const built=catalog.find(x=>x.id===c.id);for(const [k,v] of Object.entries(c))assert.equal(JSON.stringify(built[k]),JSON.stringify(v),c.id+' legacy '+k);} for(const c of catalog.filter(c=>c.kind!=='industry')){assert.equal(c.beatSource,'runtime');assert.equal(c.beats,undefined,'No stale legacy times in generated metadata');} for(const group of groups){ const expected=catalog.filter(c=>c.group===group).map(c=>c.id).sort(); if(!['elements','compositions'].includes(group)){ const source=JSON.parse(read(`assets/animejs/catalogs/${group}.json`));assert.deepEqual(source.map(c=>c.id).sort(),expected); for(const c of source)for(const [k,v] of Object.entries(c))assert.deepEqual(catalog.find(x=>x.id===c.id)[k],v,c.id+' '+k); } const registry=path.join(root,`references/registry-animejs-${group}.md`),text=fs.readFileSync(registry,'utf8'); assert.deepEqual([...text.matchAll(/^\| `([^`]+)` \|/gm)].map(m=>m[1]).sort(),expected,group+' registry IDs'); for(const match of text.matchAll(/\]\(([^)]+)\)/g)){const target=match[1].split('#')[0];if(target&&!target.includes('://'))assert.ok(fs.existsSync(path.resolve(path.dirname(registry),target)),target);} } function sameVisual(actual,expected,message='SVG equality'){if(actual===expected)return;let i=0;while(ipage.locator('[data-beat]').evaluateAll(es=>es.map(e=>({at:Number(e.dataset.beat),label:e.textContent}))); function legacyBeats(c,total){ const labels={'svg-draw':['Source','Reveal connection','Connected'],'motion-path':['Send','In transit','Receive'],'spatial-stagger':['Grid ready','Center outward','Activated'],'dependency-flow':['Input','Check','Output'],'assemble-explode':['Assembled','Separate','Inspect hold','Reassembled']}[c.id]; const times=c.id==='assemble-explode'?[0,c.duration,c.duration+200,total]:[0,total/2,total]; return labels.map((label,i)=>({at:times[i],label})); } const legacyEdits={ 'svg-draw':[{duration:450},{duration:1350}], 'motion-path':[{duration:480},{duration:1500}], 'spatial-stagger':[{duration:180,columns:4,rows:2,delay:65},{duration:520,columns:3,rows:4,delay:0},{duration:240,columns:5,rows:4,delay:140}], 'dependency-flow':[{duration:240},{duration:950}], 'assemble-explode':[{duration:180,distance:65},{duration:1100,distance:90}] }; async function checkEditedLegacy(page,c,reducedMotion){ const {id}=c; // Remount edited configs in the already-loaded source AND built page; no rebuild. const expectedDuration=await page.evaluate(c=>{ const old=animeGallery.controllers[c.id];old.destroy(); animeGallery.controllers[c.id]=animeGallery.mount(document.getElementById(c.id),c); if(c.id==='spatial-stagger'){ const cells=[...document.querySelectorAll('.cell')],delay=anime.stagger(c.delay,{grid:[c.columns,c.rows],from:'center'}); return c.duration+Math.max(...cells.map((e,i)=>delay(e,i,cells))); } return c.id==='assemble-explode'?2*c.duration+400:c.id==='dependency-flow'?2*c.duration:c.duration; },c); const call=(method,arg)=>page.evaluate(({id,method,arg})=>animeGallery.controllers[id][method](arg),{id,method,arg}); const visual=()=>page.locator('svg').evaluate(e=>e.outerHTML),final=await visual(),s=await call('state'),beats=await readBeats(page); assert.ok(Math.abs(s.duration-expectedDuration)<.01,id+' edited duration includes all delays/hold'); assert.deepEqual(beats,legacyBeats(c,s.duration),id+' edited beat semantics'); assert.ok(beats.every(b=>Number.isFinite(b.at)&&b.at>=0&&b.at<=s.duration)); if(id==='spatial-stagger')assert.equal(await page.locator('.cell').count(),c.columns*c.rows); for(const beat of beats){ await page.getByRole('button',{name:beat.label,exact:true}).click(); assert.equal((await call('state')).time,reducedMotion==='reduce'?s.duration:beat.at); if(reducedMotion==='reduce')sameVisual(await visual(),final); } if(reducedMotion==='no-preference'){ await call('seek',s.duration*.25);assert.notEqual(await visual(),final,id+' edited motion changes SVG'); if(id==='assemble-explode'){ const hold=beats.find(b=>b.label==='Inspect hold'); assert.ok(hold.at>c.duration&&hold.ates.map(e=>new DOMMatrix(getComputedStyle(e).transform).m41)),[-c.distance,0,c.distance],'Fixed 400ms hold from both seek directions'); } } if(id==='dependency-flow'){ await call('seek',c.duration);const checked=await visual();await call('seek',s.duration);assert.notEqual(await visual(),checked,'Output connection draws after Check'); } } await call('replay'); if(reducedMotion==='no-preference'){ await page.waitForTimeout(70);const running=await call('state');assert.ok(running.time>0&&running.time{const s=animeGallery.controllers[id].state();return s.paused&&s.time===s.duration;},id); sameVisual(await visual(),final,id+' edited natural completion at speed 1'); if(id==='spatial-stagger')assert.ok(await page.locator('.cell').evaluateAll(es=>es.every(e=>Number(getComputedStyle(e).opacity)===1)),'Activated includes last staggered cell'); return {duration:s.duration,beats,playback:reducedMotion==='reduce'?'suppressed':'natural completion at speed 1'}; } const evidence=path.join(root,'evidence');fs.mkdirSync(evidence,{recursive:true}); const report={package:manifest.package,completeCatalog:manifest.complete,missingGroups:manifest.missingGroups,missingIds:manifest.missingIds,examples:ids.length,groups:groups.length,htmlPages:Object.keys(manifest.files).length,files:manifest.files,playback:'Every normal-motion example: 120ms at engine speed 1, then natural completion at engine speed 12. Representative first legacy/industry complete at speed 1 on dist. Reduced motion settles without playback.',runs:[],findings:[],status:'running'}; const browser=await chromium.launch({headless:true,...(process.env.CHROMIUM_EXECUTABLE?{executablePath:process.env.CHROMIUM_EXECUTABLE}:{})}); try{for(const variant of ['source','dist'])for(const reducedMotion of ['no-preference','reduce']){ const ctx=await browser.newContext({offline:true,reducedMotion,viewport:{width:1280,height:1000}}),page=await ctx.newPage(),errors=[],network=[]; ctx.on('page',p=>p.on('pageerror',e=>errors.push(String(e))));page.on('pageerror',e=>errors.push(String(e)));page.on('console',m=>{if(m.type()==='error')errors.push(m.text());});ctx.on('request',r=>{if(!r.url().startsWith('file:')&&!r.url().startsWith('data:'))network.push(r.url());}); const base=path.join(root,variant==='source'?'assets/animejs':'dist/'+manifest.package),url=p=>pathToFileURL(path.join(base,p)).href; const run={variant,reducedMotion,examples:[],hierarchy:false};report.runs.push(run); for(const group of ['all',...groups]){ await page.goto(url(group==='all'?'index.html':group+'/index.html')); const expected=catalog.filter(c=>group==='all'||c.group===group).map(c=>c.id); assert.deepEqual(await page.locator('iframe').evaluateAll(f=>f.map(e=>e.dataset.example)),expected); assert.equal(await page.evaluate(()=>typeof window.animeGallery),'undefined'); for(const id of expected){ const f=page.locator(`iframe[data-example="${id}"]`),target=url('examples/'+id+'.html'); assert.equal(await f.evaluate(e=>e.src),target);assert.equal(await page.locator(`a.open-example[href$="/${id}.html"]`).evaluate(e=>e.href),target); const inner=await(await f.elementHandle()).contentFrame();await inner.waitForFunction(()=>window.animeGallery); assert.deepEqual(await inner.evaluate(()=>Object.keys(animeGallery.controllers)),[id]);assert.ok(await inner.locator('article h2').isVisible());assert.ok(await inner.locator('svg').isVisible());assert.equal(await inner.locator('.guidance').isVisible(),false); assert.ok(await inner.evaluate(()=>document.documentElement.scrollHeight<=innerHeight+2),id+' preview overflow'); await inner.getByRole('button',{name:'Play',exact:true}).click(); if(reducedMotion==='no-preference'){await page.waitForTimeout(60);assert.ok(await inner.evaluate(id=>animeGallery.controllers[id].state().time>0,id),id+' embedded playback');} await inner.getByRole('button',{name:'Settle',exact:true}).click(); assert.ok(await inner.evaluate(id=>{const s=animeGallery.controllers[id].state();return s.paused&&s.time===s.duration;},id),id+' embedded settle'); } if(group==='all')for(const g of groups)assert.equal(await page.locator(`section[data-registry="${g}"] h2 a`).evaluate(e=>e.href),url(g+'/index.html')); if(group==='all'&&variant==='dist'&&reducedMotion==='no-preference'){ await page.evaluate(()=>window.scrollTo({top:0,left:0,behavior:'instant'})); await page.waitForFunction(()=>scrollY===0); assert.ok(await page.locator('.registry-nav').evaluate(e=>{const r=e.getBoundingClientRect();return r.top>=0&&r.bottom<=innerHeight;}),'Discovery navigation in overview'); await page.screenshot({path:path.join(evidence,'animejs-0.6.0-overview.png')}); } await page.locator('a.open-example').first().click();assert.equal(await page.locator('body').getAttribute('data-example'),expected[0]); await page.getByRole('link',{name:'All examples',exact:true}).click();assert.equal(page.url(),url('index.html')); } run.hierarchy=true; for(const c of catalog){const {id}=c;try{ await page.goto(url('examples/'+id+'.html'));await page.waitForFunction(()=>window.animeGallery); assert.deepEqual(await page.evaluate(()=>Object.keys(animeGallery.controllers)),[id]);assert.equal(await page.locator('h1').textContent(),c.title); if(c.kind==='industry')assert.deepEqual(await page.evaluate(()=>Object.keys(industryRenderers).sort()),catalog.filter(x=>x.group===c.group).map(x=>x.id).sort(),'exact sector registrations'); assert.equal(await page.locator('svg').getAttribute('viewBox'),c.kind==='industry'?'0 0 960 540':'0 0 400 220'); assert.deepEqual(await page.locator('svg').evaluate(svg=>[...svg.querySelectorAll('text')].flatMap(e=>{const issues=[],style=getComputedStyle(e),size=e.getAttribute('font-size'),fill=e.getAttribute('fill');if(size&&/^[0-9.]+$/.test(size)&&parseFloat(style.fontSize)!==Number(size))issues.push('font-size overridden: '+e.textContent);if(fill&&fill!=='none'&&fill!=='currentColor'&&!fill.startsWith('url(')){const probe=document.createElement('span');probe.style.color=fill;document.body.append(probe);if(style.fill!==getComputedStyle(probe).color)issues.push('fill overridden: '+e.textContent);probe.remove();}return issues;})),[],id+' authored text styles'); for(const key of ['useWhen','avoid','assumptions'])assert.ok((await page.locator('.guidance').textContent()).includes(c[key])); const call=(method,arg)=>page.evaluate(({id,method,arg})=>animeGallery.controllers[id][method](arg),{id,method,arg}),state=()=>call('state'),visual=()=>page.locator('#'+id+' svg').evaluate(e=>e.outerHTML); const bounds=async()=>assert.deepEqual(await page.locator('svg').evaluate(svg=>{const r=svg.getBoundingClientRect();return [...svg.querySelectorAll('text,rect,circle,ellipse,path,line,polygon,polyline')].filter(e=>{for(let n=e;n&&n!==svg;n=n.parentElement){if(getComputedStyle(n).visibility==='hidden'||Number(getComputedStyle(n).opacity)===0)return false;}return true;}).filter(e=>{const b=e.getBoundingClientRect();return b.leftr.right+2||b.topr.bottom+2;}).map(e=>e.tagName+':'+(e.textContent||e.getAttribute('d')||'').slice(0,70));}),[],id+' visible bounds'); const final=await visual(),s=await state();assert.ok(Number.isFinite(s.duration)&&s.duration>0);assert.equal(s.time,s.duration);await bounds(); const beats=await readBeats(page); assert.deepEqual(beats,c.kind==='industry'?c.beats:legacyBeats(c,s.duration),id+' exact named beats'); for(const beat of beats)assert.ok(Number.isFinite(beat.at)&&beat.at>=0&&beat.at<=s.duration,id+' '+beat.label); if(reducedMotion==='no-preference'){ const samples=[s.duration*.17,s.duration*.45,s.duration*.73,...beats.map(b=>b.at),...beats.slice(1).map((b,i)=>(b.at+beats[i].at)/2)]; for(const at of samples){await call('settle');await call('seek',at);const backward=await visual();await bounds();await call('seek',0);await call('seek',at);sameVisual(await visual(),backward,id+' deterministic seek at '+at);} await call('seek',s.duration*.3);const earlier=await visual();await call('seek',s.duration*.8);assert.notEqual(await visual(),earlier,id+' changes with time'); if(id==='assemble-explode'){await call('seek',900);assert.deepEqual(await page.locator('[data-part]').evaluateAll(parts=>parts.map(e=>new DOMMatrix(getComputedStyle(e).transform).m41)),[-105,0,105]);} for(const beat of beats){await page.locator(`[data-beat="${beat.at}"]`).click();assert.equal((await state()).time,beat.at);} await call('seek',-100);assert.equal((await state()).time,0);await call('seek',s.duration+100);assert.equal((await state()).time,s.duration); await page.locator('input').focus();await page.keyboard.press('Home');assert.equal((await state()).time,0);await page.keyboard.press('ArrowRight');assert.ok((await state()).time>0); }else{await call('seek',0);sameVisual(await visual(),final);await page.locator('[data-beat]').first().click();sameVisual(await visual(),final);} await call('settle');await page.locator('[data-action="play"]').focus();await page.keyboard.press('Enter'); if(reducedMotion==='no-preference'){ await page.waitForTimeout(120);const running=await state();assert.ok(running.time>0&&running.time{anime.engine.speed=12;}); } await page.waitForFunction(id=>{const s=animeGallery.controllers[id].state();return s.paused&&s.time===s.duration;},id);sameVisual(await visual(),final,id+' natural completion'); await page.evaluate(()=>{anime.engine.speed=1;}); await page.locator('[data-action="replay"]').focus();await page.keyboard.press('Space'); if(variant==='dist'&&reducedMotion==='no-preference'&&(id===ids[0]||id===catalog.find(x=>x.kind==='industry')?.id))await page.waitForFunction(id=>{const s=animeGallery.controllers[id].state();return s.paused&&s.time===s.duration;},id); await page.locator('[data-action="settle"]').click();sameVisual(await visual(),final); if(variant==='dist'&&reducedMotion==='no-preference'&&(id==='assemble-explode'||id===catalog.find(x=>x.kind==='industry')?.id)){ await page.locator('article').screenshot({path:path.join(evidence,`${manifest.package}-${id}-final.png`)});await call('seek',s.duration*.45);await page.locator('article').screenshot({path:path.join(evidence,`${manifest.package}-${id}-mid.png`)}); } await call('replay');await page.emulateMedia({reducedMotion:'reduce'});await page.waitForFunction(id=>{const s=animeGallery.controllers[id].state();return s.paused&&s.time===s.duration;},id);await page.emulateMedia({reducedMotion:'no-preference'}); for(const event of ['beforeprint','pagehide']){await call('replay');await page.evaluate(event=>dispatchEvent(new Event(event)),event);assert.equal((await state()).paused,true);assert.equal((await state()).time,s.duration);} await call('replay');await call('destroy');await call('destroy');assert.ok((await state()).destroyed);assert.equal(await page.locator('#'+id+' > *').count(),0); await page.evaluate(id=>{animeGallery.controllers[id]=animeGallery.mount(document.getElementById(id),animeExamples.find(c=>c.id===id));},id);assert.equal(await page.locator('article button').count(),3+beats.length);sameVisual(await visual(),final,id+' remount'); await page.emulateMedia({reducedMotion}); if(c.kind!=='industry')for(const edits of legacyEdits[id]){ const config={...c,...edits}; const result=await checkEditedLegacy(page,config,reducedMotion); (run.legacyConfigurations??=[]).push({id,edits,...result}); } run.examples.push(id); }catch(error){report.findings.push({variant,reducedMotion,id,error:String(error.stack)});await page.emulateMedia({reducedMotion});} } assert.deepEqual(errors,[]);assert.deepEqual(network,[]);Object.assign(run,{passed:run.examples.length===ids.length,checksApplyTo:'Successful IDs in examples; failures in findings',deterministicSeek:reducedMotion==='no-preference',cleanupRemount:true,beatControls:true,bounds:true,errors,network});await ctx.close(); }assert.equal(report.findings.length,0,'Recipe integration findings: '+report.findings.map(f=>f.variant+'/'+f.id).join(', ')+' (see evidence report)');report.status='passed';console.log(JSON.stringify({status:report.status,examples:ids.length,runs:report.runs.map(r=>({variant:r.variant,motion:r.reducedMotion,examples:r.examples.length})),completeCatalog:manifest.complete}));} catch(error){report.status='failed';report.failure=String(error.stack);throw error;} finally{fs.writeFileSync(path.join(evidence,manifest.package+'-test-report.json'),JSON.stringify(report,null,2)+'\n');await browser.close();}