151 lines
14 KiB
JavaScript
151 lines
14 KiB
JavaScript
// 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;
|
|
}
|