88 lines
11 KiB
JavaScript
88 lines
11 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});
|
|
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:'storybook-expanded-core',url:`${story}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`,kind:'Core native',coreOption:true},
|
|
{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();
|
|
if(item.coreOption){await page.getByRole('combobox',{name:'직원 정보 팝업',exact:true}).click();await page.getByRole('option',{name:'Core 기본 팝업',exact:true}).click();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;
|