102 lines
10 KiB
JavaScript
102 lines
10 KiB
JavaScript
// 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:'<!doctype html><html lang="ko"><meta charset="utf-8"><title>Core close verification</title><body><main id="fixture" style="height:850px"></main></body></html>'}));
|
|
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;
|