Deploy Storybook / deploy (push) Successful in 59s
Remove the data, layout, interaction and employee-popup setting panels from the Expanded OrgChart Story. The chart now renders a fixed sample (7 companies x 20, TB, all expanded, collapse/zoom/pan on, Drag & Drop off, HM Dialog) right under the page title. Story ID, OrgChart packages and public API are unchanged. Align the Story browser checks (P7, P8, P9, P9.2-P9.5, geometry, popup parity, hierarchy, HM Story) with the preview: setting-only scenarios are removed, chart rendering, collapse, Zoom/Pan/Fit and HM Dialog checks run on the fixed state. Move/Drag & Drop coverage stays in the Vanilla Core checks (verify-p9-3-core, verify-p9-4-core). Verified: tsc, test:guard 94/94, node --check on 11 scripts, changed browser sections PASS. Pre-existing P7 story-menu, P8 Department long-name, P9 Expanded tb/runner failures remain separate issues. verify-p9-5-popup-parity was not run (needs the Vanilla example server).
251 lines
31 KiB
JavaScript
251 lines
31 KiB
JavaScript
// P9 focused visual integration checks. Historical P7/P8 full suites stay unrun.
|
|
import fs from 'node:fs/promises';
|
|
import { writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createHash } from 'node:crypto';
|
|
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');
|
|
const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6061').replace(/\/$/, '');
|
|
const args = process.argv.slice(2), baselineMode = args.includes('--baseline');
|
|
const sections = args.flatMap((arg, index) => arg === '--section' ? (args[index + 1] ?? '').split(',') : arg.startsWith('--section=') ? arg.slice(10).split(',') : []).filter(Boolean);
|
|
const run = name => !sections.length || sections.includes(name);
|
|
const widths = args.filter(arg => arg.startsWith('--width=')).map(arg => Number(arg.slice(8)));
|
|
const suffix = (baselineMode ? '-baseline' : sections.length ? '-' + sections.join('-') : '') + (widths.length ? '-' + widths.join('-') : '');
|
|
const startedAt = new Date().toISOString(), results = [], measurements = [], errors = [];
|
|
await fs.mkdir(output, { recursive: true });
|
|
const persist = () => writeFileSync(path.join(output, `browser-progress${suffix}.json`), JSON.stringify({ startedAt, origin, baselineMode, sections, results, measurements, errors }, null, 2));
|
|
const check = (pass, name, detail) => {
|
|
results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); persist();
|
|
if (!pass) console.log('FAIL', name, JSON.stringify(detail));
|
|
};
|
|
const stories = {
|
|
overview: 'orgchart--logo-personnel-summary', department: 'orgchart--department-colors',
|
|
fixed: 'orgchart--full-composition', expanded: 'orgchart--expanded-org-chart',
|
|
};
|
|
const sizes = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 390, height: 844 }];
|
|
const selectedSizes = sizes.filter(size => !widths.length || widths.includes(size.width));
|
|
const colors = { hanmac: '#cb2e28', saman: '#f08300', 'jangheon-industry': '#4580a6', jangheon: '#1d418d', ptc: '#84338e', halla: '#0068b7', baron: '#0c367f' };
|
|
const browser = await launchChromium({ headless: true });
|
|
const context = await browser.newContext({ viewport: sizes[1] });
|
|
context.on('page', target => {
|
|
target.on('pageerror', error => { errors.push({ type: 'pageerror', url: target.url(), message: error.message }); persist(); });
|
|
target.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', url: target.url(), message: message.text(), location: message.location() }); persist(); } });
|
|
});
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(20000);
|
|
let expectedHosts = 1;
|
|
async function settle() { await page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); }
|
|
async function ready(requirePage = true) {
|
|
if (requirePage) await page.locator('.sb-page-title').waitFor();
|
|
await page.waitForFunction(expected => {
|
|
const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')];
|
|
return hosts.length === expected && hosts.every(host => host.shadowRoot && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) && !host.shadowRoot.querySelector('[aria-busy="true"]') && [...host.shadowRoot.querySelectorAll('img')].every(image => image.complete));
|
|
}, expectedHosts);
|
|
await settle();
|
|
}
|
|
async function goto(name, size = sizes[1]) { expectedHosts = name === 'overview' ? 3 : 1; await page.setViewportSize(size); await page.goto(`${origin}/iframe.html?id=${stories[name]}&viewMode=story`); await ready(); }
|
|
async function capture(name, fullPage = true) { await page.screenshot({ path: path.join(output, name + '.png'), fullPage }); }
|
|
async function section(name, work) { if (!run(name)) return; try { await work(); } catch (error) { check(false, `${name}: runner completed`, { message: error.message, stack: error.stack }); } }
|
|
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
|
|
async function snapshot() {
|
|
return page.evaluate(async () => {
|
|
const shadows = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')].map(host => host.shadowRoot).filter(Boolean);
|
|
const all = selector => shadows.flatMap(shadow => [...shadow.querySelectorAll(selector)]);
|
|
const first = selector => all(selector)[0];
|
|
const rect = element => { if (!element) return null; const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; };
|
|
const properties = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'backgroundColor', 'backgroundImage', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderRadius', 'boxShadow', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'textAlign', 'whiteSpace', 'overflowWrap', 'verticalAlign'];
|
|
const style = (element, dimensions = false) => element ? Object.fromEntries([...properties, ...(dimensions ? ['width', 'height'] : [])].map(key => [key, getComputedStyle(element)[key]])) : null;
|
|
const table = first('.hm-orgchart-workdesk-position-table');
|
|
const summary = table ? {
|
|
title: first('.hm-orgchart-organization-totals-title')?.textContent,
|
|
rows: [...table.rows].map(row => [...row.cells].map(cell => ({ text: cell.textContent, tag: cell.tagName, scope: cell.getAttribute('scope'), position: cell.dataset.position ?? null, total: cell.dataset.totalCount ?? null }))),
|
|
styles: {
|
|
section: style(first('.hm-orgchart-organization-totals')),
|
|
title: style(first('.hm-orgchart-organization-totals-title'), true), frame: style(first('.hm-orgchart-workdesk-summary-frame'), true), table: style(table, true),
|
|
cells: [...table.rows].map(row => [...row.cells].map(cell => style(cell, true))),
|
|
},
|
|
footer: [...table.tFoot.rows[0].cells].slice(1).map(cell => Number(cell.textContent)),
|
|
body: [...table.tBodies[0].rows].map(row => [...row.cells].slice(1).map(cell => Number(cell.textContent))),
|
|
} : null;
|
|
const bar = first('.hm-orgchart-affiliation-bar');
|
|
const affiliation = bar ? {
|
|
items: [...bar.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(item => ({ id: item.dataset.affiliationId ?? 'total', count: item.dataset.employeeCount ?? item.dataset.totalCount, name: item.querySelector('dt')?.textContent, value: item.querySelector('dd')?.textContent })),
|
|
styles: { bar: style(bar), items: [...bar.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(item => ({ item: style(item), name: style(item.querySelector('dt')), value: style(item.querySelector('dd')), mark: style(item.querySelector('.hm-orgchart-affiliation-mark'), true), imageFit: item.querySelector('img') ? getComputedStyle(item.querySelector('img')).objectFit : null })) },
|
|
box: rect(bar),
|
|
} : null;
|
|
const wrapper = first('.hm-orgchart-composition'), stage = first('.hm-orgchart-stage');
|
|
const scale = Number(wrapper?.dataset.scale ?? 1), stageBox = rect(stage);
|
|
const round = value => Math.round(value * 20) / 20;
|
|
const fixed = stage ? {
|
|
scale, wrapper: rect(wrapper), stage: stageBox, baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight, matrix: getComputedStyle(stage).transform,
|
|
stageStyle: style(stage), background: getComputedStyle(wrapper).backgroundColor,
|
|
htmlHash: [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(stage.innerHTML)))].map(byte => byte.toString(16).padStart(2, '0')).join(''),
|
|
preflightElements: all('.hm-orgchart-stage .hm-orgchart-employee,.hm-orgchart-stage .hm-orgchart-organization-totals,.hm-orgchart-stage .hm-orgchart-organization-totals-title,.hm-orgchart-stage .hm-orgchart-workdesk-summary-frame,.hm-orgchart-stage .hm-orgchart-workdesk-position-table,.hm-orgchart-stage .hm-orgchart-workdesk-position-table th,.hm-orgchart-stage .hm-orgchart-workdesk-position-table td,.hm-orgchart-stage .hm-orgchart-affiliation-bar,.hm-orgchart-stage .hm-orgchart-affiliation-bar-list,.hm-orgchart-stage .hm-orgchart-affiliation-bar-item,.hm-orgchart-stage .hm-orgchart-affiliation-bar-item dt,.hm-orgchart-stage .hm-orgchart-affiliation-bar-item dd,.hm-orgchart-stage .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark').map(element => ({ className: element.className, text: element.textContent, style: Object.fromEntries(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'backgroundColor', 'backgroundImage', 'width', 'height', 'padding', 'border', 'borderRadius', 'boxShadow', 'textAlign', 'whiteSpace', 'gap', 'display', 'flexWrap', 'alignItems', 'justifyContent'].map(key => [key, getComputedStyle(element)[key]])) })),
|
|
invariants: all('.hm-orgchart-stage .hm-orgchart-employee,.hm-orgchart-stage .hm-orgchart-organization,.hm-orgchart-stage .hm-orgchart-organization-totals,.hm-orgchart-stage .hm-orgchart-affiliation-bar,.hm-orgchart-stage .hm-orgchart-workdesk-overlay').map(element => {
|
|
const r = rect(element); return { className: element.className, id: element.dataset.employeeId ?? element.dataset.organizationId ?? null, box: { x: round((r.x - stageBox.x) / scale), y: round((r.y - stageBox.y) / scale), width: round(r.width / scale), height: round(r.height / scale) }, style: style(element, true) };
|
|
}),
|
|
scroll: ['.hm-orgchart-shell', '.hm-orgchart-content', '.hm-orgchart-composition', '.hm-orgchart-viewport'].map(selector => {
|
|
const element = first(selector); if (!element) return { selector, missing: true };
|
|
const css = getComputedStyle(element); return { selector, left: element.scrollLeft, top: element.scrollTop, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, overflowX: css.overflowX, overflowY: css.overflowY };
|
|
}),
|
|
} : null;
|
|
const logos = all('[data-component="Logo"] .hm-orgchart-affiliation-mark').map(mark => {
|
|
const img = mark.querySelector('img'); return { id: mark.dataset.affiliationId, available: mark.dataset.logoAvailable, box: rect(mark), loaded: Boolean(img?.complete && img.naturalWidth > 0), naturalWidth: img?.naturalWidth, naturalHeight: img?.naturalHeight, fit: img ? getComputedStyle(img).objectFit : null, style: style(mark) };
|
|
});
|
|
const department = first('[data-component="DepartmentColors"]');
|
|
const departments = department ? [...department.querySelectorAll('.hm-orgchart-color-preview')].map(row => ({
|
|
name: row.querySelector('.hm-orgchart-color-department-name').textContent, box: rect(row), preview: rect(row.querySelector('.hm-orgchart-preview-grid')),
|
|
swatches: [...row.querySelectorAll('[data-color-variable]')].map(swatch => ({ variable: swatch.dataset.colorVariable, property: swatch.dataset.cssProperty, value: swatch.parentElement.querySelector('.hm-orgchart-color-value').textContent })),
|
|
cards: [...row.querySelectorAll('.hm-orgchart-employee')].map(card => ({ kind: card.dataset.cardKind, width: card.offsetWidth, height: card.offsetHeight, box: rect(card), font: getComputedStyle(card).fontFamily, asset: card.dataset.cardAssetSize })),
|
|
})) : null;
|
|
const viewport = first('.hm-orgchart-expanded .hm-orgchart-viewport'), forest = first('.hm-orgchart-forest');
|
|
const expanded = first('.hm-orgchart-expanded');
|
|
const hex = color => color.replace(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g, (_, r, g, b) => '#' + [r, g, b].map(x => Number(x).toString(16).padStart(2, '0')).join(''));
|
|
const auto = expanded ? {
|
|
type: expanded.dataset.autoLayout, viewport: rect(viewport), forest: rect(forest), employees: expanded.querySelectorAll('.hm-orgchart-employee').length,
|
|
titles: [...expanded.querySelectorAll('.node[data-company-id]')].map(node => { const title = node.querySelector('.hm-orgchart-organization-name'), toggle = node.querySelector('.hm-orgchart-expanded-toggle'); return { company: node.dataset.companyId, color: hex(getComputedStyle(title).color), buttonColor: hex(getComputedStyle(toggle).color), background: hex(getComputedStyle(title).backgroundColor) }; }),
|
|
cards: [...expanded.querySelectorAll('.hm-orgchart-employee')].map(card => { const css = getComputedStyle(card), direction = card.dataset.connectionDirection; return { company: card.dataset.companyId, direction, width: card.offsetWidth, height: card.offsetHeight, border: hex(css[direction === 'left' ? 'borderLeftColor' : direction === 'right' ? 'borderRightColor' : 'borderTopColor']), image: Boolean(card.querySelector('img')) }; }),
|
|
connectors: expanded.querySelectorAll('svg,.edge,.lines').length,
|
|
} : null;
|
|
const common = Object.fromEntries(['.sb-page-title', '.sb-page-description', '.sb-page-section-title', '.sb-page-section-header'].map(selector => [selector, style(document.querySelector(selector))]));
|
|
return { title: document.querySelector('.sb-page-title')?.textContent, titles: [...document.querySelectorAll('.sb-page-section-title')].map(element => element.textContent), document: { width: innerWidth, scrollWidth: document.documentElement.scrollWidth, scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight }, common, summary, affiliation, logos, departments, fixed, auto };
|
|
});
|
|
}
|
|
function noHorizontalOverflow(state) { return state.document.scrollWidth <= state.document.width + 1; }
|
|
function contains(outer, inner, tolerance = 1) { return outer && inner && inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.right <= outer.right + tolerance && inner.bottom <= outer.bottom + tolerance; }
|
|
function fitted(state) { return state.auto && contains(state.auto.viewport, state.auto.forest, 2); }
|
|
function affiliationStyles(styles) {
|
|
// Overview's permitted wrapping uses vertical content padding rather than a
|
|
// fixed 55.4px bar height. Every item/typography/logo style remains shared.
|
|
return { ...styles, bar: Object.fromEntries(Object.entries(styles.bar).filter(([key]) => !['paddingTop', 'paddingBottom'].includes(key))) };
|
|
}
|
|
function sourceInvariant(fixed) { return { width: fixed.baseWidth, height: fixed.baseHeight, style: fixed.stageStyle, contents: fixed.invariants }; }
|
|
function geometryNear(a, b) {
|
|
if (!a || !b || a.width !== b.width || a.height !== b.height || !equal(a.style, b.style) || a.contents.length !== b.contents.length) return false;
|
|
return a.contents.every((item, index) => { const other = b.contents[index]; return item.className === other.className && item.id === other.id && equal(item.style, other.style) && Object.keys(item.box).every(key => Math.abs(item.box[key] - other.box[key]) <= 0.1); });
|
|
}
|
|
|
|
try {
|
|
if (baselineMode) {
|
|
const snapshots = {};
|
|
for (const name of ['overview', 'department', 'fixed']) { await goto(name); snapshots[name] = await snapshot(); await capture(`baseline-${name}-1440`); }
|
|
check(Boolean(snapshots.fixed.summary && snapshots.fixed.affiliation && snapshots.fixed.fixed), 'Baseline: Fixed shared presenters and geometry captured');
|
|
await fs.writeFile(path.join(output, 'visual-baseline.json'), JSON.stringify({ startedAt, completedAt: new Date().toISOString(), origin, viewport: sizes[1], snapshots }, null, 2));
|
|
} else {
|
|
const baselinePath = await fs.access(path.join(output, 'preflight.json')).then(() => 'preflight.json', () => 'visual-baseline.json');
|
|
const baseline = JSON.parse(await fs.readFile(path.join(output, baselinePath), 'utf8'));
|
|
const preflightFixed = baselinePath === 'preflight.json' ? baseline.snapshots.fixed[0] : null;
|
|
const preflightHash = preflightFixed ? createHash('sha256').update(preflightFixed.stage.html).digest('hex') : null;
|
|
await goto('fixed'); const reference = await snapshot();
|
|
await section('overview', async () => {
|
|
for (const size of selectedSizes) {
|
|
await goto('overview', size); const state = await snapshot(); measurements.push({ name: `Overview ${size.width}`, state });
|
|
check(equal(state.titles, ['관계사·회사 로고', '조직·직위별 인원 현황', '관계사별 인원 현황']), `Overview ${size.width}: exact three confirmed section titles`, state.titles);
|
|
check(Boolean(state.summary) && equal(state.summary.rows, reference.summary.rows), `Overview ${size.width}: table rows, positions and totals equal Fixed`, state.summary?.rows);
|
|
check(Boolean(state.summary) && equal(state.summary.styles, reference.summary.styles), `Overview ${size.width}: table, heading, cells, borders and font reuse Fixed computed styles`, { overview: state.summary?.styles, fixed: reference.summary.styles });
|
|
check(Boolean(state.summary) && state.summary.footer.every(Number.isFinite) && state.summary.footer.slice(0, -1).reduce((a, b) => a + b, 0) === state.summary.footer.at(-1) && state.summary.body.every(row => row.slice(0, -1).reduce((a, b) => a + b, 0) === row.at(-1)), `Overview ${size.width}: numeric position sums and row subtotals reconcile`, { footer: state.summary?.footer, body: state.summary?.body });
|
|
check(Boolean(state.affiliation) && equal(state.affiliation.items, reference.affiliation.items) && equal(affiliationStyles(state.affiliation.styles), affiliationStyles(reference.affiliation.styles)), `Overview ${size.width}: affiliation data, typography, logos, border and background reuse Fixed`, { overview: state.affiliation, fixed: reference.affiliation });
|
|
const previewBox = await page.locator('.sb-page-section').last().locator('.sb-page-section-body').boundingBox();
|
|
check(state.affiliation?.box.x >= previewBox.x - 1 && state.affiliation.box.right <= previewBox.x + previewBox.width + 1 && state.affiliation.box.y >= previewBox.y - 1 && state.affiliation.box.bottom <= previewBox.y + previewBox.height + 1, `Overview ${size.width}: complete affiliation bar is contained in page flow`, { bar: state.affiliation?.box, preview: previewBox });
|
|
check(state.logos.length === 7 && state.logos.every(logo => logo.available === 'true' && logo.loaded && logo.fit === 'contain' && logo.naturalWidth > 0 && logo.naturalHeight > 0), `Overview ${size.width}: seven approved logos loaded with preserved contain sizing`, state.logos);
|
|
check(noHorizontalOverflow(state), `Overview ${size.width}: no document horizontal overflow`, state.document);
|
|
check(equal(state.common, reference.common), `Overview ${size.width}: common StoryPage typography and border remain identical`);
|
|
await capture(`overview-${size.width}`);
|
|
}
|
|
});
|
|
await section('department', async () => {
|
|
for (const size of selectedSizes) {
|
|
await goto('department', size); const state = await snapshot(); measurements.push({ name: `Department ${size.width}`, state });
|
|
check(state.departments?.length === 6 && state.departments.every(row => row.swatches.every(swatch => /#[0-9a-f]{6}/i.test(swatch.value))), `Department ${size.width}: six preserved organization color previews have computed HEX values`, state.departments?.map(row => ({ name: row.name, values: row.swatches })));
|
|
check(state.departments?.every(row => row.cards.every(card => card.width === (card.kind === 'employee' ? 108 : 146) && card.height === (card.kind === 'employee' ? 52 : 68) && card.font.includes('Noto Sans KR') && contains(row.box, card.box))), `Department ${size.width}: original card geometry, font and containment`, state.departments?.map(row => ({ name: row.name, cards: row.cards })));
|
|
check(noHorizontalOverflow(state) && equal(state.common, reference.common), `Department ${size.width}: responsive page retains shared frame styles`, state.document);
|
|
check(state.departments?.every((row, index, rows) => rows.every((other, otherIndex) => otherIndex === index || Math.abs(row.box.y - other.box.y) > 1 || Math.abs(row.preview.y - other.preview.y) <= 1)), `Department ${size.width}: card-preview starts align across each shared row`, state.departments?.map(row => ({ name: row.name, rowY: row.box.y, previewY: row.preview.y })));
|
|
measurements.push({ name: `Department ${size.width} card-preview alignment`, previews: state.departments?.map(row => ({ name: row.name, row: row.box, preview: row.preview })) });
|
|
await capture(`department-colors-${size.width}`);
|
|
}
|
|
});
|
|
await section('fixed', async () => {
|
|
for (const size of selectedSizes) {
|
|
await goto('fixed', size); const before = await snapshot();
|
|
await page.locator('.hm-orgchart-composition').scrollIntoViewIfNeeded();
|
|
const box = await page.locator('.hm-orgchart-composition').boundingBox();
|
|
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(30, box.height / 2)); await page.mouse.wheel(80, 80); await settle();
|
|
const state = await snapshot(), fixed = state.fixed; measurements.push({ name: `Fixed ${size.width}`, state });
|
|
const matrix = fixed.matrix.match(/matrix\(([^)]+)\)/)?.[1].split(',').map(Number);
|
|
check(fixed.scale > 0 && fixed.scale <= 1 && matrix && Math.abs(matrix[0] - matrix[3]) < 0.0001 && contains(fixed.wrapper, fixed.stage) && fixed.invariants.every(item => item.box.x >= -1 && item.box.y >= -1 && item.box.x + item.box.width <= fixed.baseWidth + 1 && item.box.y + item.box.height <= fixed.baseHeight + 1), `Fixed ${size.width}: complete chart bounds and proportional fit`, { scale: fixed.scale, matrix, wrapper: fixed.wrapper, stage: fixed.stage });
|
|
check(fixed.scroll.every(item => item.missing || (item.left === 0 && item.top === 0 && (item.overflowX === 'hidden' || item.scrollWidth <= item.clientWidth + 1) && (item.overflowY === 'hidden' || item.scrollHeight <= item.clientHeight + 1))), `Fixed ${size.width}: no actual internal scroll after wheel`, { before: before.fixed.scroll, after: fixed.scroll });
|
|
const preserved = preflightFixed ? fixed.baseWidth === preflightFixed.stage.width && fixed.baseHeight === preflightFixed.stage.height && equal(fixed.preflightElements, preflightFixed.elements) && fixed.htmlHash === preflightHash : geometryNear(sourceInvariant(fixed), sourceInvariant(baseline.snapshots.fixed.fixed)) && equal(state.summary, baseline.snapshots.fixed.summary) && equal(state.affiliation.items, baseline.snapshots.fixed.affiliation.items) && equal(state.affiliation.styles, baseline.snapshots.fixed.affiliation.styles);
|
|
check(preserved, `Fixed ${size.width}: baseline card/layout and summary styling preserved`, { baseline: baselinePath, naturalWidth: fixed.baseWidth, naturalHeight: fixed.baseHeight, baselineElementCount: preflightFixed?.elements.length, elementCount: fixed.preflightElements.length, computedStylesEqual: preflightFixed ? equal(fixed.preflightElements, preflightFixed.elements) : undefined, stageDOMHashEqual: preflightFixed ? fixed.htmlHash === preflightHash : undefined });
|
|
check(noHorizontalOverflow(state), `Fixed ${size.width}: no document horizontal overflow`, state.document);
|
|
await page.evaluate(() => scrollTo(0, 0)); await capture(`full-composition-${size.width}`);
|
|
}
|
|
});
|
|
await section('expanded', async () => {
|
|
for (const size of selectedSizes) {
|
|
await goto('expanded', size);
|
|
// Expanded is preview-only (2026-10-22): the fixed TB default is the only layout the Story shows.
|
|
{ const type = 'tb';
|
|
const state = await snapshot(); measurements.push({ name: `Expanded ${type} ${size.width}`, state });
|
|
check(state.auto?.type === type && state.auto.employees === 20 && fitted(state) && noHorizontalOverflow(state), `Expanded ${type} ${size.width}: representative layout renders unchanged sample and Fits`, state.auto);
|
|
check(state.auto?.titles.length > 0 && state.auto.titles.every(title => title.color === '#ffffff' && title.buttonColor === '#ffffff' && title.background === colors[title.company]) && new Set(state.auto.titles.map(title => title.company)).size === 7, `Expanded ${type} ${size.width}: all company title backgrounds and white text preserved`, state.auto?.titles);
|
|
check(state.auto?.cards.every(card => card.border === colors[card.company] && ['top', 'left', 'right'].includes(card.direction)) && state.auto.connectors > 0, `Expanded ${type} ${size.width}: company colored directional borders and connectors`, state.auto?.cards);
|
|
await capture(`expanded-${type}-${size.width}`);
|
|
}
|
|
}
|
|
if (!widths.length) {
|
|
await goto('expanded');
|
|
const toggle = page.locator('.hm-orgchart-expanded-toggle:not(:disabled)').first(), id = await toggle.getAttribute('data-organization-id');
|
|
await toggle.hover(); check(await toggle.evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: hover title remains white');
|
|
await toggle.focus(); check(await toggle.evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: focus title remains white');
|
|
await toggle.press('Enter'); await ready();
|
|
const current = () => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`);
|
|
check(await current().getAttribute('aria-expanded') === 'false' && await current().evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: collapsed title remains white');
|
|
await current().press('Space'); await ready();
|
|
check(await current().getAttribute('aria-expanded') === 'true' && await current().evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: expanded title remains white');
|
|
}
|
|
});
|
|
await section('dialog', async () => {
|
|
for (const size of [sizes[1], sizes[2]]) {
|
|
await goto('expanded', size);
|
|
const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id');
|
|
await employee.click(); const dialog = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await dialog.waitFor({ state: 'visible' }); await settle();
|
|
const state = await dialog.evaluate(element => { const r = element.getBoundingClientRect(); return { role: element.getAttribute('role'), employeeId: element.dataset.employeeId, portal: element.parentElement === document.body, focusInside: element.contains(document.activeElement), x: r.x, right: r.right, width: r.width, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }; });
|
|
check(state.role === 'dialog' && state.employeeId === employeeId && state.portal && state.focusInside && await page.getByRole('dialog').count() === 1 && await page.locator('dialog[open]').count() === 0 && state.x >= -1 && state.right <= size.width + 1 && state.scrollWidth <= state.clientWidth + 1, `HM Dialog ${size.width}: actual single HM Portal, identity, focus and responsive bounds`, state);
|
|
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); check(await dialog.evaluate(element => element.contains(document.activeElement)), `HM Dialog ${size.width}: Tab focus remains inside`);
|
|
await capture(`hm-dialog-${size.width}`, false);
|
|
const focusedEmployee = () => page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId);
|
|
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle(); check(await focusedEmployee() === employeeId, `HM Dialog ${size.width}: ESC returns focus to source card`);
|
|
await employee.press('Enter'); await dialog.waitFor({ state: 'visible' }); await dialog.getByRole('button', { name: '닫기', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }); await settle(); check(await focusedEmployee() === employeeId, `HM Dialog ${size.width}: close button returns focus to source card`);
|
|
}
|
|
});
|
|
await section('menu', async () => {
|
|
const response = await page.request.get(`${origin}/index.json`), index = await response.json();
|
|
const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal'));
|
|
check(entries.length === 4 && equal(entries.map(entry => entry.id).sort(), Object.values(stories).sort()), 'Storybook: exactly four public OrgChart pages and all retained IDs', entries.map(entry => ({ id: entry.id, title: entry.title })));
|
|
});
|
|
await section('independent', async () => {
|
|
expectedHosts = 1; await page.setViewportSize(sizes[1]);
|
|
for (const [id, kind] of [['orgchart--position-summary', 'PositionSummary'], ['orgchart--affiliation-summary', 'AffiliationSummary']]) {
|
|
await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`); await ready(false);
|
|
const state = await page.evaluate(kind => {
|
|
const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot, root = shadow.querySelector(`[data-component="${kind}"]`);
|
|
return { kind, rootClass: root.className, total: root.querySelector('.hm-orgchart-raw-total')?.textContent, header: [...root.querySelectorAll('thead th')].map(element => element.textContent), footer: root.querySelector('tfoot')?.textContent, affiliationItems: root.querySelectorAll('.hm-orgchart-affiliation-item').length, workdeskPresenters: shadow.querySelectorAll('.hm-orgchart-workdesk-position-table,.hm-orgchart-affiliation-bar,.hm-orgchart-summary-preview').length };
|
|
}, kind);
|
|
check(state.workdeskPresenters === 0 && state.total?.includes('41') && (kind === 'PositionSummary' ? state.rootClass.includes('hm-orgchart-position-summary') && state.header.includes('소계 (직접)') && state.header.includes('하위 포함') && state.footer.includes('전체 합계') : state.rootClass === 'hm-orgchart-affiliation-summary' && state.affiliationItems === 7), `Independent ${kind}: default presenter and aggregation contract preserved`, state);
|
|
}
|
|
});
|
|
}
|
|
} catch (error) { check(false, 'P9 runner completed', { message: error.message, stack: error.stack }); }
|
|
finally {
|
|
check(errors.length === 0, 'No new browser console or page errors', errors);
|
|
const result = { startedAt, completedAt: new Date().toISOString(), origin, baselineMode, sections, widths, browser: browser.version(), results, measurements, errors, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length,
|
|
limitations: ['Focused P9 verification only; historical P7/P8 full suites and 2,100 employee tests were not repeated.', 'Shared computed styles demonstrate runtime reuse; CSS design intent approval and original Guard NOT VERIFIED remain separate.', 'StoryPage document vertical scroll is distinct from chart internal scrolling.', 'White text company colors remain approved; Saman and Jangheon Industry contrast remains a recorded accessibility issue.', 'Other browsers, physical devices, screen readers, SSR and deployment remain NOT VERIFIED.'] };
|
|
await fs.writeFile(path.join(output, `browser${suffix}.json`), JSON.stringify(result, null, 2));
|
|
console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9/browser${suffix}.json` }));
|
|
await context.close(); await browser.close(); if (result.fail) process.exitCode = 1;
|
|
}
|