// Focused comparison with the real HM Dialog; no P9.4/P9.5 layout regression replay. import fs from 'node:fs/promises'; import path from 'node:path'; import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; const output = path.resolve('test-results/org-chart-p9-5-core-dialog-close'); const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, ''); const oldCss = await fs.readFile(path.join(output, 'before-employee-info-dialog.css'), 'utf8'); const data = JSON.parse(await fs.readFile('examples/org-chart/data-p1.json', 'utf8')); 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 }); const page = await browser.newPage({ viewport:{ width:1440,height:900 } }); page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 12; i++) await new Promise(requestAnimationFrame); }); const coreClose = '.hm-orgchart-employee-dialog-close', coreDialog = '.hm-orgchart-employee-dialog'; const refClose = '[data-slot="dialog-content"] button[data-variant="ghost"]'; async function measure(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 css=getComputedStyle(button), parent=button.closest('dialog,[data-slot="dialog-content"]'), box=rect(button), pbox=rect(parent), svg=button.querySelector('svg'); const fields=parent.querySelector('dl'), title=parent.querySelector('h2'); return { box,dialog:pbox,inset:{ top:box.y-pbox.y,right:pbox.x+pbox.width-box.x-box.width }, css:Object.fromEntries(['padding','border','borderRadius','color','backgroundColor','outlineStyle','outlineOffset','boxShadow','transition','cursor','fontSize','lineHeight','position','top','right','boxSizing','backgroundClip','userSelect'].map(k=>[k,css[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')) }, title:title && { box:rect(title),text:title.textContent },fields:fields && { box:rect(fields),html:fields.innerHTML }, container:Object.fromEntries(['padding','backgroundColor','color','border','borderRadius','fontFamily','fontSize','lineHeight','maxHeight'].map(k=>[k,getComputedStyle(parent)[k]])), accessibleName:button.getAttribute('aria-label'),focusVisible:button.matches(':focus-visible'),open:parent.open }; }); } const equivalent=(a,b)=>JSON.stringify(a)===JSON.stringify(b); const rectNear=(a,b)=>Object.keys(a).every(k=>Math.abs(a[k]-b[k])<.02); await page.goto(`${origin}/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 measure(refClose) }; await page.locator(refClose).hover(); await settle(); reference.hover=await measure(refClose); await page.mouse.move(0,0); await page.keyboard.press('Tab'); await page.locator(refClose).focus(); await settle(); reference.focus=await measure(refClose); check(reference.focus.focusVisible, 'Actual HM Dialog keyboard focus reference captured', reference.focus.css); await page.locator('[data-slot="dialog-content"]').screenshot({path:path.join(output,'hm-reference.png')}); measurements.push({name:'actual HM Dialog',...reference}); for (const mode of ['React Story / Core popup','Vanilla / no React']) { for (const width of [1440,390]) { await page.setViewportSize({width,height:900}); if (mode.startsWith('React')) { await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); } else { await page.route('**/p95-native-dialog-fixture', route=>route.fulfill({contentType:'text/html',body:'Core close verification
'})); await page.goto(`${origin}/p95-native-dialog-fixture`); await page.evaluate(async data=>{ const api=await import('/core/index.js'); window.chart=api.createOrgChart({container:document.querySelector('#fixture'),dataSource:new api.JsonOrgChartDataSource(data),displayMode:'readOnly',stylesheetUrls:{engine:'/core/engine.css',core:'/core/style.css'}}); await window.chart.mount(); },data); } await page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee').waitFor(); await page.waitForFunction(()=>[...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link=>link.sheet)); await settle(); const trigger=page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee'); const label=`${mode} ${width}`; await trigger.click(); await page.locator(`${coreDialog}[open]`).waitFor(); await settle(); // Reapply the captured pre-change stylesheet/markup on this same popup to // compare body geometry at each width/runtime without a second application. await page.locator(coreClose).evaluate((button,css)=>{ window.savedCloseNodes=[...button.childNodes]; button.textContent='닫기'; window.oldCloseStyle=document.createElement('style'); window.oldCloseStyle.textContent=`${css}\n.hm-orgchart-employee-dialog-close { all: revert; padding:var(--hm-orgchart-resolved-spacing-xs) var(--hm-orgchart-resolved-spacing-sm);border:var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-default);border-radius:var(--hm-orgchart-resolved-radius-control-md);background:var(--hm-orgchart-resolved-bg-content);color:inherit;font:inherit;cursor:pointer;box-sizing:border-box; } .hm-orgchart-employee-dialog-header { min-height:0; }`; button.getRootNode().append(window.oldCloseStyle); },oldCss); const before=await measure(coreClose); await page.locator(coreClose).evaluate(button=>{button.replaceChildren(...window.savedCloseNodes);window.oldCloseStyle.remove();}); await page.locator(coreClose).evaluate(el=>el.blur()); await page.mouse.move(0,0); await settle(); const normal=await measure(coreClose); const compareKeys=['padding','border','borderRadius','color','backgroundColor','outlineStyle','outlineOffset','transition','cursor','fontSize','lineHeight','position','top','right','boxSizing','backgroundClip','userSelect']; check(compareKeys.every(k=>normal.css[k]===reference.normal.css[k]),`${label}: HM normal button styles match`,{actual:normal.css,reference:reference.normal.css}); check(normal.box.width===reference.normal.box.width && normal.box.height===reference.normal.box.height && equivalent(normal.inset,reference.normal.inset),`${label}: 28px button and top/right inset match`,{box:normal.box,inset:normal.inset}); check(normal.svg.box.width===16 && normal.svg.box.height===16 && equivalent(normal.svg.attrs,reference.normal.svg.attrs) && equivalent(normal.svg.paths,reference.normal.svg.paths),`${label}: actual Lucide X geometry/stroke matches`,normal.svg); check(rectNear(before.dialog,normal.dialog) && rectNear(before.fields.box,normal.fields.box) && rectNear(before.title.box,normal.title.box) && equivalent(before.container,normal.container) && before.fields.html===normal.fields.html,`${label}: popup size, title, body geometry, background and data preserved`,{before,after:normal}); check(normal.accessibleName==='직원 정보 닫기',`${label}: accessible close name preserved`); await page.locator(coreClose).hover(); await settle(); const hover=await measure(coreClose); 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(coreClose).focus(); await settle(); const focus=await measure(coreClose); 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 border and 3px 50% ring match`,focus.css); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); check(await page.locator(coreClose).evaluate(el=>el.getRootNode().activeElement===el),`${label}: Tab/Shift+Tab focus trap retained`); await page.mouse.click(5,5); await settle(); check(await page.locator(`${coreDialog}[open]`).count()===1,`${label}: existing outside-click stays open policy retained`); await page.locator(coreClose).click(); await settle(); check(await page.locator(`${coreDialog}[open]`).count()===0 && await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: X click closes and restores employee focus`); await trigger.click(); await page.keyboard.press('Escape'); await settle(); check(await page.locator(`${coreDialog}[open]`).count()===0 && await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: ESC closes and restores employee focus`); await trigger.focus(); await page.keyboard.press('Enter'); await settle(); await page.keyboard.press('Tab'); const screen=mode.startsWith('React')?'react':'vanilla'; await page.locator(coreDialog).screenshot({path:path.join(output,`${screen}-${width}-popup.png`)}); if (mode.startsWith('Vanilla')) check(await page.evaluate(()=>!window.React && ![...document.querySelectorAll('script')].some(el=>/react/i.test(el.src))),`${label}: native Core mounting has no React runtime`); measurements.push({name:label,before,normal,hover,focus}); } } check(errors.length===0,'No new console/page errors',errors); const result={startedAt,completedAt:new Date().toISOString(),origin,browser:browser.version(),checks,measurements,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;