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'}); 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').click(); await frame.waitForFunction(t => document.documentElement.dataset.theme === t, theme); await frame.locator('#btn-export').click(); const [download] = await Promise.all([page.waitForEvent('download'),frame.locator('[data-format="svg"]').click()]); 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,[]); 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 /}).click(); 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;});