Files
archify/test/reader-layout-browser.test.mjs

321 lines
17 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 chromePath = process.env.ARCHIFY_CHROME ? findChrome() : null;
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',
};
test('Reader Layout preserves final-artifact behavior across its ownership boundaries', {
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
}, async (t) => {
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-reader-browser-'));
const evidence = process.env.ARCHIFY_READER_EVIDENCE;
if (evidence) fs.mkdirSync(evidence, { recursive: true });
const artifacts = {};
for (const [mode, example] of Object.entries(cases)) {
const output = path.join(scratch, `${mode}.html`);
// Optional captured base artifacts let the same behavioral cases establish
// a pre-extraction baseline without changing the implementation under test.
if (process.env.ARCHIFY_READER_BASELINE_DIR) {
fs.copyFileSync(path.join(process.env.ARCHIFY_READER_BASELINE_DIR, `${mode}.html`), output);
} else {
execFileSync(process.execPath, [path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
path.join(skillRoot, 'examples', example), output]);
}
artifacts[mode] = output;
}
const browser = new ChromeVisualBrowser(chromePath);
const records = [];
try {
const session = await browser.sessionPromise;
await browser.cdp.send('Browser.setDownloadBehavior', { behavior: 'deny' });
const send = (method, params = {}) => browser.cdp.send(method, params, session);
async function evaluate(expression, awaitPromise = false) {
const result = await send('Runtime.evaluate', { expression, awaitPromise, returnByValue: true });
assert.equal(result.exceptionDetails, undefined, result.exceptionDetails?.exception?.description);
return result.result?.value;
}
await send('Page.addScriptToEvaluateOnNewDocument', {
source: `window.readerTestErrors = [];
addEventListener('error', function (event) { readerTestErrors.push(event.message); });
addEventListener('unhandledrejection', function (event) { readerTestErrors.push(String(event.reason)); });`,
});
async function viewport(width, height) {
await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
}
async function media(theme = 'dark', reduced = false, print = false) {
await send('Emulation.setEmulatedMedia', { media: print ? 'print' : '', features: [
{ name: 'prefers-color-scheme', value: theme },
{ name: 'prefers-reduced-motion', value: reduced ? 'reduce' : 'no-preference' },
] });
}
async function stable() {
await evaluate(`(async function () {
for (var i = 0; i < 2; i += 1) {
await Archify.readerLayout.whenStable();
await Archify.viewerChromeLayout.whenStable();
}
})()`, true);
}
async function snapshot(label) {
const value = await evaluate(`(function () {
var html = document.documentElement;
var diagram = document.querySelector('.diagram-container');
var svg = diagram.querySelector(':scope > svg');
return {
active: Archify.readerLayout.active(), receipt: Archify.readerLayout.receipt(),
width: html.style.getPropertyValue('--archify-reader-width'),
layout: html.getAttribute('data-reader-layout'), overflow: html.getAttribute('data-reader-overflow'),
wide: diagram.getAttribute('data-wide-diagram'), shape: html.getAttribute('data-diagram-shape'),
geometry: ['viewBox', 'width', 'height'].map(function (name) { return svg.getAttribute(name); }),
shellWidth: document.querySelector('.container').getBoundingClientRect().width,
scrollHeight: Math.max(html.scrollHeight, document.body.scrollHeight),
innerWidth: innerWidth, innerHeight: innerHeight,
theme: html.getAttribute('data-theme'), reduced: matchMedia('(prefers-reduced-motion: reduce)').matches,
errors: window.readerTestErrors,
externalResources: performance.getEntriesByType('resource').map(function (entry) { return entry.name; }).filter(function (name) { return /^https?:/.test(name); })
};
})()`);
assert.deepEqual(value.errors, [], `${label}: uncaught Viewer errors`);
assert.deepEqual(value.externalResources, [], `${label}: external runtime assets`);
records.push({ label, ...value });
return value;
}
async function load(file, { width = 1440, height = 900, theme = 'dark', reduced = false, query = '', print = false, waitForLayout = true } = {}) {
await viewport(width, height);
await media(theme, reduced, print);
const loaded = browser.cdp.waitFor('Page.loadEventFired', session);
const result = await send('Page.navigate', { url: pathToFileURL(file).href + `?theme=${theme}${query}` });
assert.equal(result.errorText, undefined, result.errorText);
await loaded;
assert.deepEqual(await evaluate('window.readerTestErrors'), [], 'Viewer initialization');
if (waitForLayout) await stable();
}
function variant(name, { ratio, beforeViewer = '' } = {}) {
let html = fs.readFileSync(artifacts.architecture, 'utf8');
if (ratio !== undefined) assert.match(html, /<svg\b[^>]*\bviewBox="[^"]+"/, 'Reader viewBox fixture anchor');
if (beforeViewer) assert.ok(html.includes(' <script>\n var Archify = {};'), 'Reader setup fixture anchor');
if (ratio !== undefined) html = html.replace(/(<svg\b[^>]*\bviewBox=")[^"]+(")/, (_, start, end) => `${start}0 0 ${ratio * 1000} 1000${end}`);
if (beforeViewer) html = html.replace(' <script>\n var Archify = {};', () => ` <script>${beforeViewer}</script>\n <script>\n var Archify = {};`);
const file = path.join(scratch, `${name}.html`);
fs.writeFileSync(file, html);
return file;
}
function inactive(state, wide = true) {
assert.equal(state.active, false);
assert.equal(state.width, '');
assert.equal(state.layout, null);
assert.equal(state.overflow, null);
assert.equal(state.receipt.width, 0);
assert.equal(state.wide, wide ? 'true' : null);
assert.equal(state.shape, wide ? 'wide' : null);
}
await t.test('five modes initialize, export clean SVG, and honor themes and reduced motion', async () => {
for (const [mode, file] of Object.entries(artifacts)) {
for (const theme of ['dark', 'light']) {
await load(file, { theme, reduced: theme === 'light' });
const state = await snapshot(`${mode}-${theme}`);
assert.equal(state.theme, theme);
assert.equal(state.reduced, theme === 'light');
assert.equal(state.active, state.receipt.ratio >= 1.55);
await evaluate('Archify.view.zoomIn()');
await stable();
const exported = await evaluate(`(async function () {
var original = URL.createObjectURL;
var captured;
URL.createObjectURL = function (blob) {
if (blob.type.indexOf('image/svg+xml') === 0) captured = blob;
return original.call(URL, blob);
};
try {
await Archify.exportMenu.run('svg');
if (!captured) throw new Error('SVG export did not produce a blob');
var text = await captured.text();
var svg = new DOMParser().parseFromString(text, 'image/svg+xml').documentElement;
return { text: text, geometry: ['viewBox', 'width', 'height'].map(function (name) { return svg.getAttribute(name); }),
dirty: !!svg.querySelector('[data-focus-match], [data-story-step], [data-route-match], [data-reader-layout], [data-source-evidence-beacon]') ||
svg.hasAttribute('data-view-scale') || svg.hasAttribute('data-focus-active') || svg.hasAttribute('data-route-active') };
} finally { URL.createObjectURL = original; }
})()`, true);
assert.equal(exported.dirty, false);
assert.equal(exported.geometry[0], state.geometry[0]);
if (evidence) fs.writeFileSync(path.join(evidence, `${mode}-${theme}.svg`), exported.text);
await evaluate('Archify.view.reset()');
await stable();
const reset = await snapshot(`${mode}-${theme}-reset`);
assert.deepEqual(reset.geometry, state.geometry);
if (evidence && mode === 'architecture') {
const capture = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(evidence, `architecture-1440x900-${theme}.png`), Buffer.from(capture.data, 'base64'));
}
}
}
});
await t.test('ratio and desktop thresholds preserve shape while clearing temporary state', async () => {
for (const ratio of [1.549, 1.55, 1.551]) {
await load(variant(`ratio-${ratio}`, { ratio }));
const before = await snapshot(`ratio-${ratio}`);
assert.equal(before.active, ratio >= 1.55);
if (ratio < 1.55) inactive(before, false);
for (const width of [1023, 1024, 1025, 1023, 1440]) {
await viewport(width, 900);
await stable();
const state = await snapshot(`ratio-${ratio}-width-${width}`);
assert.deepEqual(state.geometry, before.geometry);
if (ratio >= 1.55 && width >= 1024) assert.equal(state.active, true);
else inactive(state, ratio >= 1.55);
}
}
});
const wide = variant('wide', { ratio: 3 });
await t.test('desktop budgets, extreme content and limited horizontal space preserve geometry', async () => {
for (const [width, height] of [[1440, 900], [1600, 1000], [1920, 1080], [2048, 1320]]) {
await load(wide, { width, height });
const state = await snapshot(`desktop-${width}x${height}`);
assert.equal(state.active, true);
assert.ok(state.receipt.width >= 960 && state.receipt.width <= Math.min(width, 1920));
}
await load(wide, { width: 2048, height: 3000 });
assert.equal((await snapshot('maximum-width')).receipt.width, 1920);
await evaluate(`document.body.style.paddingLeft = '100px'; document.body.style.paddingRight = '100px'`);
await viewport(1024, 900);
await stable();
assert.equal((await snapshot('available-width-below-floor')).receipt.width, 824);
await load(wide, { width: 1440, height: 300 });
const geometry = (await snapshot('short-window')).geometry;
await evaluate(`document.querySelector('.header').style.minHeight = '1000px';
document.querySelector('.cards').innerHTML = '<div style="height:1200px">Long content</div>'`);
await stable();
const overflow = await snapshot('long-content');
assert.equal(overflow.receipt.width, 960);
assert.equal(overflow.overflow, 'authored');
assert.ok(overflow.scrollHeight > overflow.innerHeight);
assert.deepEqual(overflow.geometry, geometry);
});
await t.test('embed, presentation and print return to ordinary layout without clearing shape', async () => {
for (const mode of ['embed', 'present', 'print']) {
await load(wide, { query: mode === 'print' ? '' : `&${mode}=1`, print: mode === 'print' });
inactive(await snapshot(`initial-${mode}`));
if (mode === 'print') await media();
else if (mode === 'present') await evaluate('Archify.presentation.exit()');
else await evaluate("document.documentElement.removeAttribute('data-embed')");
await stable();
assert.equal((await snapshot(`exit-${mode}`)).active, true);
for (let attempt = 0; attempt < 2; attempt += 1) {
if (mode === 'print') await media('dark', false, true);
else if (mode === 'present') await evaluate('Archify.presentation.enter()');
else await evaluate("document.documentElement.setAttribute('data-embed', 'true')");
await stable();
inactive(await snapshot(`enter-${mode}-${attempt}`));
if (mode === 'print') await media();
else if (mode === 'present') await evaluate('Archify.presentation.exit()');
else await evaluate("document.documentElement.removeAttribute('data-embed')");
await stable();
assert.equal((await snapshot(`return-${mode}-${attempt}`)).active, true);
}
}
});
await t.test('content observers and burst scheduling converge while camera state remains usable', async () => {
await load(wide, { width: 1920, height: 1080 });
const before = await snapshot('before-content');
await evaluate(`document.querySelector('.header h1').textContent = 'Long reader title '.repeat(30);
document.querySelector('.cards').innerHTML += '<div class="card" style="height:500px">Late card</div>';
for (var i = 0; i < 30; i += 1) { dispatchEvent(new Event('resize')); Archify.readerLayout.schedule(); }
Archify.view.zoomIn();`);
await stable();
const changed = await snapshot('after-content');
assert.ok(changed.receipt.width <= before.receipt.width);
assert.deepEqual(changed.geometry, before.geometry);
await evaluate('Archify.view.reset()');
await stable();
const first = await snapshot('settled-1');
await stable();
assert.deepEqual(await snapshot('settled-2'), first);
});
await t.test('optional content and browser interfaces retain their fallback behavior', async () => {
const file = variant('optional', { ratio: 3, beforeViewer: `
document.querySelector('.cards').remove();
// Other Viewer modules require the chapter control IDs. Only remove
// Reader's optional layout selector, keeping those controls available.
document.querySelector('.guided-views')?.classList.remove('guided-views');
window.ResizeObserver = undefined;
window.MutationObserver = undefined;
Object.defineProperty(document, 'fonts', { value: undefined });
` });
await load(file);
assert.equal((await snapshot('optional-interfaces-absent')).active, true);
await viewport(1023, 900);
await stable();
inactive(await snapshot('optional-resize-out'));
await viewport(1440, 900);
await stable();
assert.equal((await snapshot('optional-resize-back')).active, true);
});
await t.test('font readiness gates sampling and pending-frame timeout remains explicit', async () => {
const delayedFonts = variant('delayed-fonts', { ratio: 3, beforeViewer: `
window.readerTestOriginalFonts = document.fonts;
Object.defineProperty(document, 'fonts', { configurable: true, value: {
ready: new Promise(function (resolve) { window.readerTestReleaseFonts = resolve; })
} });
` });
await load(delayedFonts, { waitForLayout: false });
const fontGate = await evaluate(`(async function () {
var resolved = false;
var ready = Archify.readerLayout.whenStable().then(function () { resolved = true; });
document.querySelector('.cards').style.minHeight = '450px';
await new Promise(function (resolve) { requestAnimationFrame(function () { requestAnimationFrame(resolve); }); });
var beforeReady = resolved;
readerTestReleaseFonts();
await ready;
Object.defineProperty(document, 'fonts', { configurable: true, value: readerTestOriginalFonts });
return { beforeReady: beforeReady, afterReady: resolved };
})()`, true);
assert.deepEqual(fontGate, { beforeReady: false, afterReady: true });
await stable();
assert.equal((await snapshot('delayed-fonts-and-content')).active, true);
await load(wide);
const result = await evaluate(`(async function () {
var original = document.fonts;
var release;
var scheduled = 0;
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready: new Promise(function (resolve) { release = resolve; }) } });
try {
var waiting = Archify.waitForStableLayout({ maximumFrames: 1, schedule: function () { scheduled += 1; }, pending: function () { return true; } });
var observed = waiting.then(function () { return 'unexpected success'; }, function (error) { return error.message; });
await new Promise(function (resolve) { requestAnimationFrame(function () { requestAnimationFrame(resolve); }); });
var beforeReady = scheduled;
release();
return { beforeReady: beforeReady, outcome: await observed, afterReady: scheduled };
} finally { Object.defineProperty(document, 'fonts', { configurable: true, value: original }); }
})()`, true);
assert.equal(result.beforeReady, 0);
assert.equal(result.afterReady, 1);
assert.match(result.outcome, /did not reach stable dimensions/);
await stable();
});
} finally {
if (evidence) fs.writeFileSync(path.join(evidence, 'reader-observations.json'), `${JSON.stringify(records, null, 2)}\n`);
await browser.close();
fs.rmSync(scratch, { recursive: true, force: true });
}
});