import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; import fs from 'node:fs/promises'; import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-p5-23'); await loadPlaywright(); const story = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6054'; const html = process.env.ORGCHART_HTML_ORIGIN ?? 'http://127.0.0.1:4189'; const fixtures = Object.fromEntries(await Promise.all(['small', 'medium', 'large'].map(async size => [size, JSON.parse(await fs.readFile(path.join(root, `examples/org-chart/data-expanded-${size}.json`), 'utf8'))]))); const fixed = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); const results = [], errors = [], timings = [], measurements = []; const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; const mime = { '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf', '.json': 'application/json', '.png': 'image/png' }; const server = http.createServer(async (request, response) => { try { const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); if (pathname === '/shell') return response.writeHead(200, { 'Content-Type': 'text/html' }).end('
'); const file = path.resolve(root, '.' + pathname); if (path.relative(root, file).startsWith('..')) return response.writeHead(403).end(); response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }).end(await fs.readFile(file)); } 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}`; await fs.mkdir(output, { recursive: true }); const browser = await launchChromium({ headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); 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; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(resolve)))); }); async function ready(count) { await page.waitForFunction(n => { const s = document.querySelector('.hm-orgchart-host')?.shadowRoot; return s?.querySelectorAll('.hm-orgchart-employee').length === n && s.querySelectorAll('link').length > 0 && [...s.querySelectorAll('link')].every(link => link.sheet); }, count); await settle(); } async function mount(data, options = {}, baseline = false) { await page.goto(origin + '/shell'); const ms = await page.evaluate(async ({ origin, data, options, baseline }) => { const start = performance.now(); const base = origin + (baseline ? '/test-results/org-chart-p5-23/baseline-dist' : '/packages/org-chart/dist'); const mod = await import(base + '/index.js'); window.handle = mod.createOrgChart({ container: document.getElementById('chart'), dataSource: new mod.JsonOrgChartDataSource(data), stylesheetUrls: { engine: base + '/engine.css', core: base + '/style.css' }, ...options }); await window.handle.mount(); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); return performance.now() - start; }, { origin, data, options, baseline }); await settle(); return ms; } async function measure() { return page.evaluate(() => { const s = document.querySelector('.hm-orgchart-host').shadowRoot; const rect = el => { const r = el.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 nodes = [...s.querySelectorAll('.node[data-organization-id]')].map(n => ({ id:n.dataset.organizationId, parentId:n.dataset.parentId, company:n.dataset.companyId, depth:+n.dataset.depth, rect:rect(n), article:rect(n.querySelector('article')), subtree:rect(n.parentElement), domParent:n.parentElement.parentElement.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId ?? '', color:getComputedStyle(n.querySelector('h3')).backgroundColor, ink:getComputedStyle(n.querySelector('h3')).color })); const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => { const css = getComputedStyle(c), svg = c.closest('article')?.querySelector('svg.hm-orgchart-employee-connectors'); const line = svg && [...svg.querySelectorAll('path')].find(p => p.dataset.employeeId === c.dataset.employeeId); const direction = c.dataset.connectionDirection, box = rect(c), scale = +s.querySelector('.hm-orgchart-viewport').dataset.scale || 1; const end = line ? { x:svg.getBoundingClientRect().x + +line.dataset.endX * scale, y:svg.getBoundingClientRect().y + +line.dataset.endY * scale } : null; const target = direction==='left' ? {x:box.x,y:box.y+box.height/2} : direction==='right' ? {x:box.right,y:box.y+box.height/2} : {x:box.cx,y:box.y}; return { id:c.dataset.employeeId, kind:c.dataset.cardKind, direction, company:c.dataset.companyId, box, border:[css.borderTopColor,css.borderRightColor,css.borderBottomColor,css.borderLeftColor], shadow:css.boxShadow, name:c.querySelector('.hm-orgchart-person-name')?.textContent, role:c.querySelector('.hm-orgchart-person-role')?.textContent, asset:c.dataset.cardAsset, endpointError:end?Math.hypot(end.x-target.x,end.y-target.y):null }; }); const collisions = []; for (let i=0;i0.5 && Math.min(a.box.bottom,b.box.bottom)-Math.max(a.box.y,b.box.y)>0.5) collisions.push([a.id,b.id]); } const viewport = s.querySelector('.hm-orgchart-viewport'), forest=s.querySelector('.hm-orgchart-forest'); return { nodes, cards, collisions, scale:+viewport.dataset.scale, forest:rect(forest), viewport:rect(viewport), viewportState:{x:+viewport.dataset.panX,y:+viewport.dataset.panY}, doc:{width:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth,height:document.documentElement.clientHeight,scrollHeight:document.documentElement.scrollHeight}, controls:s.querySelectorAll('.hm-orgchart-expanded-toolbar button').length }; }); } async function hierarchy(name, data) { const m = await measure(); measurements.push({ name, value:m }); check(m.nodes.length===data.organizations.length, name+' all organizations'); check(m.cards.length===data.employees.length && new Set(m.cards.map(c=>c.id)).size===data.employees.length, name+' unique employees exactly once'); check(m.collisions.length===0,name+' no card overlap',m.collisions); check(m.nodes.every(n=>n.parentId===n.domParent),name+' DOM ancestry matches source'); check(m.nodes.every(n=>!n.parentId || n.rect.y > m.nodes.find(p=>p.id===n.parentId).article.bottom),name+' children below own parent'); check(m.nodes.every(n=>m.nodes.filter(p=>p.depth===n.depth).every(p=>Math.abs(p.rect.y-n.rect.y)<1)),name+' same depth horizontal alignment'); check(m.cards.every(c=>c.endpointError!==null && c.endpointError<1),name+' actual connector endpoints match cards',m.cards.filter(c=>c.endpointError===null||c.endpointError>=1)); check(m.cards.every(c=>c.border.filter((color,index)=>index!==({top:0,right:1,left:3}[c.direction])).every(color=>color===c.border[2]) && c.border[{top:0,right:1,left:3}[c.direction]]!==c.border[2]),name+' only incoming border side accented'); check(m.doc.scrollWidth<=m.doc.width+1,name+' no document horizontal overflow'); return m; } async function popup(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 detail = await page.evaluate(()=> {const s=document.querySelector('.hm-orgchart-host').shadowRoot,d=s.querySelector('dialog');return {focused:d.contains(s.activeElement),transformed:!!d.closest('.hm-orgchart-forest'),text:d.textContent};}); check(detail.focused && !detail.transformed,'Popup focus inside; outside transformed forest',detail); await page.keyboard.press('Escape'); check(await page.evaluate(id=>document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId===id,id),'Popup Escape restores card focus'); } async function timedToggle(id, key='Enter') { const button=page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`); await button.focus(); await page.evaluate(()=>{window.toggleStart=performance.now();}); await button.press(key); await settle(); const ms=await page.evaluate(()=>performance.now()-window.toggleStart); timings.push({action:'toggle '+id,ms}); return ms; } try { if (!process.env.ORGCHART_VERIFY_ONLY) { await mount(fixed,{displayMode:'readOnly'},true); const before=await measure(); await mount(fixed,{displayMode:'readOnly'}); const after=await measure(); const fixedSig=m=>m.cards.map(c=>({id:c.id,kind:c.kind,asset:c.asset,name:c.name,role:c.role,box:c.box,border:c.border})); check(JSON.stringify(fixedSig(before))===JSON.stringify(fixedSig(after)),'Fixed baseline/current exact card geometry, SVG, typography, color'); check(after.controls===0,'Fixed no Auto controls'); await popup(after.cards[0].id); await page.screenshot({path:path.join(output,'fixed-1440.png'),fullPage:true}); for(const [size,data] of Object.entries(fixtures)) { const ms=await mount(data,{layoutMode:'auto',displayMode:'readOnly',employeeConnections:Object.fromEntries(data.organizations.map((org,index)=>[org.id,['top','left','right'][index%3]]))}); timings.push({action:`${size} initial mount (module/styles + 2 animation frames)`,ms}); await ready(data.employees.length); await hierarchy('HTML '+size,data); await page.screenshot({path:path.join(output,`html-${size}-fit.png`),fullPage:true}); } const data=fixtures.large; await timedToggle('hanmac-office-0'); check(await page.locator('[data-organization-id="hanmac-office-0"].hm-orgchart-expanded-toggle').getAttribute('aria-expanded')==='false','Enter collapses group'); await timedToggle('hanmac-division-0','Space'); await timedToggle('hanmac-division-0','Space'); check(await page.locator('[data-organization-id="hanmac-office-0"].hm-orgchart-expanded-toggle').getAttribute('aria-expanded')==='false','Parent re-open preserves child collapse'); check(await page.evaluate(()=>document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.organizationId==='hanmac-division-0'),'Toggle preserves initiating focus'); await timedToggle('hanmac-office-0'); await ready(300); const selectMs=await page.evaluate(async()=>{const t=performance.now();await window.handle.setSelectedCompanies(['hanmac']);await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));return performance.now()-t;});timings.push({action:'300 single company selection + layout',ms:selectMs}); let m=await measure();check(new Set(m.nodes.map(n=>n.company)).size===1 && m.nodes.every(n=>n.company==='hanmac'),'Single company independent root'); check(await page.evaluate(()=>window.handle.getState().summary.raw.total)===300,'Company filter preserves immutable source total'); await page.evaluate(async()=>window.handle.setSelectedCompanies(['hanmac','ptc']));await settle();m=await measure();check(new Set(m.nodes.map(n=>n.company)).size===2,'Multiple companies without synthetic root'); await page.evaluate(async()=>window.handle.setSelectedCompanies([]));await settle();check(await page.locator('.hm-orgchart-expanded-empty').count()===1 && await page.locator('.node[data-organization-id]').count()===0,'Explicit empty selection'); await page.evaluate(async()=>window.handle.setSelectedCompanies());await ready(300); await page.getByRole('button',{name:'100% 복원',exact:true}).click();let zoom=await measure();check(zoom.scale===1,'100% resets scale'); await page.getByRole('button',{name:'확대',exact:true}).click();check((await measure()).scale>1,'Zoom in button'); await page.getByRole('button',{name:'축소',exact:true}).click();check(Math.abs((await measure()).scale-1)<0.0001,'Zoom out button'); const area=page.locator('.hm-orgchart-viewport'),box=await area.boundingBox(); await page.mouse.move(box.x+box.width-50,box.y+50);const state=(await measure()).viewportState; await page.mouse.down();await page.mouse.move(box.x+box.width-150,box.y+120,{steps:8});await page.mouse.up(); const moved=(await measure()).viewportState;check(moved.x!==state.x&&moved.y!==state.y,'Mouse drag pans forest');check(await page.locator('dialog[open]').count()===0,'Drag does not open popup'); await page.evaluate(()=> {window.handle.zoomTo(999);});check((await measure()).scale===3,'Zoom clamps maximum'); await page.evaluate(()=> {window.handle.zoomTo(0);});check((await measure()).scale===0.05,'Zoom clamps minimum'); await page.getByRole('button',{name:'Fit to View',exact:true}).click();zoom=await measure();check(zoom.forest.width<=zoom.viewport.width+1&&zoom.forest.height<=zoom.viewport.height+1,'Fit contains current forest'); await popup('virtual-001'); const beforeWheel=(await measure()).scale; await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.wheel(0,-150);await settle();check((await measure()).scale===beforeWheel,'Ordinary wheel does not zoom or consume scroll'); await page.keyboard.down('Control');await page.mouse.wheel(0,-150);await page.keyboard.up('Control');await settle();check((await measure()).scale>beforeWheel,'Ctrl wheel zooms'); await mount(data,{layoutMode:'auto',companyTopNodes:{hanmac:{title:'최고기술책임자',name:'가상리더',organizationName:'가상 기술회사'},saman:{display:'titleOnly'},halla:{display:'hidden'},baron:{employeeId:null}}}); check(await page.locator('[data-employee-id="virtual-001"] .hm-orgchart-person-role').textContent()==='최고기술책임자','Custom top title uses data/presentation, not representative constant'); check(await page.locator('.hm-orgchart-top-node-title').count()===1,'Top titleOnly mode'); check(await page.locator('.node[data-organization-id="halla-root"] .hm-orgchart-employee').count()===0,'Hidden top card mode'); check(await page.locator('.node[data-organization-id="baron-root"]').count()===1,'No top employee retains original organization root'); } if (process.env.ORGCHART_VERIFY_ONLY !== 'extras') { const data=fixtures.large; await page.goto(html+'/expanded.html');await ready(20);await page.selectOption('#dataset','large');await ready(300);const htmlValue=await hierarchy('Real HTML large',data); await page.goto(story+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');await ready(20); await page.getByRole('combobox',{name:'가상 데이터'}).click();await page.getByRole('option',{name:'Large · 300명',exact:true}).click();await ready(300); const react=await hierarchy('React large',data); const signature=m=>m.cards.map(c=>({id:c.id,kind:c.kind,role:c.role,name:c.name,company:c.company,direction:c.direction,border:c.border,width:Math.round(c.box.width/m.scale),height:Math.round(c.box.height/m.scale)})); check(JSON.stringify(signature(htmlValue))===JSON.stringify(signature(react)),'Actual HTML/React shared card geometry/content/color/direction'); const index=await (await page.request.get(story+'/index.json')).json();check(Object.values(index.entries).filter(e=>e.type==='story'&&e.title==='OrgChart'&&!e.tags?.includes('internal')).length===4,'Four existing user Storybook menus retained'); await page.getByRole('button',{name:'선택 해제',exact:true}).click();await page.locator('.hm-orgchart-expanded-empty').waitFor();await page.getByRole('checkbox',{name:'한맥기술',exact:true}).click();await settle();check((await measure()).nodes.every(n=>n.company==='hanmac'),'React single selection'); await page.getByRole('checkbox',{name:'피티씨',exact:true}).click();await settle();check(new Set((await measure()).nodes.map(n=>n.company)).size===2,'React multiple selection'); await page.getByRole('button',{name:'전체 회사',exact:true}).click();await ready(300); await page.screenshot({path:path.join(output,'storybook-large-1440.png'),fullPage:true}); for(const width of [390,768,1280,1440]) { await page.setViewportSize({width,height:1000});await settle();const m=await measure();measurements.push({name:'React viewport '+width,value:m}); check(m.doc.scrollWidth<=m.doc.width+1,'React '+width+' no page overflow',m.doc);check(m.controls===4,'React '+width+' zoom toolbar accessible'); await page.screenshot({path:path.join(output,`storybook-${width}.png`),fullPage:true}); } } if (!process.env.ORGCHART_VERIFY_ONLY || process.env.ORGCHART_VERIFY_ONLY === 'extras') { const data=fixtures.large; const directions=Object.fromEntries(data.organizations.map((org,i)=>[org.id,['top','left','right'][i%3]])); await mount(data,{layoutMode:'auto',employeeConnections:directions,organizationLinks:{'hanmac-root':'#company-detail'}}); const expected={hanmac:'#CB2E28',saman:'#F08300','jangheon-industry':'#4580A6',jangheon:'#1D418D',ptc:'#84338E',halla:'#0068B7',baron:'#0C367F'}; const rgb=hex=>`rgb(${[1,3,5].map(i=>parseInt(hex.slice(i,i+2),16)).join(', ')})`; const colors=await measure(); check(colors.nodes.every(n=>n.color===rgb(expected[n.company])),'All seven group header preset colors exact'); check(colors.cards.every(c=>c.border[{top:0,right:1,left:3}[c.direction]]===rgb(expected[c.company])),'All seven employee border preset colors exact'); check(new Set(colors.cards.map(c=>c.direction)).size===3,'Real top/left/right connector examples present'); const duration=await page.evaluate(async()=>{const t=performance.now();document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]').click();await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));return performance.now()-t;}); timings.push({action:'300 group collapse (native click + 2 frames)',ms:duration}); await page.evaluate(()=>document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]').click());await ready(300); const zoomPan=await page.evaluate(async()=>{const t=performance.now();window.handle.zoomTo(1.2);for(let i=0;i<60;i++)window.handle.panBy(1,-1);await new Promise(r=>requestAnimationFrame(r));return performance.now()-t;}); timings.push({action:'300 zoom + 60 pan updates + frame',ms:zoomPan}); await page.evaluate(()=>window.handle.fitToView());await settle(); const drag=await page.locator('.hm-orgchart-employee[data-employee-id="virtual-001"]').boundingBox(); await page.mouse.move(drag.x+drag.width/2,drag.y+drag.height/2);await page.mouse.down();await page.mouse.move(drag.x+drag.width/2+30,drag.y+drag.height/2+30,{steps:5});await page.mouse.up(); check(await page.locator('dialog[open]').count()===0,'Dragging an employee card suppresses popup click'); const touch=await page.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot,v=s.querySelector('.hm-orgchart-viewport'),before=window.handle.getViewportState();v.setPointerCapture=()=>{};v.hasPointerCapture=()=>false;for(const [type,x,y] of [['pointerdown',50,50],['pointermove',100,100],['pointerup',100,100]])v.dispatchEvent(new PointerEvent(type,{pointerId:42,pointerType:'touch',isPrimary:true,button:0,clientX:x,clientY:y,bubbles:true}));const after=window.handle.getViewportState();return {before,after};}); check(touch.after.x-touch.before.x===50&&touch.after.y-touch.before.y===50,'Touch pointer event route pans (synthetic; real device unverified)',touch); await page.locator('.hm-orgchart-expanded-link').click();check(await page.locator('.node[data-organization-id]').count()===data.organizations.length,'Separate group page link does not collapse'); await page.evaluate(async()=>window.handle.setSelectedCompanies(['hanmac']));await settle(); await timedToggle('hanmac-office-0');await page.evaluate(async()=>window.handle.setSelectedCompanies(['saman']));await settle(); check(await page.locator('.hm-orgchart-expanded-toggle[aria-expanded="false"]').count()===0,'Other company remains independently expanded'); await page.evaluate(async()=>window.handle.setSelectedCompanies(['hanmac']));await settle(); check(await page.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]').getAttribute('aria-expanded')==='false','Company re-selection preserves its collapse state'); const updated=structuredClone(data);updated.organizations.push({id:'hanmac-empty-leaf',companyId:'hanmac',parentId:'hanmac-root',name:'가상 빈 조직',type:'team',leaderId:null,displayOrder:5}); await page.evaluate(async d=>window.handle.update(d),updated);await settle(); check(await page.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-empty-leaf"]').isDisabled(),'Empty group title is disabled'); await page.goto(story+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');await ready(20); await page.getByRole('button',{name:'선택 해제',exact:true}).click();await page.getByRole('checkbox',{name:'한맥기술',exact:true}).click();await settle(); await page.getByRole('button',{name:'100% 복원',exact:true}).click(); await page.screenshot({path:path.join(output,'storybook-small-hanmac-100.png'),fullPage:true}); await page.locator('.hm-orgchart-expanded-toggle').first().focus();await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter');await settle(); check(await page.locator('.hm-orgchart-expanded-toggle').first().getAttribute('aria-expanded')==='false','React group keyboard collapse uses same Core'); await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter');await settle(); await page.getByRole('checkbox',{name:'삼안',exact:true}).click();await settle(); await page.getByRole('combobox',{name:'가상 데이터'}).click();await page.getByRole('option',{name:'Medium · 100명',exact:true}).click();await ready(100); check((await measure()).cards.length===100,'React fixture switch restores all 100 employees after selections'); } } catch(error) {check(false,'Suite completed',{message:error.message,stack:error.stack});} finally { check(errors.length===0,'No new browser console/runtime errors',errors); await browser.close();await new Promise(resolve=>server.close(resolve)); const summary={total:results.length,pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length}; const file=path.join(output,'browser.json'); if(process.env.ORGCHART_VERIFY_ONLY) { const previous=JSON.parse(await fs.readFile(file,'utf8')); const names=new Set(results.map(result=>result.name)); results.unshift(...previous.results.filter(result=>result.name!=='Suite completed' && !names.has(result.name))); timings.unshift(...previous.timings);measurements.unshift(...previous.measurements); summary.total=results.length;summary.pass=results.filter(r=>r.status==='PASS').length;summary.fail=results.filter(r=>r.status==='FAIL').length; } await fs.writeFile(file,JSON.stringify({scope:'P5-2/P5-3 affected paths',summary,results,timings,measurements,errors,notVerified:['Reference SVG design match','Real touch device, screen reader and other browsers','Historical full regression suite']},null,2)); console.log(JSON.stringify({summary,timings}));if(summary.fail)process.exitCode=1; }