Files
archify/test/export-browser.test.mjs

284 lines
22 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { ChromeVisualBrowser, findChrome } from '../bin/visual-check.mjs';
const skillRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const chrome = process.env.ARCHIFY_CHROME ? findChrome() : null;
test('Export preserves menu, clipboard, semantic cards and recording lifecycles', {
skip: chrome ? false : 'Set ARCHIFY_CHROME to run real-browser Export checks.',
}, async (t) => {
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-export-'));
t.after(() => fs.rmSync(scratch, { recursive: true, force: true }));
const evidence = process.env.ARCHIFY_EXPORT_RUNTIME_EVIDENCE;
const records = [];
if (evidence) fs.mkdirSync(evidence, { recursive: true });
t.after(() => { if (evidence) fs.writeFileSync(path.join(evidence, 'observations.json'), JSON.stringify(records, null, 2) + '\n'); });
const input = path.join(scratch, 'motion.json');
const file = path.join(scratch, 'motion.html');
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/web-app.architecture.json'), 'utf8'));
source.meta.animation = 'trace';
source.meta.visual_preset = 'signal-flow';
fs.writeFileSync(input, JSON.stringify(source));
execFileSync(process.execPath, [path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'), input, file]);
const browser = new ChromeVisualBrowser(chrome);
t.after(() => browser.close());
const session = await browser.sessionPromise;
const send = (method, params = {}) => browser.cdp.send(method, params, session);
await browser.cdp.send('Browser.setDownloadBehavior', { behavior: 'deny' });
await send('Emulation.setFocusEmulationEnabled', { enabled: true });
async function run(expression) {
const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
assert.equal(result.exceptionDetails, undefined, result.exceptionDetails?.exception?.description);
return result.result?.value;
}
// Instrument browser boundaries only: all serialization, drawing and encoding
// remain production code. Each navigation restores the original environment.
await send('Page.addScriptToEvaluateOnNewDocument', { source: `
window.exportErrors=[];window.exportAlerts=[];window.exportConsole=[];
addEventListener('error',e=>exportErrors.push(e.message));
addEventListener('unhandledrejection',e=>exportErrors.push(String(e.reason)));
window.alert=message=>exportAlerts.push(message);
console.error=(...args)=>exportConsole.push(args.map(String).join(' '));
window.exportUrls=new Map();window.exportDownloads=[];window.exportTracks=[];window.exportCancelled=[];
const create=URL.createObjectURL.bind(URL),revoke=URL.revokeObjectURL.bind(URL);
URL.createObjectURL=blob=>{const url=create(blob);exportUrls.set(url,{blob,revoked:false});return url;};
URL.revokeObjectURL=url=>{const item=exportUrls.get(url);if(item)item.revoked=true;revoke(url);};
const click=HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click=function(){if(this.download){exportDownloads.push({name:this.download,blob:exportUrls.get(this.href).blob,attached:this.isConnected});return;}return click.call(this);};
const capture=HTMLCanvasElement.prototype.captureStream;
if(capture)HTMLCanvasElement.prototype.captureStream=function(...args){const stream=capture.apply(this,args);exportTracks.push(...stream.getTracks());return stream;};
const cancel=window.cancelAnimationFrame;
window.cancelAnimationFrame=id=>{exportCancelled.push(id);return cancel(id);};
// Activation can survive asynchronous work. Observe the actual handler
// stack so even a microtask cannot masquerade as synchronous construction.
window.copyInClickHandler=false;
const listen=EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener=function(type,listener,options){
if(this.id==='export-menu'&&type==='click'&&typeof listener==='function'){
const handler=listener;
listener=function(event){
copyInClickHandler=true;
try{return handler.call(this,event);}finally{copyInClickHandler=false;}
};
}
return listen.call(this,type,listener,options);
};
window.exportWait=predicate=>new Promise((resolve,reject)=>{const start=performance.now();function poll(){if(predicate())return resolve();if(performance.now()-start>12000)return reject(new Error('Export observation timed out'));setTimeout(poll,20);}poll();});
const fault=new URL(location.href).searchParams.get('fault');
if(fault==='unsupported'){window.MediaRecorder=undefined;window.ClipboardItem=undefined;HTMLCanvasElement.prototype.toDataURL=()=> 'data:image/png;base64,';}
` });
async function load({ width = 1440, theme = 'dark', extra = '' } = {}) {
await send('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: false });
await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
const loaded = browser.cdp.waitFor('Page.loadEventFired', session);
await send('Page.navigate', { url: pathToFileURL(file).href + '?theme=' + theme + extra });
await loaded;
await run('document.fonts.ready');
await run('Archify.readerLayout.whenStable()');
await run('Archify.viewerChromeLayout.whenStable()');
}
async function key(key, code, windowsVirtualKeyCode) {
await send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode });
await send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode });
}
async function click(selector) {
const point = await run(`(()=>{const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`);
await send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button: 'left', clickCount: 1 });
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', clickCount: 1 });
}
async function record(label) {
const value = await run(`({open:Archify.exportMenu.isOpen(),expanded:document.getElementById('btn-export').getAttribute('aria-expanded'),
active:document.activeElement.id||document.activeElement.dataset.action||document.activeElement.dataset.format,
downloads:exportDownloads.map(d=>({name:d.name,type:d.blob.type,attached:d.attached})),
urls:[...exportUrls.values()].map(u=>({type:u.blob.type,revoked:u.revoked})),tracks:exportTracks.map(t=>t.readyState),
receipt:Object.fromEntries([...document.documentElement.attributes].filter(a=>a.name.startsWith('data-last-export-')&&!a.name.endsWith('-bytes')).map(a=>[a.name,a.value])),
errors:exportErrors,alerts:exportAlerts,console:exportConsole,
external:performance.getEntriesByType('resource').map(r=>r.name).filter(n=>/^https?:/.test(n))})`);
assert.deepEqual(value.errors, [], label);
assert.deepEqual(value.external, [], label);
records.push({ label, ...value });
return value;
}
const route = `Archify.routeProbe.begin({source:'users',focusNode:false});if(!Archify.routeProbe.choose('db',{updateUrl:false}))throw new Error('route fixture failed');`;
const reach = `Archify.focus.set('api',{toggle:false,updateUrl:false});if(!Archify.focus.reach('downstream',{toggle:false,updateUrl:false,reveal:false}))throw new Error('reach fixture failed');`;
await t.test('native menu input skips unavailable entries and preserves focus and mutual exclusion', async () => {
for (const width of [390, 720, 1440]) {
await load({ width, extra: '&fault=unsupported' });
assert.deepEqual(await run('Object.keys(Archify.exportMenu).sort()'), ['close','copyShareCard','downloadReachShareCard','downloadRouteShareCard','isOpen','open','run','shareCard','syncReachShare','syncRouteShare'].sort());
assert.deepEqual(await run('Object.keys(Archify.motion).sort()'), ['canRecord','recordWebm']);
assert.deepEqual(await run(`[...document.querySelectorAll('#export-menu [data-format="jpeg"],#export-menu [data-format="webp"],#export-menu [data-format="webm"],#export-menu [data-action="copy"]')].map(e=>e.disabled)`), [true,true,true,true]);
await run(`document.getElementById('btn-export').focus()`);
await key('ArrowUp', 'ArrowUp', 38);
const last = await run(`document.activeElement.dataset.format||document.activeElement.dataset.action`);
await key('Home', 'Home', 36);
const first = await run(`document.activeElement.dataset.format||document.activeElement.dataset.action`);
assert.notEqual(first, last);
await key('ArrowUp', 'ArrowUp', 38);
assert.equal(await run(`document.activeElement.dataset.format||document.activeElement.dataset.action`), last);
await key('ArrowDown', 'ArrowDown', 40);
assert.equal(await run(`document.activeElement.dataset.format||document.activeElement.dataset.action`), first);
await key('End', 'End', 35);
assert.equal(await run(`document.activeElement.dataset.format||document.activeElement.dataset.action`), last);
await key('Escape', 'Escape', 27);
assert.equal((await record('escape-' + width)).active, 'btn-export');
assert.equal(await run('Archify.exportMenu.isOpen()'), false);
await click('#btn-export');
await key('Tab', 'Tab', 9);
assert.equal(await run('Archify.exportMenu.isOpen()'), false);
await click('#btn-export');
assert.equal(await run(`document.getElementById('export-menu').contains(document.elementFromPoint(5,5))`), false);
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: 5, y: 5, button: 'left', clickCount: 1 });
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 5, y: 5, button: 'left', clickCount: 1 });
assert.equal(await run('Archify.exportMenu.isOpen()'), false);
await run('Archify.preset.open();Archify.exportMenu.open()');
assert.equal(await run('Archify.preset.isOpen()'), false);
await run('Archify.semanticLens.open();Archify.exportMenu.open()');
assert.equal(await run('Archify.semanticLens.isOpen()'), false);
const state = await record('menu-' + width);
assert.equal(state.open, true);assert.equal(state.expanded, 'true');assert.deepEqual(state.console, []);
}
});
await t.test('auto-open and themed menu rendering keep stable layout', async () => {
for (const theme of ['dark', 'light']) {
await load({ theme, extra: '&openExport=1' });
await run('exportWait(()=>Archify.exportMenu.isOpen())');
const state = await record('auto-' + theme);
assert.equal(state.expanded, 'true');assert.deepEqual(state.console, []);
if (evidence) {
await run(`Promise.all(document.getAnimations().filter(a=>Number.isFinite(a.effect.getTiming().iterations)).map(a=>a.finished.catch(()=>{})))`);
const shot = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(evidence, theme + '-menu.png'), Buffer.from(shot.data, 'base64'));
}
}
});
await t.test('semantic card downloads retain snapshots, receipts and URL lifetime', async () => {
for (const variant of ['route', 'reach']) {
await load();await run(variant === 'route' ? route : reach);
await run(`Archify.exportMenu.open();document.querySelector('[data-action="${variant}-share-card"]').focus()`);
const result = await run(`(async()=>{let triggerFocus=0;const trigger=document.getElementById('btn-export');const onFocus=()=>triggerFocus++;trigger.addEventListener('focus',onFocus);const blob=await Archify.exportMenu.${variant === 'route' ? 'downloadRouteShareCard' : 'downloadReachShareCard'}();const image=await createImageBitmap(blob);const dimensions=[image.width,image.height];image.close();trigger.removeEventListener('focus',onFocus);return {dimensions,triggerFocus,type:blob.type};})()`);
assert.deepEqual(result, { dimensions: [1200,630], triggerFocus: 0, type: 'image/png' });
const live = await record(variant + '-download');
assert.equal(live.receipt['data-last-export-variant'], variant);
assert.equal(live.receipt['data-last-export-canonical'], 'false');
assert.equal(live.receipt['data-last-export-' + variant + '-state-clean'], 'true');
assert.ok(live.downloads[0].name.endsWith(variant === 'route' ? '-route-share-card.png' : '-downstream-reach-share-card.png'));
assert.deepEqual(live.urls.map(u=>u.revoked), [true,false]);
assert.equal(await run(`document.querySelectorAll('a[download]').length`), 0);
await run('exportWait(()=>[...exportUrls.values()].every(u=>u.revoked))');
assert.deepEqual((await record(variant + '-released')).urls.map(u=>u.revoked), [true,true]);
await run(`Archify.exportMenu.open();${variant === 'route' ? 'Archify.routeProbe.clear()' : 'Archify.focus.clearReach()'};`);
assert.equal(await run(`document.querySelector('[data-action="${variant}-share-card"]').hidden`), true);
await run(`Archify.exportMenu.${variant === 'route' ? 'downloadRouteShareCard' : 'downloadReachShareCard'}()`);
const missing = await record(variant + '-invalidated');
assert.equal(missing.receipt['data-last-export-error-format'], 'share-card');
assert.equal(missing.receipt['data-last-export-variant'], undefined);assert.equal(missing.alerts.length, 1);
// An invalid provider snapshot must not be sanitized into a different route.
await run(variant === 'route' ? route : reach);
await run(`(()=>{const provider=${variant === 'route' ? 'Archify.routeProbe' : 'Archify.focus'};const method=${JSON.stringify(variant === 'route' ? 'exportSnapshot' : 'reachabilitySnapshot')};const snapshot=provider[method]();snapshot.nodeIds=['missing'];provider[method]=()=>snapshot;})()`);
assert.equal(await run(`Archify.exportMenu.shareCard({variant:${JSON.stringify(variant)}}).then(()=>false,()=>true)`), true);
assert.equal((await record(variant + '-malformed')).downloads.length, 1);
}
});
await t.test('clipboard keeps promise construction in the click and distinct fallback/error receipts', async () => {
for (const action of ['copy','copy-share-card']) for (const mode of ['promise','fallback','reject']) {
await load();
await run(`window.copyCalls=[];window.copyDone=false;window.copyBlob=null;
window.ClipboardItem=class {constructor(data){const value=data['image/png'];copyCalls.push({promise:value instanceof Promise,gesture:navigator.userActivation.isActive,inClickHandler:copyInClickHandler});if(${JSON.stringify(mode)}==='fallback'&&value instanceof Promise)throw new Error('promise unsupported');this.value=value;}};
Object.defineProperty(navigator,'clipboard',{configurable:true,value:{write(items){copyCalls.push({write:true});if(${JSON.stringify(mode)}==='reject'){copyDone=true;return Promise.reject(new Error('clipboard denied'));}return Promise.resolve(items[0].value).then(blob=>{copyBlob=blob;copyDone=true;});}}});
document.querySelector('[data-action="${action}"]').disabled=false;Archify.exportMenu.open();`);
await click(`[data-action="${action}"]`);
await run(`exportWait(()=>copyDone&&(${JSON.stringify(mode)}==='reject'?exportAlerts.length>0:${JSON.stringify(action)}==='copy-share-card'?document.documentElement.hasAttribute('data-last-export-format'):document.querySelector('.archify-toast').textContent.length>0))`);
const calls = await run('copyCalls');
assert.deepEqual(calls[0], { promise: true, gesture: true, inClickHandler: true });
if (mode === 'fallback') assert.equal(calls[1].inClickHandler, false, 'Blob fallback remains asynchronous');
assert.deepEqual(calls.map(c=>c.write?'write':c.promise?'promise':'blob'), mode === 'fallback' ? ['promise','blob','write'] : ['promise','write']);
const state = await record(action + '-' + mode);
assert.equal(state.active, 'btn-export');
if (mode !== 'reject') {
assert.equal(await run('copyBlob.type'), 'image/png');
const dims = await run('(async()=>{const b=await createImageBitmap(copyBlob);const size=[b.width,b.height];b.close();return size;})()');
if (action === 'copy-share-card') assert.deepEqual(dims,[1200,630]);
else assert.ok(dims[0] > 1200);
assert.equal(state.receipt['data-last-export-format'], action === 'copy-share-card' ? 'share-card' : undefined);
} else {
assert.equal(state.alerts.length,1);
assert.equal(state.receipt['data-last-export-error-format'], action === 'copy-share-card' ? 'share-card' : undefined);
}
await run('exportWait(()=>[...exportUrls.values()].every(u=>u.revoked))');
}
await load({extra:'&fault=unsupported'});
assert.equal(await run('Archify.exportMenu.copyShareCard() === undefined'), true);
assert.equal((await record('clipboard-unavailable')).alerts.length, 1);
});
await t.test('raster failures release sources and retain retry and synchronous SVG behavior', async () => {
for (const fault of ['image','context','null-blob']) {
await load();
await run(`window.savedImage=Image;window.savedContext=HTMLCanvasElement.prototype.getContext;window.savedToBlob=HTMLCanvasElement.prototype.toBlob;`);
if (fault === 'image') await run(`window.Image=class {set src(value){queueMicrotask(()=>this.onerror(new Error('image failed')));}}`);
if (fault === 'context') await run('HTMLCanvasElement.prototype.getContext=()=>null');
if (fault === 'null-blob') await run('HTMLCanvasElement.prototype.toBlob=function(callback){callback(null)}');
await run(`Archify.exportMenu.run('png')`);
const state = await record('raster-' + fault);
assert.equal(state.receipt['data-last-export-error-format'],'png');assert.equal(state.alerts.length,1);
assert.equal(state.urls.length,1);assert.equal(state.urls[0].revoked,true);assert.deepEqual(state.downloads,[]);
await run(`window.Image=savedImage;HTMLCanvasElement.prototype.getContext=savedContext;HTMLCanvasElement.prototype.toBlob=savedToBlob;Archify.exportMenu.run('png')`);
await run('exportWait(()=>exportDownloads.length===1)');
assert.equal((await record('retry-' + fault)).receipt['data-last-export-error-format'],undefined);
}
await load();
const sync = await run(`(()=>{const svg=document.querySelector('.diagram-container > svg'),clone=svg.cloneNode;svg.cloneNode=()=>{throw new Error('sync serialization')};try{Archify.exportMenu.run('svg');return false;}catch(e){return e.message==='sync serialization';}finally{svg.cloneNode=clone;}})()`);
assert.equal(sync,true);assert.deepEqual((await record('svg-sync-throw')).receipt,{});
});
await t.test('recording succeeds with real encoding and releases tracks and the background URL', async () => {
await load();assert.equal(await run('Archify.motion.canRecord()'),true);
const result = await run(`(async()=>{const blob=await Archify.motion.recordWebm({duration:500,fps:10});window.recordedBlob=blob;const url=URL.createObjectURL(blob),video=document.createElement('video');video.muted=true;video.src=url;await new Promise((resolve,reject)=>{video.onloadeddata=resolve;video.onerror=()=>reject(new Error('WebM decode failed'));});const dimensions=[video.videoWidth,video.videoHeight];await video.play();await new Promise(resolve=>video.requestVideoFrameCallback(resolve));video.pause();video.removeAttribute('src');video.load();URL.revokeObjectURL(url);return {type:blob.type,nonempty:blob.size>0,dimensions,cancelled:exportCancelled.length>0};})()`);
assert.match(result.type,/^video\/webm/);assert.equal(result.nonempty,true);assert.equal(result.cancelled,true);assert.ok(result.dimensions.every(n=>n>0&&n%2===0));
const state = await record('webm-real');assert.ok(state.tracks.length>0);assert.ok(state.tracks.every(s=>s==='ended'));assert.ok(state.urls.every(u=>u.revoked));assert.deepEqual(state.console,[]);
if(evidence){const bytes=await run(`(async()=>Array.from(new Uint8Array(await recordedBlob.arrayBuffer())))()`);fs.writeFileSync(path.join(evidence,'recording.webm'),Buffer.from(bytes));}
});
await t.test('menu recording writes its receipt and downloads the real default-duration WebM', async () => {
await load();await run('Archify.exportMenu.open()');
await click('[data-format="webm"]');
await run('exportWait(()=>exportDownloads.length===1)');
const state=await record('webm-download');
assert.equal(state.active,'btn-export');assert.equal(state.receipt['data-last-export-format'],'webm');
assert.equal(state.receipt['data-last-export-canonical'],'true');assert.deepEqual(state.console,[]);
assert.ok(state.downloads[0].name.endsWith('.webm'));assert.match(state.downloads[0].type,/^video\/webm/);
assert.equal(await run('Number(document.documentElement.dataset.lastMotionBytes)===exportDownloads[0].blob.size&&exportDownloads[0].blob.size>0'),true);
assert.ok(state.tracks.length>0);assert.ok(state.tracks.every(s=>s==='ended'));
assert.deepEqual(state.urls.map(u=>u.revoked),[true,false]);
await run('exportWait(()=>[...exportUrls.values()].every(u=>u.revoked))');
});
await t.test('recording constructor/error/empty failures clean up and public run disables WebM', async () => {
for (const fault of ['constructor','error','empty']) {
await load();
await run(`window.MediaRecorder=class {
static isTypeSupported(){return true;}
constructor(){if(${JSON.stringify(fault)}==='constructor')throw new Error('recorder constructor');this.state='inactive';this.mimeType='video/webm';}
start(){this.state='recording';if(${JSON.stringify(fault)}==='error')setTimeout(()=>{this.state='inactive';this.onerror({error:new Error('recorder error')});},20);}
requestData(){} stop(){this.state='inactive';this.onstop();}
};`);
assert.equal(await run('Archify.motion.recordWebm({duration:250,fps:10}).then(()=>false,()=>true)'),true);
const state=await record('webm-'+fault);assert.ok(state.urls.every(u=>u.revoked));assert.ok(state.tracks.length>0);assert.ok(state.tracks.every(s=>s==='ended'));
}
await load({extra:'&fault=unsupported'});
await run(`Archify.exportMenu.run('webm')`);
const state=await record('webm-unavailable');assert.equal(state.receipt['data-last-export-error-format'],'webm');assert.deepEqual(state.alerts,[]);
assert.deepEqual(await run(`(()=>{const b=document.querySelector('[data-format="webm"]');return {disabled:b.disabled,opacity:b.style.opacity};})()`),{disabled:true,opacity:'0.5'});
});
});