Files
archify/test/intent-trace-browser.test.mjs

285 lines
21 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 { findChrome } from '../bin/visual-check.mjs';
import { desktopBrowser, desktopPointerCheck } from './helpers/desktop-browser.mjs';
const skillRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const chrome = process.env.ARCHIFY_CHROME ? findChrome() : null;
test('Intent Trace preserves input handoffs, transient geometry and cleanup', {
skip: chrome ? false : 'Set ARCHIFY_CHROME to run real-browser Intent Trace checks.',
}, async (t) => {
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-intent-'));
t.after(() => fs.rmSync(scratch, { recursive: true, force: true }));
const evidence = process.env.ARCHIFY_INTENT_EVIDENCE;
if (evidence) fs.mkdirSync(evidence, { recursive: true });
const records = [];
t.after(() => {
if (evidence) fs.writeFileSync(path.join(evidence, 'observations.json'), JSON.stringify(records, null, 2) + '\n');
});
const cases = {
architecture: 'web-app.architecture.json', workflow: 'agent-tool-call.workflow.json',
sequence: 'cache-miss-request.sequence.json', dataflow: 'product-analytics.dataflow.json',
lifecycle: 'agent-run.lifecycle.json',
};
const files = {};
for (const [mode, example] of Object.entries(cases)) {
files[mode] = path.join(scratch, mode + '.html');
execFileSync(process.execPath, [path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
path.join(skillRoot, 'examples', example), files[mode]]);
}
const trace = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', cases.architecture), 'utf8'));
trace.meta.animation = 'trace';
const traceInput = path.join(scratch, 'trace.json');
fs.writeFileSync(traceInput, JSON.stringify(trace)); files.trace = path.join(scratch, 'trace.html');
execFileSync(process.execPath, [path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'), traceInput, files.trace]);
const browser = desktopBrowser(chrome);
t.after(() => browser.close());
const session = await browser.sessionPromise;
const checkPointer = await desktopPointerCheck(browser, session);
await browser.cdp.send('Browser.setDownloadBehavior', { behavior: 'deny' });
const send = (method, params = {}) => browser.cdp.send(method, params, session);
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;
}
await send('Page.addScriptToEvaluateOnNewDocument', { source: `
window.intentErrors=[]; window.intentEnds=[];
addEventListener('error',e=>intentErrors.push(e.message));
addEventListener('unhandledrejection',e=>intentErrors.push(String(e.reason)));
addEventListener('animationend',e=>{if(e.target.matches('.intent-trace-flow'))intentEnds.push({name:e.animationName,trusted:e.isTrusted});},true);
try {localStorage.removeItem('archify-motion');} catch (_) {}
window.intentWait=predicate=>new Promise((resolve,reject)=>{
const start=performance.now();
function sample(){if(predicate())return resolve();if(performance.now()-start>12000)return reject(new Error('Intent observation timed out'));requestAnimationFrame(sample);}
requestAnimationFrame(sample);
});
// This synchronous fixture isolates timer ordering; ordinary pointer tests
// below still use the browser's real timers and CDP input.
window.intentTimerFixture=body=>{
const schedule=window.setTimeout,cancel=window.clearTimeout,queue=new Map(),delays=[];let serial=0;
window.setTimeout=(fn,delay)=>{delays.push(delay);queue.set(++serial,fn);return serial;};
window.clearTimeout=id=>queue.delete(id);
const node=id=>document.querySelector('.diagram-container svg [data-node-id="'+id+'"]');
const over=(id,related=null,pointerType='mouse')=>node(id).dispatchEvent(new PointerEvent('pointerover',{bubbles:true,pointerType,relatedTarget:related}));
const out=id=>node(id).dispatchEvent(new PointerEvent('pointerout',{bubbles:true,pointerType:'mouse'}));
try {return body({node,over,out,queue,delays,fire:()=>{const [id,fn]=queue.entries().next().value;queue.delete(id);fn();}});}
finally {window.setTimeout=schedule;window.clearTimeout=cancel;}
};
` });
async function media(reduced) {
await send('Emulation.setEmulatedMedia', { media: '', features: [
{ name: 'prefers-reduced-motion', value: reduced ? 'reduce' : 'no-preference' },
] });
}
async function load(mode = 'architecture', { theme = 'dark', reduced = false } = {}) {
await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 0, y: 0 });
await send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
await media(reduced);
const loaded = browser.cdp.waitFor('Page.loadEventFired', session);
await send('Page.navigate', { url: pathToFileURL(files[mode]).href + `?theme=${theme}` });
await loaded;
await checkPointer();
await run('document.fonts.ready'); await run('Archify.viewerChromeLayout.whenStable()');
}
async function point(selector) {
return run(`(()=>{const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};})()`);
}
async function move(id) {
const p = id ? await point(`.diagram-container svg [data-node-id="${id}"]`) : { x: 0, y: 0 };
await send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...p });
}
async function click(selector) {
const p = await point(selector);
await send('Input.dispatchMouseEvent', { type: 'mousePressed', ...p, button: 'left', clickCount: 1 });
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...p, button: 'left', clickCount: 1 });
}
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 snapshot(scenario) {
const state = await run(`(()=>{
const svg=document.querySelector('.diagram-container > svg');
return {active:Archify.intentTrace.active(),attribute:svg.getAttribute('data-intent-trace-active'),
selected:[...svg.querySelectorAll('[data-intent-trace-selected]')].map(n=>n.getAttribute('data-node-id')),
matched:[...svg.querySelectorAll('[data-node-id][data-intent-trace-match]')].map(n=>n.getAttribute('data-node-id')),
edges:svg.querySelectorAll('[data-edge-from][data-intent-trace-match]').length,
overlays:svg.querySelectorAll('[data-intent-trace-overlay]').length,
directions:[...svg.querySelectorAll('.intent-trace-flow')].map(n=>n.getAttribute('data-direction')),
status:document.getElementById('intent-trace-status').textContent,
focus:Archify.focus.active(),route:Archify.routeProbe.active(),owner:Archify.motionGovernor.owner(),
errors:intentErrors,external:performance.getEntriesByType('resource').map(e=>e.name).filter(n=>/^https?:/.test(n))};
})()`);
assert.deepEqual(state.errors, [], scenario); assert.deepEqual(state.external, [], scenario);
records.push({ scenario, ...state }); return state;
}
async function afterTimer() { await run('new Promise(resolve=>setTimeout(resolve,150))'); }
await t.test('five modes initialize and public return/broadcast contracts remain intact', async () => {
for (const mode of Object.keys(cases)) {
await load(mode); const initial = await snapshot(mode + '-initial');
assert.equal(initial.active, null); assert.equal(initial.overlays, 0); assert.equal(initial.status, '');
assert.deepEqual(await run('Object.keys(Archify.intentTrace).sort()'), ['active', 'clear', 'show']);
}
await load();
const api = await run(`(()=>{
const p=Archify.intentTrace;const shown=p.show('api',{announce:true}),text=document.getElementById('intent-trace-status').textContent;
const overlay=document.querySelector('[data-intent-trace-overlay]');
const repeated=p.show('api',{announce:true}),same=overlay===document.querySelector('[data-intent-trace-overlay]');
const missing=p.show('missing'),retained=document.getElementById('intent-trace-status').textContent===text;
const empty=p.show(''),cleared=p.clear(),twice=p.clear();
return {shown,repeated,same,missing,retained,empty,cleared:cleared===undefined,twice:twice===undefined,text};
})()`);
assert.deepEqual({ ...api, text: '' }, { shown: true, repeated: true, same: true, missing: false, retained: true, empty: false, cleared: true, twice: true, text: '' });
assert.match(api.text, /API Server/); assert.equal((await snapshot('public-cleared')).status, '');
});
await t.test('real pointer delay, fast exit, keyboard focus and Escape share cleanup', async () => {
await load(); await move('api');
await run(`intentWait(()=>Archify.intentTrace.active()==='api')`);
let state = await snapshot('hover-api'); assert.equal(state.status, ''); assert.equal(state.overlays, 1);
await move(); await afterTimer(); assert.equal((await snapshot('hover-left')).active, null);
await move('db'); await move(); await afterTimer(); assert.equal((await snapshot('fast-exit')).active, null);
await move('api'); await move('db');
await run(`intentWait(()=>Archify.intentTrace.active()==='db')`); await afterTimer();
assert.equal((await snapshot('hover-replaced')).active, 'db');
await run(`document.querySelector('.diagram-container svg [data-node-id="api"]').focus()`);
state = await snapshot('keyboard-api'); assert.equal(state.active, 'api'); assert.match(state.status, /API Server/);
const internalFocus = await run(`(()=>{
const overlay=document.querySelector('[data-intent-trace-overlay]'),child=document.querySelector('.diagram-container svg [data-node-id="api"] text');
child.setAttribute('tabindex','0');child.focus();
const same=overlay===document.querySelector('[data-intent-trace-overlay]')&&Archify.intentTrace.active()==='api';
child.blur();child.removeAttribute('tabindex');return same;
})()`);
assert.equal(internalFocus, true);
assert.equal((await snapshot('focusout-falls-back')).active, 'db');
await move();
await run(`document.querySelector('.diagram-container svg [data-node-id="api"]').focus()`);
await key('Escape', 'Escape', 27);
state = await snapshot('escape'); assert.equal(state.active, null); assert.equal(state.status, '');
await move('db'); await run(`intentWait(()=>Archify.intentTrace.active()==='db')`);
// Pointerout sync can reuse the retained keyboard reference after clear.
await move(); assert.equal((await snapshot('retained-focus')).active, 'api');
await click('.diagram-nav'); await afterTimer();
assert.equal((await snapshot('outside-pointerdown')).active, null);
});
await t.test('controlled timers preserve repeated-show, retained-input and input-filter semantics', async () => {
await load();
const result = await run(`intentTimerFixture(({node,over,out,queue,delays,fire})=>{
const p=Archify.intentTrace;
node('api').focus();const text=document.getElementById('intent-trace-status').textContent;
over('db');p.show('api',{announce:true});const pending=queue.size;fire();const afterHover=p.active();
out('db');const afterSync=p.active();
over('db');p.clear({announce:false});const cancelled=queue.size,retained=document.getElementById('intent-trace-status').textContent===text;
out('db');const reactivated=p.active();
over('api',node('api').querySelector('text'));const internal=queue.size;
node('api').blur();p.clear();over('db',null,'touch');const touch=queue.size;
const mm=window.matchMedia;window.matchMedia=()=>({matches:false});over('db');const coarse=queue.size;
window.matchMedia=undefined;over('db');const fallback=queue.size;fire();window.matchMedia=mm;
const fallbackActive=p.active();p.clear();
return {pending,afterHover,afterSync,cancelled,retained,reactivated,internal,touch,coarse,fallback,fallbackActive,delays};
})`);
assert.deepEqual(result, { pending: 1, afterHover: 'db', afterSync: 'api', cancelled: 0, retained: true, reactivated: 'api', internal: 0, touch: 0, coarse: 0, fallback: 1, fallbackActive: 'db', delays: [90, 90, 90] });
await snapshot('timer-fixture');
await load(); await move('api'); await run('Archify.intentTrace.clear()'); await afterTimer();
assert.equal((await snapshot('pending-clear')).active, null);
await run(`Archify.intentTrace.show('api',{announce:true}); window.dispatchEvent(new Event('blur'))`); await afterTimer();
const blurred = await snapshot('blur-fixture'); assert.equal(blurred.active, null); assert.match(blurred.status, /API Server/);
});
await t.test('a minimal SVG fixture preserves counting, cloned geometry and isolated nodes', async () => {
await load();
const result = await run(`(()=>{
const svg=document.querySelector('.diagram-container > svg');
svg.innerHTML='<g data-edge-from="a" data-edge-to="b" data-edge-key="ab" transform="translate(3 4)"><path id="original-path" d="M0 0 L10 10" class="original" style="opacity:.7" marker-end="url(#arrow)"/><line x1="1" y1="2" x2="3" y2="4"/></g><path data-edge-from="a" data-edge-to="b" data-edge-key="ab" d="M2 3 L4 5"/><polyline data-edge-from="c" data-edge-to="a" data-edge-label="incoming" points="1,2 3,4"/><path data-edge-from="a" data-edge-to="a" d="M0 0 C1 2 3 4 0 0"/><g data-node-id="a" data-node-label="Alpha"><rect width="10" height="10"/></g><g data-node-id="b"/><g data-node-id="c"/><g data-node-id="solo"/>';
const original=svg.querySelector('#original-path').outerHTML;Archify.intentTrace.show('a',{announce:true});
const overlay=svg.querySelector('[data-intent-trace-overlay]'),shapes=[...overlay.querySelectorAll('.intent-trace-flow')];
const data={directions:shapes.map(n=>n.getAttribute('data-direction')),status:document.getElementById('intent-trace-status').textContent,
transform:overlay.firstElementChild.getAttribute('transform'),d:shapes[0].getAttribute('d'),points:shapes[3].getAttribute('points'),
stripped:!overlay.querySelector('[id],[style],[marker-end],[data-edge-from]'),pathLength:shapes.every(n=>n.getAttribute('pathLength')==='1'),
original:svg.querySelector('#original-path').outerHTML===original,beforeEdges:overlay.nextElementSibling.hasAttribute('data-edge-from')};
Archify.intentTrace.show('solo',{announce:true});data.solo={active:Archify.intentTrace.active(),overlays:svg.querySelectorAll('[data-intent-trace-overlay]').length,status:document.getElementById('intent-trace-status').textContent};
return data;
})()`);
assert.deepEqual(result.directions, ['out', 'out', 'out', 'in', 'loop']);
assert.match(result.status, /Alpha/); assert.match(result.status, /1 outgoing/); assert.match(result.status, /1 incoming/);
assert.match(result.status, /1 self loop/); assert.match(result.status, /3 connections/);
assert.equal(result.transform, 'translate(3 4)'); assert.equal(result.d, 'M0 0 L10 10'); assert.equal(result.points, '1,2 3,4');
for (const flag of ['stripped', 'pathLength', 'original', 'beforeEdges']) assert.equal(result[flag], true, flag);
assert.equal(result.solo.active, 'solo'); assert.equal(result.solo.overlays, 0);
assert.match(result.solo.status, /0 connections/);
await snapshot('geometry-fixture');
});
await t.test('blockers gate requests while actual Focus, Route and Lens retain handoff behavior', async () => {
await load();
const blockers = await run(`(()=>{
const html=document.documentElement,svg=document.querySelector('.diagram-container > svg'),container=svg.parentElement,p=Archify.intentTrace;
const cases=[[html,'data-embed','true'],[html,'data-guide-open','true'],[svg,'data-lens-active',''],[svg,'data-story-active',''],[svg,'data-relationship-preview-active','']];
const results=cases.map(([el,name,value])=>{p.show('api');el.setAttribute(name,value);const before=p.active(),rejected=p.show('api'),after=p.active();el.removeAttribute(name);return {before,rejected,after};});
container.classList.add('is-panning');results.push({rejected:p.show('api'),after:p.active()});container.classList.remove('is-panning');return results;
})()`);
for (const row of blockers) { assert.equal(row.rejected, false); assert.equal(row.after, null); }
assert.ok(blockers.slice(0, 5).every(row => row.before === 'api'));
for (const [name, action, release] of [
['focus', `Archify.focus.set('api',{toggle:false})`, `Archify.focus.clear({updateUrl:false})`],
['route', `Archify.routeProbe.begin({source:'users'})`, `Archify.routeProbe.clear({updateUrl:false})`],
['lens', `Archify.semanticLens.select('backend')`, `Archify.semanticLens.clear({updateUrl:false})`],
]) {
await load(); await move('db'); await run(`intentWait(()=>Archify.intentTrace.active()==='db')`);
await run(action); await snapshot(name + '-handoff');
assert.equal(await run(`Archify.intentTrace.show('db')`), false);
assert.equal((await snapshot(name + '-blocked')).overlays, 0);
await run(release); await move(); await move('db');
await run(`intentWait(()=>Archify.intentTrace.active()==='db')`);
assert.equal((await snapshot(name + '-released')).active, 'db');
}
});
await t.test('real CSS completion, Motion ownership, reduced motion and themes preserve previews', async () => {
await load('trace'); await run(`intentWait(()=>document.documentElement.getAttribute('data-ambient-motion')==='settled')`);
await move('api'); await run(`intentWait(()=>Archify.motionGovernor.owner()==='intent')`);
await run(`intentWait(()=>intentEnds.some(e=>e.trusted&&e.name==='archify-intent-trace-flow'))`);
assert.equal((await snapshot('animation-complete')).active, 'api');
const direction = await run(`getComputedStyle(document.querySelector('.intent-trace-flow[data-direction="in"]')).animationDirection`);
assert.equal(direction, 'normal');
await move(); await run(`intentWait(()=>Archify.motionGovernor.owner()==='')`);
await snapshot('owner-cleared');
for (const theme of ['dark', 'light']) {
await load('trace', { theme, reduced: true });
const scheduled = await run(`intentTimerFixture(({over,queue,delays,fire})=>{over('api');const before=Archify.intentTrace.active();fire();return {before,after:Archify.intentTrace.active(),delays};})`);
assert.deepEqual(scheduled, { before: null, after: 'api', delays: [0] });
await run(`intentWait(()=>Archify.motionGovernor.owner()==='intent')`);
const style = await run(`(()=>{const c=getComputedStyle(document.querySelector('.intent-trace-flow'));return {animation:c.animationName,pointer:getComputedStyle(document.querySelector('.intent-trace-overlay')).pointerEvents,dash:c.strokeDasharray};})()`);
assert.equal(style.animation, 'none'); assert.equal(style.pointer, 'none'); assert.equal(style.dash, 'none');
await snapshot('reduced-' + theme);
if (evidence) {
await run(`Promise.all([...document.querySelectorAll('[data-intent-trace-match]')].flatMap(n=>n.getAnimations()).filter(a=>Number.isFinite(a.effect.getTiming().iterations)).map(a=>a.finished.catch(()=>{})))`);
await run('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
const shot = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(evidence, theme + '.png'), Buffer.from(shot.data, 'base64'));
}
const exported = await run(`(async()=>{
const original=URL.createObjectURL;let blob;
URL.createObjectURL=function(v){if(v.type.startsWith('image/svg+xml'))blob=v;return original.call(URL,v);};
try {await Archify.exportMenu.run('svg');}finally{URL.createObjectURL=original;}
const root=new DOMParser().parseFromString(await blob.text(),'image/svg+xml').documentElement;
return {clean:!root.hasAttribute('data-intent-trace-active')&&!root.querySelector('[data-intent-trace-overlay],[data-intent-trace-match],[data-intent-trace-selected]'),viewBox:root.getAttribute('viewBox')===document.querySelector('.diagram-container > svg').getAttribute('viewBox')};
})()`);
assert.deepEqual(exported, { clean: true, viewBox: true });
await click('.diagram-container svg [data-node-id="api"]');
assert.equal(await run('Archify.focus.active()'), 'api');
}
});
});