Build and publish Eldorado documentation / build (pull_request) Successful in 1m30s
194 lines
12 KiB
JavaScript
194 lines
12 KiB
JavaScript
const {chromium} = require('playwright');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const {pathToFileURL} = require('node:url');
|
||
const {resolve} = require('node:path');
|
||
|
||
// Inspect the rendered paths, not just the authored JSON. Also runs on downloaded SVGs.
|
||
function inspect({mobile}) {
|
||
const svg = document.querySelector('svg[role="img"]');
|
||
const point = (element, p) => new DOMPoint(p.x, p.y).matrixTransform(element.getCTM());
|
||
const boxes = [...svg.querySelectorAll('[data-node-id]')].map(node => {
|
||
const rect = node.querySelector('rect'), box = rect.getBBox();
|
||
const a = point(rect, box), b = point(rect, {x:box.x + box.width, y:box.y + box.height});
|
||
return {id:node.dataset.nodeId, x:a.x, y:a.y, right:b.x, bottom:b.y, cx:(a.x+b.x)/2, cy:(a.y+b.y)/2};
|
||
});
|
||
const agent = boxes.find(n => n.id === 'agent');
|
||
const errors = [], endpoints = {};
|
||
const phases = ['scope','architecture','ui-ux','planning','delivery','uat','security','releases'];
|
||
const edges = [...svg.querySelectorAll('path[data-edge-id]')];
|
||
if (edges.length !== 24) errors.push(`expected 24 graph edges, got ${edges.length}`);
|
||
phases.forEach((phase, index) => {
|
||
for (const [id, from, to] of [
|
||
[`govern-${phase}`,'agent',phase],
|
||
[`stage-${index+1}`,phase,phases[(index+1)%phases.length]],
|
||
[`contribute-${phase}`,`code-${phase}`,phase],
|
||
]) {
|
||
const matches = edges.filter(p => p.dataset.edgeId === id);
|
||
if (matches.length !== 1 || matches[0].dataset.edgeFrom !== from || matches[0].dataset.edgeTo !== to || !matches[0].getAttribute('marker-end') || matches[0].getAttribute('marker-start')) errors.push(`${id}: graph or direction changed`);
|
||
}
|
||
});
|
||
// CTMs put boxes and paths in the same viewport coordinate system.
|
||
// 0.01 covers floating-point error against bounds, NOT source spreading:
|
||
// shared endpoints below must be exactly identical.
|
||
const paths = [...svg.querySelectorAll('path[data-edge-id^="govern-"]')];
|
||
if (paths.length !== 8) errors.push(`expected eight governance paths, got ${paths.length}`);
|
||
for (const path of paths) {
|
||
const id = path.dataset.edgeTo;
|
||
const side = mobile ? 'left' : ['scope','architecture','releases'].includes(id) ? 'top'
|
||
: ['planning','delivery','uat'].includes(id) ? 'bottom' : id === 'security' ? 'left' : 'right';
|
||
const expected = {top:[agent.cx,agent.y],bottom:[agent.cx,agent.bottom],left:[agent.x,agent.cy],right:[agent.right,agent.cy]}[side];
|
||
const start = point(path,path.getPointAtLength(0));
|
||
endpoints[id] = [start.x,start.y];
|
||
if (Math.hypot(start.x-expected[0],start.y-expected[1]) > 0.01) errors.push(`${id}: wrong ${side} midpoint`);
|
||
const length = path.getTotalLength();
|
||
const target = boxes.find(n => n.id === id);
|
||
const destination = mobile ? [target.x,target.cy]
|
||
: side === 'top' ? [target.cx,target.bottom] : side === 'bottom' ? [target.cx,target.y]
|
||
: side === 'left' ? [target.right,target.cy] : [target.x,target.cy];
|
||
const end = point(path,path.getPointAtLength(length));
|
||
if (Math.hypot(end.x-destination[0],end.y-destination[1]) > 0.01) errors.push(`${id}: destination midpoint changed`);
|
||
for (let distance = 1; distance < length; distance += 2) {
|
||
const p = point(path,path.getPointAtLength(distance));
|
||
for (const box of boxes) {
|
||
if (box.id === id) continue;
|
||
if (p.x > box.x+0.1 && p.x < box.right-0.1 && p.y > box.y+0.1 && p.y < box.bottom-0.1) {
|
||
errors.push(`${id}: crosses ${box.id}`); break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (const group of mobile ? [phases] : [['scope','architecture','releases'],['planning','delivery','uat']]) {
|
||
const first = endpoints[group[0]];
|
||
if (group.some(id => !endpoints[id] || endpoints[id][0] !== first?.[0] || endpoints[id][1] !== first?.[1])) errors.push(`source-port spreading: ${group.join(',')}`);
|
||
}
|
||
const lifecycle = [];
|
||
if (!mobile) {
|
||
for (const [id, x, y1, y2, peer] of [
|
||
['stage-2',850,262.5,457.5,'architecture'], ['stage-3',850,532.5,727.5,'planning'],
|
||
['stage-6',350,727.5,532.5,'uat'], ['stage-7',350,457.5,262.5,'releases'],
|
||
]) {
|
||
const path = svg.querySelector(`path[data-edge-id="${id}"]`);
|
||
if (!path) {errors.push(`${id}: missing lifecycle path`); continue;}
|
||
const length = path.getTotalLength();
|
||
const a = path.getPointAtLength(0), b = path.getPointAtLength(length);
|
||
// Local SVG coordinates test exact offsets; transformed coordinates test
|
||
// node clearance at any inline/fullscreen/export scale.
|
||
if (Math.hypot(a.x-x,a.y-y1) > 0.01 || Math.hypot(b.x-x,b.y-y2) > 0.01) errors.push(`${id}: wrong shifted endpoints`);
|
||
if (!path.getAttribute('marker-end')) errors.push(`${id}: missing direction`);
|
||
const governance = paths.find(p => p.dataset.edgeTo === peer);
|
||
if (!governance) {errors.push(`${id}: missing governance peer ${peer}`); continue;}
|
||
const end = governance.getPointAtLength(governance.getTotalLength());
|
||
if (Math.abs(Math.abs(a.x-end.x)-20) > 0.01) errors.push(`${id}: wrong attachment clearance`);
|
||
for (let distance = 1; distance < length; distance += 2) {
|
||
const local = path.getPointAtLength(distance), p = point(path,local);
|
||
if (Math.abs(local.x-x) > 0.01) errors.push(`${id}: channel not straight`);
|
||
for (const box of boxes) {
|
||
if (p.x > box.x+0.1 && p.x < box.right-0.1 && p.y > box.y+0.1 && p.y < box.bottom-0.1) errors.push(`${id}: crosses ${box.id}`);
|
||
}
|
||
}
|
||
// No positive-length collinear overlap with ANY governance segment.
|
||
// Perpendicular crossings remain legitimate in this fixed radial layout.
|
||
for (const govern of paths) {
|
||
const total = govern.getTotalLength();
|
||
for (let d = 0; d < total - 1; d += 1) {
|
||
const p = govern.getPointAtLength(d), q = govern.getPointAtLength(d+1);
|
||
if (Math.abs(p.x-x) < 0.01 && Math.abs(q.x-x) < 0.01 && Math.min(Math.max(p.y,q.y),Math.max(y1,y2)) - Math.max(Math.min(p.y,q.y),Math.min(y1,y2)) > 0.01) errors.push(`${id}: overlaps ${govern.dataset.edgeId}`);
|
||
}
|
||
}
|
||
lifecycle.push({id,start:[a.x,a.y],end:[b.x,b.y],attachmentClearance:Math.abs(a.x-end.x)});
|
||
}
|
||
}
|
||
return {errors:[...new Set(errors)],endpoints,lifecycle};
|
||
}
|
||
|
||
function inspectTypography({mobile}) {
|
||
const errors = [];
|
||
const nodes = [...document.querySelectorAll('[data-node-id]')].map(node => {
|
||
const box = node.querySelector('rect').getBoundingClientRect();
|
||
const texts = [...node.querySelectorAll(':scope > text')];
|
||
const bounds = texts.map(text => text.getBoundingClientRect());
|
||
texts.forEach((text, index) => {
|
||
const expected = text.hasAttribute('data-node-label') ? (mobile ? 38 : 27) : (mobile ? 28 : 21);
|
||
if (parseFloat(getComputedStyle(text).fontSize) !== expected) errors.push(`${node.id}: wrong text size`);
|
||
const b = bounds[index];
|
||
if (b.width <= 0 || b.height <= 0 || b.x < box.x - 1 || b.right > box.right + 1 || b.y < box.y - 1 || b.bottom > box.bottom + 1) errors.push(`${node.id}: text outside node`);
|
||
});
|
||
if (bounds.length === 2 && bounds[0].bottom > bounds[1].top) errors.push(`${node.id}: title/subtitle overlap`);
|
||
return {id:node.id, text:texts.map(t => t.textContent), sizes:texts.map(t => getComputedStyle(t).fontSize)};
|
||
});
|
||
if (nodes.length !== 17) errors.push(`expected 17 nodes, got ${nodes.length}`);
|
||
return {errors, nodes};
|
||
}
|
||
|
||
(async () => {
|
||
const base = process.argv[2] || 'http://127.0.0.1:4173';
|
||
const output = `.cache/midpoints-${process.argv[3] || 'local'}`;
|
||
fs.mkdirSync(output,{recursive:true});
|
||
const browser = await chromium.launch({headless:true, executablePath:process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,args:['--no-sandbox']});
|
||
const results = [];
|
||
try {
|
||
for (const width of [1440,390,320]) {
|
||
const page = await browser.newPage({viewport:{width,height:1000}});
|
||
await page.goto(`${base}/eldorado-v1/documentation/strategy/target-operating-model/`,{waitUntil:'networkidle'});
|
||
await page.addStyleTag({content:'html {scroll-behavior:auto !important;}'});
|
||
await page.getByText('Conditional long-term Target operating model — not the six-month promise',{exact:true}).focus();
|
||
await page.keyboard.press('Enter');
|
||
const panel = page.locator('[data-diagram="strategy-target"]');
|
||
const iframe = panel.locator(`:scope > .story-${width > 700 ? 'desktop' : 'mobile'}`);
|
||
await iframe.scrollIntoViewIfNeeded();
|
||
const frame = await (await iframe.elementHandle()).contentFrame();
|
||
await frame.locator('svg[role="img"]').waitFor();
|
||
const inline = await frame.evaluate(inspect,{mobile:width < 700});
|
||
assert.deepEqual(inline.errors,[]);
|
||
await page.screenshot({path:`${output}/${width}-inline.png`,fullPage:true});
|
||
const exported = [];
|
||
for (const theme of ['light','dark']) {
|
||
if (await frame.locator('html').getAttribute('data-theme') !== theme) {await frame.locator('#btn-theme').focus(); await page.keyboard.press('Enter');}
|
||
await frame.waitForFunction(t => document.documentElement.dataset.theme === t, theme);
|
||
await frame.locator('#btn-export').focus(); await page.keyboard.press('Enter');
|
||
await frame.locator('[data-format="svg"]').focus();
|
||
const [download] = await Promise.all([page.waitForEvent('download'),page.keyboard.press('Enter')]);
|
||
assert.equal(await download.failure(),null);
|
||
const path = `${output}/${width}-${theme}.svg`;
|
||
await download.saveAs(path);
|
||
// A separate SVG document has no viewer CSS to hide export regressions.
|
||
const standalone = await browser.newPage({viewport:{width:1440,height:1100},colorScheme:theme});
|
||
await standalone.goto(pathToFileURL(resolve(path)).href);
|
||
assert.equal(await standalone.evaluate(() => document.contentType),'image/svg+xml');
|
||
await standalone.evaluate(() => document.fonts.ready);
|
||
const geometry = await standalone.evaluate(inspect,{mobile:width < 700});
|
||
const typography = await standalone.evaluate(inspectTypography,{mobile:width < 700});
|
||
assert.deepEqual(geometry.errors,[]);
|
||
assert.deepEqual(typography.errors,[]);
|
||
assert(await standalone.locator('#eldorado-target-caption').evaluate(g=>{
|
||
const v=g.ownerSVGElement.viewBox.baseVal;
|
||
return [...g.querySelectorAll('text')].every(t=>{const b=t.getBBox();return b.x>=v.x&&b.y>=v.y&&b.x+b.width<=v.x+v.width&&b.y+b.height<=v.y+v.height;});
|
||
}), 'Standalone proposed caption must fit the complete canvas');
|
||
const background = await standalone.locator('rect.c-bg-rect').evaluate(n => getComputedStyle(n).fill);
|
||
if (exported.length) assert.notEqual(background,exported[0].background,'standalone theme must change');
|
||
// CDP captures SVG documents without Playwright's HTML-body screenshot wait.
|
||
const client = await standalone.context().newCDPSession(standalone);
|
||
const size = await standalone.evaluate(() => ({width:document.documentElement.width.baseVal.value,height:document.documentElement.height.baseVal.value}));
|
||
const image = await client.send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,...size,scale:1}});
|
||
fs.writeFileSync(`${output}/${width}-${theme}-standalone.png`,Buffer.from(image.data,'base64'));
|
||
exported.push({theme,background,geometry,typography});
|
||
await standalone.close();
|
||
}
|
||
await panel.getByRole('button',{name:/^Expand /}).focus(); await page.keyboard.press('Enter');
|
||
const full = await (await panel.locator('dialog iframe').elementHandle()).contentFrame();
|
||
await full.locator('svg[role="img"]').waitFor();
|
||
// The existing fullscreen viewer always opens the radial desktop source.
|
||
const fullscreen = await full.evaluate(inspect,{mobile:false});
|
||
assert.deepEqual(fullscreen.errors,[]);
|
||
await page.screenshot({path:`${output}/${width}-fullscreen.png`});
|
||
await panel.getByRole('button',{name:'Close full-screen diagram'}).click();
|
||
results.push({width,inline,exported,fullscreen});
|
||
fs.writeFileSync(`${output}/results.json`,JSON.stringify(results,null,2));
|
||
await page.close();
|
||
}
|
||
assert.equal(results.length,3);
|
||
console.log('PASS midpoint geometry: 3 viewports × inline/fullscreen; standalone SVG geometry and typography in light/dark');
|
||
} finally {await browser.close();}
|
||
})().catch(error => {console.error(error);process.exitCode=1;});
|