// P9.2 only: measure the actual visible Content Wrapper and the entire scaled Stage. // --baseline captures P9.1 without asserting its centering; the final run compares invariants. import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-p9-2'); const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6062').replace(/\/$/, ''); const baselineMode = process.argv.includes('--baseline'); const prefix = baselineMode ? 'before' : 'after'; const sizes = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 390, height: 844 }]; const startedAt = new Date().toISOString(), results = [], snapshots = {}, errors = []; await fs.mkdir(output, { recursive: true }); const browser = await launchChromium({ headless: true }); const page = await browser.newPage({ viewport: sizes[0] }); page.on('pageerror', error => errors.push({ type: 'pageerror', message: error.message })); page.on('console', message => { if (message.type() === 'error') errors.push({ type: 'console', message: message.text() }); }); const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; const contains = (a, b, tolerance = 1) => b.left >= a.left - tolerance && b.right <= a.right + tolerance && b.top >= a.top - tolerance && b.bottom <= a.bottom + tolerance; const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); const near = (a, b) => Object.keys(a).every(key => Math.abs(a[key] - b[key]) <= .15); const preserved = (a, b) => a && a.htmlHash === b.htmlHash && same(a.assets, b.assets) && a.elements.length === b.elements.length && b.elements.every((item, i) => { const old = a.elements[i]; return old.key === item.key && old.hash === item.hash && same(old.style, item.style) && near(old.box, item.box); }); async function settle() { await page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 12; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); } async function open(size) { await page.setViewportSize(size); await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); await page.locator('.sb-page-title').waitFor(); await page.waitForFunction(() => { const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; return shadow?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]') && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet); }); await page.evaluate(async () => { await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); }); await settle(); } async function snapshot() { return page.evaluate(async () => { const wrapper = document.querySelector('.hm-orgchart-story-full'); const shadowHost = wrapper.querySelector('.hm-orgchart-host'); const shadow = shadowHost.shadowRoot; const scaleWrapper = shadow.querySelector('.hm-orgchart-composition'); const stage = shadow.querySelector('.hm-orgchart-stage[data-workdesk-artwork]'); const rect = element => { const r = element.getBoundingClientRect(); return Object.fromEntries(['left', 'right', 'top', 'bottom', 'width', 'height'].map(key => [key, r[key]])); }; const css = getComputedStyle(wrapper), wb = rect(wrapper), sb = rect(stage), sc = getComputedStyle(stage); const inset = Object.fromEntries(['paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom', 'borderLeftWidth', 'borderRightWidth', 'borderTopWidth', 'borderBottomWidth'].map(key => [key, parseFloat(css[key]) || 0])); const content = { left: wb.left + inset.borderLeftWidth + inset.paddingLeft, right: wb.right - inset.borderRightWidth - inset.paddingRight, top: wb.top + inset.borderTopWidth + inset.paddingTop, bottom: wb.bottom - inset.borderBottomWidth - inset.paddingBottom, }; content.width = content.right - content.left; content.height = content.bottom - content.top; const scale = sb.width / stage.offsetWidth; const normalized = box => ({ x: (box.left - sb.left) / scale, y: (box.top - sb.top) / scale, width: box.width / scale, height: box.height / scale }); const hash = async value => [...new Uint8Array(await crypto.subtle.digest('SHA-256', typeof value === 'string' ? new TextEncoder().encode(value) : value))].map(byte => byte.toString(16).padStart(2, '0')).join(''); const selectors = '.hm-orgchart-employee,.hm-orgchart-organization-name,.hm-orgchart-organization-totals,.hm-orgchart-organization-totals-title,.hm-orgchart-workdesk-summary-frame,.hm-orgchart-workdesk-position-table,.hm-orgchart-workdesk-position-table th,.hm-orgchart-workdesk-position-table td,.hm-orgchart-slot-side,.hm-orgchart-slot-belowTree,.hm-orgchart-status-group,.hm-orgchart-summary-title,.hm-orgchart-slot-footer,.hm-orgchart-affiliation-bar,.hm-orgchart-affiliation-bar-item,.hm-orgchart-axis,.hm-orgchart-top-leaders,.hm-orgchart-hub,.hm-orgchart-workdesk-overlay,.hm-orgchart-workdesk-decoration'; const styleKeys = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'color', 'backgroundColor', 'backgroundImage', 'border', 'borderRadius', 'boxShadow', 'padding', 'margin', 'width', 'height', 'gap', 'objectFit']; const elements = await Promise.all([...stage.querySelectorAll(selectors)].map(async (element, index) => ({ key: `${element.tagName}:${typeof element.className === 'string' ? element.className : element.className.baseVal}:${element.dataset.employeeId ?? element.dataset.organizationId ?? element.dataset.contentSlot ?? element.dataset.workdeskDecoration ?? ''}:${index}`, box: normalized(rect(element)), hash: await hash(element.outerHTML), style: Object.fromEntries(styleKeys.map(key => [key, getComputedStyle(element)[key]])), }))); const assetHashes = new Map(); const assets = await Promise.all([...stage.querySelectorAll('img')].map(async image => { const url = image.currentSrc || image.src; if (!assetHashes.has(url)) assetHashes.set(url, fetch(url).then(r => r.arrayBuffer()).then(hash)); return { hash: await assetHashes.get(url), width: image.naturalWidth, height: image.naturalHeight, loaded: image.complete && image.naturalWidth > 0 }; })); const visible = element => { const r = rect(element); return r.width > 0 && r.height > 0 && getComputedStyle(element).display !== 'none'; }; const painted = [...stage.querySelectorAll(selectors)].filter(visible).map(element => ({ key: typeof element.className === 'string' ? element.className : element.className.baseVal, bounds: rect(element) })); const collisionElements = [...stage.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-organization-name,.hm-orgchart-organization-totals,.hm-orgchart-affiliation-bar,.hm-orgchart-status-group > .hm-orgchart-summary-title')].filter(visible); const collisions = []; for (let i = 0; i < collisionElements.length; i++) for (let j = i + 1; j < collisionElements.length; j++) { const a = collisionElements[i], b = collisionElements[j]; if (a.contains(b) || b.contains(a)) continue; const ra = normalized(rect(a)), rb = normalized(rect(b)); const width = Math.min(ra.x + ra.width, rb.x + rb.width) - Math.max(ra.x, rb.x); const height = Math.min(ra.y + ra.height, rb.y + rb.height) - Math.max(ra.y, rb.y); if (width > .5 && height > .5) collisions.push({ pair: `${i}:${j}`, a: a.className, b: b.className, width, height }); } const scroll = element => { const cs = getComputedStyle(element); return { scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, overflowX: cs.overflowX, overflowY: cs.overflowY }; }; return { wrapper: { selector: '.hm-orgchart-story-full', tag: wrapper.tagName, ariaLabel: wrapper.getAttribute('aria-label'), background: css.backgroundColor, bounds: wb, inset, content }, sectionBody: rect(wrapper.closest('.sb-page-section-body')), shadowHost: rect(shadowHost), scaleWrapper: rect(scaleWrapper), stage: sb, contentCenter: (content.left + content.right) / 2, stageCenter: (sb.left + sb.right) / 2, centerDifference: (sb.left + sb.right - content.left - content.right) / 2, viewportCenter: innerWidth / 2, baseWidth: parseFloat(sc.width), baseHeight: parseFloat(sc.height), scale, verticalScale: sb.height / parseFloat(sc.height), coreTransform: sc.transform, additionalScale: sc.scale, translate: sc.translate, htmlHash: await hash(stage.innerHTML), elements, assets, painted, collisions, counts: { trees: stage.querySelectorAll('.hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree').length, cards: stage.querySelectorAll('.hm-orgchart-employee').length, overlays: stage.querySelectorAll('.hm-orgchart-workdesk-overlay').length, paths: stage.querySelectorAll('.hm-orgchart-workdesk-overlay path').length, side: stage.querySelectorAll('.hm-orgchart-slot-side').length, status: stage.querySelectorAll('.hm-orgchart-slot-belowTree').length, footer: stage.querySelectorAll('.hm-orgchart-slot-footer').length }, document: { width: innerWidth, height: innerHeight, root: scroll(document.documentElement), body: scroll(document.body), scrolling: scroll(document.scrollingElement) }, scroll: [wrapper, shadowHost, ...['.hm-orgchart-shell', '.hm-orgchart-content', '.hm-orgchart-composition', '.hm-orgchart-viewport'].map(selector => shadow.querySelector(selector)).filter(Boolean)].map(element => ({ selector: element.className, ...scroll(element) })), }; }); } async function stability() { return page.evaluate(async () => { const stage = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-stage'); let changes = 0; const observer = new MutationObserver(records => { changes += records.length; }); observer.observe(stage, { attributes: true, attributeFilter: ['style'] }); for (let i = 0; i < 12; i++) await new Promise(resolve => requestAnimationFrame(resolve)); observer.disconnect(); return { styleMutations: changes }; }); } const baseline = baselineMode ? null : JSON.parse(await fs.readFile(path.join(output, 'before-composition.json'), 'utf8')); async function record(key) { const state = await snapshot(); snapshots[key] = state; await page.screenshot({ path: path.join(output, `${prefix}-full-composition-${key}.png`), fullPage: true }); if (baselineMode) return; check(Math.abs(state.centerDifference) <= 1, `${key}: whole Stage centered in visible wrapper content box`, { wrapper: state.wrapper, stage: state.stage, contentCenter: state.contentCenter, stageCenter: state.stageCenter, centerDifference: state.centerDifference, viewportCenter: state.viewportCenter }); check(state.baseWidth === 1920 && state.baseHeight === 914.5 && state.scale > 0 && state.scale <= 1 && Math.abs(state.scale - state.verticalScale) <= .0001 && contains(state.wrapper.content, state.stage), `${key}: original canvas fully contained with uniform proportional fit`, { baseWidth: state.baseWidth, baseHeight: state.baseHeight, scale: state.scale, verticalScale: state.verticalScale, stage: state.stage, content: state.wrapper.content }); const outside = state.painted.filter(item => !contains(state.stage, item.bounds) || !contains(state.wrapper.content, item.bounds)); check(outside.length === 0 && state.collisions.length === 0, `${key}: painted cards, lines and statistics remain visible without collisions`, { outside, collisions: state.collisions, counts: state.counts }); check(preserved(baseline.snapshots[key], state), `${key}: P9.1 inner DOM, assets, styles and normalized geometry preserved`, { beforeHash: baseline.snapshots[key]?.htmlHash, afterHash: state.htmlHash, elements: state.elements.length, assets: state.assets.length }); const d = state.document; const documentFits = [d.root, d.body, d.scrolling].every(item => item.scrollWidth <= d.width + 1 && item.scrollHeight <= d.height + 1); const scrollable = state.scroll.filter(item => ['auto', 'scroll'].includes(item.overflowX) && item.scrollWidth > item.clientWidth + 1 || ['auto', 'scroll'].includes(item.overflowY) && item.scrollHeight > item.clientHeight + 1); check(documentFits && scrollable.length === 0, `${key}: no document or interactive chart scrollbars`, { document: d, scrollable, scroll: state.scroll }); check((await stability()).styleMutations === 0, `${key}: alignment settles without observer feedback loop`); } let fatal; try { for (const size of sizes) { await open(size); await record(String(size.width)); } await open(sizes[1]); await page.evaluate(() => { const wrapper = document.querySelector('.hm-orgchart-story-full'); // Perturb the actual visible wrapper, not a proxy box; keep standard document flow. Object.assign(wrapper.style, { boxSizing: 'border-box', width: 'calc(100% - 80px)', marginLeft: '23px', padding: '17px 53px 19px 11px', borderStyle: 'solid', borderColor: '#789', borderWidth: '2px 7px 5px 3px' }); }); await settle(); await record('asymmetric-1440'); await page.evaluate(() => { document.querySelector('.hm-orgchart-story-full').style.padding = '17px 19px 19px 45px'; }); await settle(); await record('padding-shift-1440'); await page.setViewportSize({ width: 1010, height: 840 }); await settle(); await record('asymmetric-resize-1010'); await page.setViewportSize(sizes[2]); await settle(); await record('asymmetric-resize-390'); if (!baselineMode) check(errors.length === 0, 'No new Full Composition console or page errors', errors); } catch (error) { fatal = error.stack; check(false, 'Runner completed', fatal); } finally { const result = { startedAt, completedAt: new Date().toISOString(), origin, browser: browser.version(), baselineMode, sizes, results, snapshots, errors, fatal, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length }; await fs.writeFile(path.join(output, baselineMode ? 'before-composition.json' : 'browser-composition.json'), JSON.stringify(result, null, 2)); await browser.close(); console.log(JSON.stringify({ baselineMode, pass: result.pass, fail: result.fail, output })); if (result.fail) process.exitCode = 1; }