Files
HMDesign/examples/org-chart/verify-p9-5-popup-parity.mjs
T
shkim4 f4ddd5ac4b
Deploy Storybook / deploy (push) Successful in 59s
docs(storybook): make Expanded OrgChart a preview-only page
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).
2026-10-22 17:51:21 +09:00

87 lines
10 KiB
JavaScript

// Verify actual consumers and OPEN popup ownership, not just the presence of DOM.
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const output=path.resolve('test-results/org-chart-p9-5-popup-parity');
const story=(process.env.ORGCHART_STORY_ORIGIN??'http://127.0.0.1:6064').replace(/\/$/,'');
const vanilla=(process.env.ORGCHART_VANILLA_ORIGIN??'http://127.0.0.1:4178').replace(/\/$/,'');
const checks=[],measurements=[],errors=[],startedAt=new Date().toISOString();
const check=(pass,name,detail)=>{checks.push({name,status:pass?'PASS':'FAIL',detail});if(!pass)console.log('FAIL',name,JSON.stringify(detail));};
const browser=await launchChromium({headless:true}),page=await browser.newPage({viewport:{width:1440,height:900}});
page.setDefaultTimeout(15000);
page.on('pageerror',error=>errors.push(error.message));page.on('console',msg=>{if(msg.type()==='error')errors.push(msg.text());});
const settle=()=>page.evaluate(async()=>{await document.fonts.ready;for(let i=0;i<12;i++)await new Promise(requestAnimationFrame);});
const coreOpen='dialog.hm-orgchart-employee-dialog[open]',hmOpen='[data-component="HMOrgChartEmployeeDialog"][data-state="open"]';
const refClose='[data-slot="dialog-content"] button[data-variant="ghost"]';
async function snapshot(selector){return page.locator(selector).evaluate(button=>{
const rect=el=>{const r=el.getBoundingClientRect();return{x:r.x,y:r.y,width:r.width,height:r.height};};
const c=getComputedStyle(button),box=rect(button),popup=button.closest('dialog,[data-slot="dialog-content"]'),pbox=rect(popup),svg=button.querySelector('svg');
return{box,popup:pbox,inset:{top:box.y-pbox.y,right:pbox.x+pbox.width-box.x-box.width},focusVisible:button.matches(':focus-visible'),
css:Object.fromEntries(['width','height','padding','border','borderRadius','color','backgroundColor','outlineStyle','outlineOffset','transition','cursor','position','top','right','boxSizing','backgroundClip','boxShadow'].map(k=>[k,c[k]])),
svg:svg&&{box:rect(svg),attrs:Object.fromEntries(['viewBox','fill','stroke','stroke-width','stroke-linecap','stroke-linejoin','aria-hidden'].map(k=>[k,svg.getAttribute(k)])),paths:[...svg.querySelectorAll('path')].map(p=>p.getAttribute('d'))},
popupType:popup.matches('dialog')?'Core native':'React HMDesign Dialog',employeeId:popup.dataset.employeeId,
content:popup.querySelector('dl')?.textContent,closeName:button.getAttribute('aria-label')||button.textContent.trim(),root:button.getRootNode() instanceof ShadowRoot?'ShadowRoot':'Document'
};
});}
await page.goto(`${story}/iframe.html?id=components-overlay-dialog--all&viewMode=story`);await page.getByRole('button',{name:'기본',exact:true}).click();await page.locator(refClose).waitFor();await settle();
await page.locator(refClose).evaluate(el=>el.blur());await page.mouse.move(0,0);const reference={normal:await snapshot(refClose)};
await page.locator(refClose).hover();await settle();reference.hover=await snapshot(refClose);
await page.mouse.move(0,0);await page.keyboard.press('Tab');await page.locator(refClose).focus();await settle();reference.focus=await snapshot(refClose);
await page.locator('[data-slot="dialog-content"]').screenshot({path:path.join(output,'hm-dialog-reference-popup.png')});
measurements.push({name:'HM Dialog actual public component',...reference});
// Expanded is preview-only (2026-10-22): no popup selector, so its Core case was removed; Core stays covered by Full/Vanilla.
const cases=[
{name:'storybook-full',url:`${story}/iframe.html?id=orgchart--full-composition&viewMode=story`,kind:'Core native'},
{name:'storybook-expanded-hm',url:`${story}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`,kind:'React HMDesign Dialog'},
{name:'vanilla-index',url:`${vanilla}/index.html`,kind:'Core native',vanilla:true},
{name:'vanilla-full',url:`${vanilla}/p1.html?mode=readOnly`,kind:'Core native',vanilla:true},
{name:'vanilla-expanded',url:`${vanilla}/expanded.html`,kind:'Core native',vanilla:true},
];
const near=(a,b)=>Math.abs(a-b)<.02,eq=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
for(const width of [1440,390])for(const item of cases){
const label=`${item.name} ${width}`;await page.setViewportSize({width,height:900});await page.goto(item.url);
await page.waitForFunction(()=>{const root=document.querySelector('.hm-orgchart-host')?.shadowRoot;return root?.querySelector('.hm-orgchart-employee[role="button"]')&&[...root.querySelectorAll('link[rel="stylesheet"]')].every(link=>link.sheet);});await settle();
// Use a rendered card inside the horizontally clipped viewport; the Forest
// may legitimately extend beyond the viewport without shrinking its cards.
const choice=await page.evaluate(()=>{
const root=document.querySelector('.hm-orgchart-host').shadowRoot,v=root.querySelector('.hm-orgchart-viewport').getBoundingClientRect();
const cards=[...root.querySelectorAll('.hm-orgchart-employee[role="button"]')];
const candidates=cards.filter(el=>{const r=el.getBoundingClientRect();return r.width>0&&r.height>0&&r.left>=Math.max(0,v.left)&&r.right<=Math.min(innerWidth,v.right);});
const card=(candidates.length?candidates:cards).sort((a,b)=>Math.abs(a.getBoundingClientRect().left-innerWidth/2)-Math.abs(b.getBoundingClientRect().left-innerWidth/2))[0];
return{employeeId:card.dataset.employeeId,aria:card.getAttribute('aria-label')};
});
const trigger=page.locator(`.hm-orgchart-employee[data-employee-id="${choice.employeeId}"]`).first();
await trigger.click();const popupSelector=item.kind==='Core native'?coreOpen:hmOpen;await page.locator(popupSelector).waitFor();await settle();
const closeSelector=item.kind==='Core native'?`${coreOpen} .hm-orgchart-employee-dialog-close`:`${hmOpen} button[data-variant="ghost"]`;
const ownership=await page.evaluate(()=>({core:[...document.querySelectorAll('.hm-orgchart-host')].reduce((n,h)=>n+h.shadowRoot.querySelectorAll('dialog.hm-orgchart-employee-dialog[open]').length,0),hm:document.querySelectorAll('[data-component="HMOrgChartEmployeeDialog"][data-state="open"]').length}));
check(ownership.core===(item.kind==='Core native'?1:0)&&ownership.hm===(item.kind==='Core native'?0:1),`${label}: actual open popup owner matches`,ownership);
await page.locator(closeSelector).evaluate(el=>el.blur());await page.mouse.move(0,0);await settle();const normal=await snapshot(closeSelector);
const keys=['width','height','padding','border','borderRadius','color','backgroundColor','outlineStyle','outlineOffset','transition','cursor','position','top','right','boxSizing','backgroundClip'];
check(keys.every(k=>normal.css[k]===reference.normal.css[k]),`${label}: HM normal X button CSS matches`,normal.css);
check(near(normal.box.width,28)&&near(normal.box.height,28)&&near(normal.svg?.box.width,16)&&near(normal.svg?.box.height,16)&&eq(normal.inset,reference.normal.inset)&&eq(normal.svg?.attrs,reference.normal.svg.attrs)&&eq(normal.svg?.paths,reference.normal.svg.paths),`${label}: HM X shape, stroke, size and inset match`,normal);
await page.locator(popupSelector).screenshot({path:path.join(output,`${item.name}-${width}-popup.png`)});
await page.screenshot({path:path.join(output,`${item.name}-${width}-context.png`)});
await page.locator(closeSelector).hover();await settle();const hover=await snapshot(closeSelector);
check(hover.css.backgroundColor===reference.hover.css.backgroundColor&&hover.css.color===reference.hover.css.color,`${label}: HM hover matches`,hover.css);
await page.mouse.move(0,0);await page.keyboard.press('Tab');await page.locator(closeSelector).focus();await settle();const focus=await snapshot(closeSelector);
check(focus.focusVisible&&focus.css.border===reference.focus.css.border&&focus.css.outlineStyle==='none'&&focus.css.boxShadow.includes('oklab(0.462622 -0.0726663 0.0150061 / 0.5) 0px 0px 0px 3px'),`${label}: HM focus ring matches`,focus.css);
// Compare the actual policies: Core retains the open dialog on backdrop;
// Radix HM Dialog closes it. Closing must restore the original shadow card.
const expectedOpen=item.kind==='Core native';await page.mouse.click(2,2);await settle();
check((await page.locator(popupSelector).count()>0)===expectedOpen,`${label}: existing outside-click policy retained`,{expected:expectedOpen?'stay open':'close'});
if(!expectedOpen){check(await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: outside-close restores employee focus`);await trigger.click();await page.locator(popupSelector).waitFor();await settle();}
await page.locator(closeSelector).click();await settle();
check(await page.locator(popupSelector).count()===0&&await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: X closes and returns focus`);
await trigger.focus();await page.keyboard.press('Enter');await page.locator(popupSelector).waitFor();await settle();
const reopened=await snapshot(closeSelector);check(reopened.employeeId===normal.employeeId&&reopened.content===normal.content,`${label}: reopen keeps selected employee/body data`);
await page.keyboard.press('Escape');await settle();check(await page.locator(popupSelector).count()===0&&await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: ESC closes and returns focus`);
if(item.vanilla)check(await page.evaluate(()=>!window.React&&[...document.querySelectorAll('script[src]')].every(s=>!/(?:react|storybook)/i.test(s.src))),`${label}: actual example runs without React`);
measurements.push({name:label,url:item.url,expectedOwner:item.kind,ownership,choice,normal,hover,focus});
}
const copied=[];for(const file of ['index.js','style.css']){const hash=async location=>createHash('sha256').update(await fs.readFile(location)).digest('hex');copied.push({file,core:await hash(`packages/org-chart/dist/${file}`),vanilla:await hash(`examples/org-chart/dist/assets/${file}`)});}
check(copied.every(item=>item.core===item.vanilla),'Actual Vanilla example JS/CSS are byte-identical to modified Core build',copied);
check(errors.length===0,'New console/page errors: zero',errors);
const result={startedAt,completedAt:new Date().toISOString(),browser:browser.version(),checks,measurements,copied,errors,pass:checks.filter(c=>c.status==='PASS').length,fail:checks.filter(c=>c.status==='FAIL').length};
await fs.writeFile(path.join(output,'result.json'),JSON.stringify(result,null,2));await browser.close();console.log(JSON.stringify({pass:result.pass,fail:result.fail,output}));if(result.fail)process.exitCode=1;