Files
HMDesign/examples/org-chart/verify-p5-1b.mjs
T

378 lines
29 KiB
JavaScript

import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
import http from 'node:http';
import path from 'node:path';
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
// P5-1B focused integration suite. Preserve historical verification evidence.
// Copy the pre-change public Core dist to baseline-dist before rebuilding.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p5-1b');
const storyOrigin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6053';
await loadPlaywright();
const fixedFixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
const expandedFixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded.json'), 'utf8'));
const presets = [
{ id: 'hanmac', name: '한맥기술', primary: '#CB2E28', secondary: ['#1D4289'] },
{ id: 'saman', name: '삼안', primary: '#F08300', secondary: ['#6D6C6C'] },
{ id: 'jangheon-industry', name: '장헌산업', primary: '#4580A6', secondary: ['#92BED4'] },
{ id: 'jangheon', name: '장헌', primary: '#1D418D', secondary: ['#AE232A'] },
{ id: 'ptc', name: '피티씨', primary: '#84338E', secondary: ['#F7AF00', '#0066B5'] },
{ id: 'halla', name: '한라산업개발', primary: '#0068B7', secondary: ['#231815'] },
{ id: 'baron', name: '바론컨설턴트', primary: '#0C367F', secondary: [] },
];
const results = [], measurements = [], errors = [], warnings = [];
const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const near = (a, b) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= 0.35;
const rgb = hex => `rgb(${[1, 3, 5].map(offset => parseInt(hex.slice(offset, offset + 2), 16)).join(', ')})`;
const expectedCompanyIds = presets.map(preset => preset.id);
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml', '.png': 'image/png' };
const shellHtml = '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:,"><style>@font-face{font-family:"Noto Sans KR";src:url("/fonts/NotoSansKR-variable.ttf");font-weight:100 900;font-style:normal}*{box-sizing:border-box}html,body{margin:0;width:100%;min-height:100%;font-family:"Noto Sans KR",sans-serif}#chart{width:100%;height:100vh;min-width:0}</style></head><body><main id="chart"></main></body></html>';
await fs.mkdir(output, { recursive: true });
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/__p51b') return response.writeHead(200, { 'Content-Type': 'text/html' }).end(shellHtml);
const pathname = decodeURIComponent(url.pathname);
const file = path.resolve(root, '.' + (pathname.startsWith('/fonts/') ? '/public' + pathname : pathname));
const relative = path.relative(root, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
const bytes = await fs.readFile(file);
response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' });
response.end(bytes);
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const browser = await launchChromium({ headless: true });
async function newPage(name, viewport = { width: 1440, height: 900 }) {
const page = await browser.newPage({ viewport });
page.on('pageerror', error => errors.push({ name, type: 'pageerror', message: error.message }));
page.on('console', message => {
if (message.type() === 'error') errors.push({ name, type: 'console', message: message.text() });
if (message.type() === 'warning') warnings.push({ name, message: message.text() });
});
return page;
}
async function ready(page, mode) {
await page.waitForFunction(expected => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
const content = shadow?.querySelector(expected === 'auto' ? '.hm-orgchart-expanded' : '.hm-orgchart-composition');
return content?.querySelector('.hm-orgchart-employee') && [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => link.sheet);
}, mode, { timeout: 25000 });
await page.evaluate(async () => {
await document.fonts.load('600 24px "Noto Sans KR"', '홍 길 동');
await document.fonts.ready;
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const images = [...shadow.querySelectorAll('.hm-orgchart-employee img')];
for (const image of images) image.loading = 'eager';
await Promise.all(images.map(image => image.decode().catch(() => {})));
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
}
async function mount(page, { baseline = false, fixture = expandedFixture, options = {} } = {}) {
await page.goto(`${origin}/__p51b`);
await page.evaluate(async ({ base, fixture, options }) => {
window.hmModule = await import(`${base}/index.js`);
window.fixture = structuredClone(fixture);
window.handle = window.hmModule.createOrgChart({
container: document.getElementById('chart'),
dataSource: new window.hmModule.JsonOrgChartDataSource(fixture),
stylesheetUrls: { engine: `${base}/engine.css`, core: `${base}/style.css` },
...options,
});
await window.handle.mount();
}, { base: `${origin}/${baseline ? 'test-results/org-chart-p5-1b/baseline-dist' : 'packages/org-chart/dist'}`, fixture, options });
await ready(page, options.layoutMode === 'auto' ? 'auto' : 'fixed');
return page.evaluate(() => window.handle.getState());
}
async function measure(page) {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const stage = shadow.querySelector('.hm-orgchart-stage');
const base = stage?.getBoundingClientRect() ?? shadow.querySelector('.hm-orgchart-expanded').getBoundingClientRect();
const scale = stage ? base.width / stage.offsetWidth : 1;
const box = element => {
const bounds = element.getBoundingClientRect();
return { x: (bounds.x - base.x) / scale, y: (bounds.y - base.y) / scale, width: bounds.width / scale, height: bounds.height / scale };
};
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({
key: `${card.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? 'tree'}:${card.dataset.employeeId}`,
id: card.dataset.employeeId, kind: card.dataset.cardKind, size: card.dataset.cardAssetSize ?? null,
box: box(card), asset: card.querySelector('.hm-orgchart-card-background')?.src ?? null,
logo: card.querySelector('.hm-orgchart-affiliation-mark img')?.src ?? null,
name: card.querySelector('.hm-orgchart-person-name')?.textContent,
role: card.querySelector('.hm-orgchart-person-role')?.textContent,
font: getComputedStyle(card.querySelector('.hm-orgchart-person-name')).fontFamily,
badgeCount: card.querySelectorAll('.hm-orgchart-job-title,.hm-orgchart-status-label,.hm-orgchart-person-attributes').length,
placeholder: card.dataset.photoState,
}));
const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => {
const heading = node.querySelector('.hm-orgchart-organization-name');
const style = getComputedStyle(heading);
return { id: node.dataset.organizationId, companyId: node.dataset.companyId ?? null,
depth: Number(node.dataset.organizationDepth), text: heading.textContent, box: box(heading),
backgroundColor: style.backgroundColor, backgroundImage: style.backgroundImage,
primary: node.style.getPropertyValue('--hm-orgchart-expanded-primary'),
secondary: [...node.style].filter(property => property.startsWith('--hm-orgchart-expanded-secondary-')).map(property => node.style.getPropertyValue(property)),
};
});
const collisions = [];
for (let index = 0; index < cards.length; index++) for (const other of cards.slice(index + 1)) {
const a = cards[index].box, b = other.box;
if (Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.5
&& Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.5) collisions.push([cards[index].key, other.key]);
}
const viewport = shadow.querySelector('.hm-orgchart-viewport');
const expanded = shadow.querySelector('.hm-orgchart-expanded');
const summaries = [...shadow.querySelectorAll('[data-component="PositionSummary"],[data-component="AffiliationSummary"],[data-component="StatusSummary"]')].map(element => ({ kind: element.dataset.component, text: element.textContent }));
return { scale, cards, nodes, summaries, collisions,
hostCount: document.querySelectorAll('.hm-orgchart-host').length,
compositionCount: shadow.querySelectorAll('.hm-orgchart-composition,.hm-orgchart-expanded').length,
controls: shadow.querySelectorAll('.hm-orgchart-toggle,.zoom-controls,.pan-controls,.minimap').length,
layoutStatus: expanded?.dataset.layoutStatus ?? null,
phaseText: expanded?.textContent ?? '',
viewport: viewport ? { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight, overflowX: getComputedStyle(viewport).overflowX } : null,
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
};
});
}
function compareFixed(before, after, name) {
check(JSON.stringify(before.cards.map(({ box, ...rest }) => rest)) === JSON.stringify(after.cards.map(({ box, ...rest }) => rest)), `${name}: card assets/content/typography/placeholder unchanged`);
check(before.cards.length === after.cards.length && before.cards.every((card, index) => ['x', 'y', 'width', 'height'].every(key => near(card.box[key], after.cards[index].box[key]))), `${name}: fixed card coordinates and dimensions unchanged`);
check(before.nodes.length === after.nodes.length && before.nodes.every((node, index) => {
const next = after.nodes[index];
return node.id === next.id && node.depth === next.depth && node.text === next.text
&& node.backgroundColor === next.backgroundColor && node.backgroundImage === next.backgroundImage
&& ['x', 'y', 'width', 'height'].every(key => near(node.box[key], next.box[key]));
}), `${name}: hierarchy headers and Workdesk gradients unchanged`);
check(JSON.stringify(before.summaries) === JSON.stringify(after.summaries), `${name}: summary counts unchanged`);
check(after.controls === 0 && after.compositionCount === 1 && after.layoutStatus === null, `${name}: fixed read-only renderer retained`);
}
async function popup(page, name, id) {
const card = page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`).first();
await card.focus();
await card.press('Enter');
await page.locator('dialog[open]').waitFor();
const data = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const dialog = shadow.querySelector('dialog[open]');
return { employeeId: dialog.dataset.employeeId, text: dialog.textContent, count: shadow.querySelectorAll('dialog[open]').length, activeInside: dialog.contains(shadow.activeElement) };
});
check(data.employeeId === id && data.count === 1 && data.activeInside, `${name}: keyboard opens shared employee popup`, data);
await page.keyboard.press('Tab');
check(await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
return shadow.querySelector('dialog[open]').contains(shadow.activeElement);
}), `${name}: popup Tab focus remains inside`);
await page.keyboard.press('Escape');
await page.locator('dialog[open]').waitFor({ state: 'detached' });
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), `${name}: Escape restores initiating card focus`);
return data.text;
}
async function scenario(name, run) {
try { await run(); }
catch (error) { check(false, `${name}: verification completed`, { message: error.message, stack: error.stack }); }
}
async function geometry(page) {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const rect = element => { const r = element.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,cx:r.x+r.width/2}; };
const pseudo = (element, name) => {
const s = getComputedStyle(element, name);
return {content:s.content,top:s.top,bottom:s.bottom,height:s.height,width:s.width,border:s.borderTopWidth,color:s.backgroundColor};
};
const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => {
const hierarchy = node.parentElement;
const childRow = [...hierarchy.children].find(item => item.classList.contains('nodes'));
const parent = hierarchy.parentElement.closest('.hierarchy')?.querySelector(':scope > .node');
return {id:node.dataset.organizationId,parentId:node.dataset.parentId,domParent:parent?.dataset.organizationId ?? '',depth:Number(node.dataset.depth),
node:rect(node),article:rect(node.querySelector('.hm-orgchart-organization')),subtree:rect(hierarchy),children:childRow ? rect(childRow) : null,
upper:pseudo(node,'::before'),lower:pseudo(node,'::after'),bar:pseudo(hierarchy,'::before')};
});
return {nodes,tree:rect(shadow.querySelector('.hm-orgchart-forest'))};
});
}
async function assertHierarchy(page, name, fixture) {
const value=await measure(page), layout=await geometry(page);
measurements.push({name,...value,layout});
check(value.nodes.length===fixture.organizations.length,`${name}: every organization rendered`);
const expected = fixture.employees.filter(employee => fixture.presentation?.groups.showEmployees !== false || fixture.organizations.some(org=>org.leaderId===employee.id));
check(value.cards.length===expected.length && new Set(value.cards.map(c=>c.id)).size===value.cards.length,`${name}: employees once, no missing or duplicate leaders`);
check(value.collisions.length===0,`${name}: card collision count zero`,value.collisions);
check(value.controls===0 && value.layoutStatus==='hierarchical' && value.scale===1,`${name}: expanded hierarchy without scale/collapse/zoom/pan controls`);
for (const depth of new Set(layout.nodes.map(n=>n.depth))) {
const row=layout.nodes.filter(n=>n.depth===depth);
check(row.every(n=>near(n.node.y,row[0].node.y)),`${name}: depth ${depth} horizontal alignment`);
}
for (const n of layout.nodes) {
check(n.parentId===n.domParent,`${name}: ${n.id} actual DOM parent matches domain parent`);
if(n.parentId) {
const parent=layout.nodes.find(p=>p.id===n.parentId);
check(parent && n.node.y>parent.article.bottom,`${name}: ${n.id} below its parent content`);
check(n.upper.content!=='none' && parseFloat(n.upper.height)>0,`${name}: ${n.id} upward connector exists`);
}
if(n.children) {
check(near(n.node.cx,n.children.cx),`${name}: ${n.id} centered above own subtree`);
check(n.lower.content!=='none' && parseFloat(n.lower.height)>0,`${name}: ${n.id} downward connector`);
} else check(n.lower.content==='none',`${name}: ${n.id} leaf has no downward connector`);
}
for (let i=0;i<layout.nodes.length;i++) for(const other of layout.nodes.slice(i+1)) {
const a=layout.nodes[i],b=other;
if(a.parentId===b.parentId && a.depth===b.depth)
check(Math.min(a.subtree.right,b.subtree.right)-Math.max(a.subtree.x,b.subtree.x)<=0.5,`${name}: sibling subtrees ${a.id}/${b.id} disjoint`);
}
check(value.cards.every(c=>c.name==='홍 길 동' && c.font.includes('Noto Sans KR') && !c.asset),`${name}: Auto content, shared font, no Fixed SVG assets`);
const rep=value.cards.filter(c=>c.kind==='topLeader');
check(rep.length===1 && rep[0].badgeCount===0 && rep[0].role==='대표이사',`${name}: representative position and zero badges`);
check(value.document.scrollWidth<=value.document.clientWidth+1,`${name}: no document horizontal overflow`,value.document);
return {value,layout};
}
try {
if(process.env.ORGCHART_VERIFY_RETRY === 'overflow') {
const page=await newPage('overflow-recheck',{width:1920,height:1080});
try {
await mount(page,{options:{layoutMode:'auto',displayMode:'readOnly'}});
const before=await geometry(page);
const grown=structuredClone(expandedFixture);
for(let i=0;i<12;i++) grown.employees.push({id:'many-'+i,organizationId:'business-management',affiliationId:'hanmac',name:'김지우',position:'선임',jobTitle:null,profileImage:null,workStatus:'active'});
for(let i=0;i<8;i++) grown.organizations.push({id:'new-sibling-'+i,parentId:'business-planning',name:'추가 셀 '+i,type:'cell',leaderId:null,displayOrder:100+i});
await page.evaluate(async data=>window.handle.update(data),grown);await ready(page,'auto');
const after=await assertHierarchy(page,'Wider updated tree',grown);
check(after.layout.tree.width>before.tree.width && after.layout.tree.height>before.tree.height,'Organization/staff growth increases natural content width and height');
check(after.value.viewport.scrollWidth>after.value.viewport.clientWidth && after.value.viewport.scrollHeight>after.value.viewport.clientHeight,'Large tree has horizontal and vertical inner overflow');
const scroll=await page.evaluate(()=>{const v=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-viewport');v.scrollLeft=v.scrollWidth;v.scrollTop=v.scrollHeight;return {x:v.scrollLeft,y:v.scrollTop};});
check(scroll.x>0&&scroll.y>0,'Both overflow axes scroll to far content');
await page.screenshot({path:path.join(output,'vanilla-overflow-scrolled.png'),fullPage:true});
await page.goto(storyOrigin+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');await ready(page,'auto');
for(const width of [1280,1440]) {
await page.setViewportSize({width,height:900});await ready(page,'auto');
const value=await measure(page),layout=await geometry(page);
check(value.document.scrollWidth<=value.document.clientWidth+1 && (layout.nodes[0].subtree.width<=value.viewport.clientWidth || value.viewport.scrollWidth>value.viewport.clientWidth),'React '+width+' wide tree accessible with horizontal scroll');
}
// Full-tree capture: expand only this test's viewport height. All cards
// remain actual React DOM; the normal screenshots retain scroll policy.
await page.setViewportSize({width:1920,height:2000});await ready(page,'auto');
await page.screenshot({path:path.join(output,'storybook-expanded-full-tree.png'),fullPage:true});
} finally {await page.close();}
} else {
await scenario('Fixed minimal regression',async()=>{
const page=await newPage('fixed-regression');
try {
await mount(page,{baseline:true,fixture:fixedFixture,options:{displayMode:'readOnly'}});
const before=await measure(page);
const beforePopup=await popup(page,'Fixed baseline','mock-leader-00');
await mount(page,{fixture:fixedFixture,options:{displayMode:'readOnly'}});
const after=await measure(page);
compareFixed(before,after,'Fixed baseline/current');
check(await popup(page,'Fixed current','mock-leader-00')===beforePopup,'Fixed popup unchanged');
await page.screenshot({path:path.join(output,'fixed-current-1440.png'),fullPage:true});
} finally {await page.close();}
});
let vanilla;
await scenario('Auto Core hierarchy and updates',async()=>{
const page=await newPage('vanilla-auto',{width:1920,height:1080});
try {
await mount(page,{options:{layoutMode:'auto',displayMode:'readOnly',organizationLinks:{'business-management':'#business'}}});
vanilla=await assertHierarchy(page,'Vanilla initial',expandedFixture);
check(vanilla.layout.nodes.some(n=>n.depth===4),'4Depth recursive fixture rendered');
check(await page.locator('.hm-orgchart-organization-link').count()===1,'1Depth native page link retained');
await page.locator('.hm-orgchart-organization-link').click();
check(await page.locator('.node[data-organization-id]').count()===expandedFixture.organizations.length && await page.locator('dialog[open]').count()===0,'Group link leaves hierarchy visible and no employee popup');
await popup(page,'Vanilla nested employee','expanded-nested-5');
check(vanilla.value.cards.every(c=>c.placeholder==='placeholder'),'Missing photos share person placeholder');
const augmented=structuredClone(expandedFixture);
for(let i=0;i<12;i++) augmented.employees.push({id:`many-${i}`,organizationId:'business-management',affiliationId:'hanmac',name:'김지우',position:'선임',jobTitle:null,profileImage:null,workStatus:'active'});
for(let i=0;i<8;i++) augmented.organizations.push({id:`new-sibling-${i}`,parentId:'business-planning',name:`추가 셀 ${i}`,type:'cell',leaderId:null,displayOrder:100+i});
await page.evaluate(async data=>{await window.handle.update(data);window.currentData=data;},augmented);
await ready(page,'auto');
const grown=await assertHierarchy(page,'Unequal staff + additional organizations',augmented);
check(grown.layout.tree.width>vanilla.layout.tree.width && grown.layout.tree.height>vanilla.layout.tree.height,'Organization/staff growth increases natural content width and height');
check(grown.value.viewport.scrollWidth>grown.value.viewport.clientWidth && grown.value.viewport.scrollHeight>grown.value.viewport.clientHeight,'Large tree has horizontal and vertical inner overflow');
const scrolled=await page.evaluate(()=>{const v=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-viewport');v.scrollLeft=v.scrollWidth;v.scrollTop=v.scrollHeight;return {x:v.scrollLeft,y:v.scrollTop};});
check(scrolled.x>0 && scrolled.y>0,'Both overflow axes scroll to far content');
await page.screenshot({path:path.join(output,'vanilla-overflow-scrolled.png'),fullPage:true});
const moved=structuredClone(augmented);
moved.employees=moved.employees.filter(e=>e.id!=='many-11');
moved.employees.find(e=>e.id==='many-0').organizationId='finance';
moved.organizations=moved.organizations.filter(o=>o.id!=='new-sibling-2');
await page.evaluate(async data=>window.handle.update(data),moved);
await ready(page,'auto');
await assertHierarchy(page,'Deletion and employee reassignment',moved);
check(await page.evaluate(()=>document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('[data-employee-id="many-0"]').closest('.node').dataset.organizationId)==='finance','Membership change moves the employee card');
const imageData=structuredClone(expandedFixture);
imageData.employees.find(e=>e.id==='expanded-employee-01').profileImage='/packages/org-chart/src/assets/workdesk/bg_sky.png';
await page.evaluate(async data=>window.handle.update(data),imageData);
await ready(page,'auto');
check(await page.locator('[data-employee-id="expanded-employee-01"]').getAttribute('data-photo-state')==='image','Valid profile photo uses Employee data');
const hidden=structuredClone(expandedFixture);hidden.presentation.groups.showEmployees=false;
await page.evaluate(async data=>window.handle.update(data),hidden);
await ready(page,'auto');
await assertHierarchy(page,'Show employees false',hidden);
for(const [kind,code] of [['orphan','PARENT_REFERENCE'],['cycle','HIERARCHY_CYCLE']]) {
const bad=structuredClone(expandedFixture);
if(kind==='orphan')bad.organizations[1].parentId='missing';else bad.organizations[0].parentId='business-management';
const state=await page.evaluate(async data=>{await window.handle.update(data);return window.handle.getState();},bad);
check(state.status==='error' && state.issues.some(i=>i.code===code),`Invalid ${kind} reports existing validation policy`);
check(await page.locator('.node[data-organization-id]').count()===0,`Invalid ${kind} clears stale tree without silent omission`);
}
await page.evaluate(async()=>window.handle.reload());
await ready(page,'auto');
await assertHierarchy(page,'Reload restores source',expandedFixture);
await page.evaluate(()=>window.handle.destroy());
check(await page.locator('.hm-orgchart-host').count()===0,'Destroy removes Auto DOM and observers');
} finally {await page.close();}
});
await scenario('Actual React Storybook',async()=>{
const page=await newPage('storybook-auto',{width:1920,height:1080});
try {
const response=await page.request.get(`${storyOrigin}/index.json`);const index=await response.json();
const visible=Object.values(index.entries).filter(e=>e.type==='story'&&e.title==='OrgChart'&&!e.tags?.includes('internal')).map(e=>e.id).sort();
check(JSON.stringify(visible)===JSON.stringify(['orgchart--logo-personnel-summary','orgchart--department-colors','orgchart--full-composition','orgchart--expanded-org-chart'].sort()),'Storybook exactly four user menus',visible);
await page.goto(`${storyOrigin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);await ready(page,'auto');
const react=await assertHierarchy(page,'React 1920',expandedFixture);
const signature=value=>value.cards.map(c=>({id:c.id,kind:c.kind,width:c.box.width,height:c.box.height,name:c.name,role:c.role,font:c.font,asset:c.asset}));
check(JSON.stringify(signature(react.value))===JSON.stringify(signature(vanilla.value)),'HTML/React identical Core card content and geometry');
await page.screenshot({path:path.join(output,'storybook-expanded-1920.png'),fullPage:true});
for(const preset of presets) {
await page.getByRole('combobox',{name:'회사별 컬러 테마'}).click();await page.getByRole('option',{name:preset.name,exact:true}).click();
await page.waitForFunction(id=>document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.node')?.dataset.companyId===id,preset.id);
await ready(page,'auto');const value=await measure(page);
check(value.nodes.every(n=>n.companyId===preset.id && n.primary===preset.primary && n.backgroundColor===rgb(preset.primary) && JSON.stringify(n.secondary)===JSON.stringify(preset.secondary)),`${preset.name} Main/all secondary preset variables`);
const style=await page.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot;return [...s.querySelectorAll('[data-card-presentation="expanded"]')].map(c=>getComputedStyle(c).borderTopColor);});
check(style.every(c=>c===rgb(preset.primary)),`${preset.name} employee accent matches preset`);
}
await page.getByRole('combobox',{name:'회사별 컬러 테마'}).click();await page.getByRole('option',{name:'한맥기술',exact:true}).click();await ready(page,'auto');
for(const width of [390,768,1280,1440]) {
await page.setViewportSize({width,height:width<1000?1024:900});await ready(page,'auto');
const checked=await assertHierarchy(page,`React ${width}`,expandedFixture);
const scrolled=await page.evaluate(()=>{const v=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-viewport');v.scrollLeft=v.scrollWidth;const x=v.scrollLeft;v.scrollLeft=0;return x;});
check(checked.layout.nodes[0].subtree.width <= checked.value.viewport.clientWidth || scrolled>0,`React ${width} wide tree accessible with horizontal scroll`);
await page.screenshot({path:path.join(output,`storybook-expanded-${width}.png`),fullPage:true});
}
await popup(page,'React nested card','expanded-team-leader-01');
await page.goto(`${storyOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story`);await ready(page,'fixed');
const fixed=await measure(page);
check(fixed.layoutStatus===null && fixed.controls===0 && fixed.cards.some(c=>c.asset),'Existing Full Composition Story keeps Fixed path and assets');
} finally {await page.close();}
});
}
} finally {
await browser.close();await new Promise(resolve=>server.close(resolve));
check(errors.length===0,'No new console/runtime errors',errors);
if(process.env.ORGCHART_VERIFY_RETRY === 'overflow') {
const previous=JSON.parse(await fs.readFile(path.join(output,'browser.json'),'utf8'));
const updates=new Map(results.map(result=>[result.name,result]));
const combined=previous.results.map(result=>updates.get(result.name)??result);
results.splice(0,results.length,...combined,...results.filter(result=>!previous.results.some(old=>old.name===result.name)));
measurements.unshift(...previous.measurements);
}
const summary={total:results.length,pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length};
await fs.writeFile(path.join(output,'browser.json'),JSON.stringify({scope:'P5-1B focused validation',summary,results,measurements,errors,warnings,notVerified:['Two reference SVGs and final design match','P5-2 collapse/zoom/pan','Historical full regression suite','Actual screen reader speech/touch/other browsers']},null,2));
console.log(JSON.stringify(summary));
console.log(JSON.stringify(results.filter(r=>r.status==='FAIL'),null,2));
if(summary.fail)process.exitCode=1;
}