const {chromium} = require('playwright'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const ids = ['assessment','readiness','pilot','expansion','sustain']; (async () => { const base = process.argv[2] || 'http://127.0.0.1:4173'; const output = `.cache/roadmap-timeline-${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 = []; const inspect = async (frame, vertical) => { await frame.locator('svg[role="img"]').waitFor(); const geometry = await frame.locator('svg[role="img"]').evaluate(svg => ({ width:innerWidth, height:innerHeight, cards:[...svg.querySelectorAll('[data-node-id]')].map(node => ({ id:node.dataset.nodeId, box:node.querySelector('rect').getBoundingClientRect().toJSON(), number:node.querySelector('[data-stage-number]').textContent, numberColor:getComputedStyle(node.querySelector('[data-stage-number]')).fill, titleColor:getComputedStyle(node.querySelector('[data-node-label]')).fill, text:[...node.querySelectorAll('text')].map(text => ({value:text.textContent, box:text.getBoundingClientRect().toJSON(), size:parseFloat(getComputedStyle(text).fontSize)*text.getScreenCTM().a})) })) })); geometry.cards.sort((a,b) => a.box.y-b.box.y || a.box.x-b.box.x); assert.deepEqual(geometry.cards.map(n => n.id), ids); assert.deepEqual(geometry.cards.map(n => n.number), ['1','2','3','4','5']); for (const [index, card] of geometry.cards.entries()) { assert.equal(card.text.length, 3, 'number, title and one outcome only'); assert.equal(card.numberColor,card.titleColor,'stage number must inherit readable theme color'); assert(card.box.left >= 0 && card.box.top >= 0 && card.box.right <= geometry.width + 1 && card.box.bottom <= geometry.height + 1, `outside iframe: ${card.id}`); for (const text of card.text) { assert(text.size >= 13, `small text: ${card.id} ${text.size}`); assert(!/OUTPUT|Exit:/.test(text.value)); assert(text.box.left >= card.box.left && text.box.right <= card.box.right + 1 && text.box.top >= card.box.top && text.box.bottom <= card.box.bottom + 1, `clipped: ${card.id}`); } for (let t=1; t errors.push(error.message)); assert.equal((await page.goto(`${base}/eldorado-v1/documentation/strategy/roadmap/`, {waitUntil:'networkidle'})).status(),200); const panel = page.locator('[data-diagram="strategy-roadmap"]'); const iframe = panel.locator(width < 1360 ? '.story-mobile' : '.story-desktop'); const frame = await (await iframe.elementHandle()).contentFrame(); await frame.locator('svg[role="img"]').waitFor(); const opener = panel.getByRole('button', {name:/^Expand /}); for (const theme of ['light','dark']) { if (await frame.locator('html').getAttribute('data-theme') !== theme) await frame.locator('#btn-theme').click(); await page.waitForFunction(t => document.documentElement.dataset.theme === t, theme); const inline = await inspect(frame,width < 1360); await panel.screenshot({path:`${output}/${width}-${theme}-inline.png`}); await opener.focus(); await page.keyboard.press('Enter'); const close = panel.getByRole('button', {name:'Close full-screen diagram', exact:true}); await close.waitFor({state:'visible'}); const fullscreen = await (await panel.locator('dialog iframe').elementHandle()).contentFrame(); await fullscreen.waitForFunction(t => document.documentElement.dataset.theme === t, theme); const expanded = await inspect(fullscreen,width < 1000); await page.screenshot({path:`${output}/${width}-${theme}-fullscreen.png`}); await page.keyboard.press('Escape'); await page.waitForFunction(() => !document.querySelector('[data-diagram="strategy-roadmap"] dialog').open); assert(await opener.evaluate(n => n === document.activeElement)); assert(!await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)); results.push({width,theme,inline,expanded}); fs.writeFileSync(`${output}/results.json`,JSON.stringify(results,null,2)); } assert.deepEqual(errors,[]); } finally {await context.close();} } assert.equal(results.length,8); for (const viewport of [{width:390,height:667},{width:320,height:568}]) { const context = await browser.newContext({viewport}); try { const page = await context.newPage(); await page.goto(`${base}/eldorado-v1/documentation/strategy/roadmap/`, {waitUntil:'networkidle'}); const panel = page.locator('[data-diagram="strategy-roadmap"]'); const opener = panel.getByRole('button', {name:/^Expand /}); await opener.focus(); await page.keyboard.press('Enter'); const modal = panel.locator('dialog'); const close = modal.getByRole('button', {name:'Close full-screen diagram', exact:true}); await close.waitFor({state:'visible'}); const iframe = modal.locator('iframe'); const frame = await (await iframe.elementHandle()).contentFrame(); const geometry = await inspect(frame,true); assert(await modal.evaluate(n => n.scrollHeight > n.clientHeight), 'short fullscreen must scroll'); for (const card of geometry.cards) { await modal.evaluate((n, y) => {n.scrollTop = y;}, card.box.top); const frameBox = await iframe.boundingBox(); const toolbarBox = await modal.locator('.archify-modal-toolbar').boundingBox(); const closeBox = await close.boundingBox(); assert(closeBox.y >= 0 && closeBox.y + closeBox.height <= viewport.height, 'close remains visible'); assert(frameBox.y + card.box.top >= toolbarBox.y + toolbarBox.height - 1 && frameBox.y + card.box.bottom <= viewport.height + 1, `stage unreachable: ${card.id}`); } // Controls stay reachable at the beginning of the scrollable viewer. await modal.evaluate(n => {n.scrollTop = 0;}); for (const selector of ['#btn-theme','#btn-preset','#btn-export']) { const control = frame.locator(selector); const box = await control.boundingBox(); assert(box.x >= 0 && box.x + box.width <= viewport.width && box.y >= 0 && box.y + box.height <= viewport.height, `control outside viewport: ${selector}`); await control.click(); if (selector !== '#btn-theme') await control.click(); } await close.focus(); await page.keyboard.press('Escape'); await page.waitForFunction(() => !document.querySelector('[data-diagram="strategy-roadmap"] dialog').open); await iframe.waitFor({state:'detached'}); assert(await opener.evaluate(n => n === document.activeElement), 'Escape restores opener focus'); await page.keyboard.press('Enter'); await close.waitFor({state:'visible'}); await close.focus(); await page.keyboard.press('Enter'); await page.waitForFunction(() => !document.querySelector('[data-diagram="strategy-roadmap"] dialog').open); assert(await opener.evaluate(n => n === document.activeElement), 'close button restores opener focus'); results.push({viewport,scrollable:geometry}); fs.writeFileSync(`${output}/results.json`,JSON.stringify(results,null,2)); } finally {await context.close();} } assert.equal(results.length,10); console.log('PASS 16 timeline contexts: 4 widths × 2 themes × inline/fullscreen; ordered readable text, no clipping, keyboard exit.'); console.log('PASS short fullscreen 390×667 and 320×568: readable stages reachable by scroll, sticky close, accessible controls, keyboard close and focus restoration.'); } finally {await browser.close();} })().catch(error => {console.error(error); process.exitCode=1;});