import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; // Focused P5-4 checks. Does not replay P5-1B/P5-23 suites or performance timings. import fs from 'node:fs/promises'; import { writeFileSync } from 'node:fs'; 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-4'); await loadPlaywright(); 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 = [], measurements = []; const consoleOnly = process.env.ORGCHART_P54_RESUME === 'console'; const resume = consoleOnly || process.env.ORGCHART_P54_RESUME === 'remaining'; let baseline; if (resume) { const prior = JSON.parse(await fs.readFile(path.join(output, consoleOnly ? 'browser-before-console.json' : 'browser-initial.json'), 'utf8')); results.push(...prior.results.filter(r => r.status === 'PASS')); measurements.push(...prior.measurements); baseline = prior.measurements.find(m => m.name === 'small geometry').value; } const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); writeFileSync(path.join(output,'progress.json'),JSON.stringify({results},null,2)); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf', '.json': 'application/json', '.svg': 'image/svg+xml' }; const server = http.createServer(async (request, response) => { try { const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); if (pathname === '/favicon.ico') return response.writeHead(204).end(); if (pathname === '/shell') return response.writeHead(200, { 'Content-Type': 'text/html' }).end('
'); if (pathname === '/virtual-profile.svg') return response.writeHead(200, { 'Content-Type': 'image/svg+xml' }).end(''); const file = path.resolve(root, pathname.startsWith('/assets/') ? 'test-results/org-chart-p5-4/react-build/dist'+pathname : '.' + pathname), relative = path.relative(root, file); if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); const data = await fs.readFile(file); response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }).end(data); } catch { response.writeHead(404).end(); } }); await fs.mkdir(output, { recursive: true }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const origin = `http://127.0.0.1:${server.address().port}`; const browser = await launchChromium({ headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); const track = p => { p.on('pageerror', e => errors.push(e.message)); p.on('console', m => { if (m.type() === 'error') errors.push({message:m.text(),location:m.location()}); }); }; track(page); const settle = p => p.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(resolve)))); }); const shadow = '.hm-orgchart-host'; async function ready(p, count) { await p.waitForFunction(count => { const s = document.querySelector('.hm-orgchart-host')?.shadowRoot; return s?.querySelectorAll('.hm-orgchart-employee').length === count && [...s.querySelectorAll('link')].every(link => link.sheet); }, count); await settle(p); } async function mount(data, options = {}, baseline = false) { await page.goto(origin + '/shell'); await page.evaluate(async ({ origin, data, options, baseline }) => { const base = origin + (baseline ? '/test-results/org-chart-p5-4/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(); }, { origin, data, options, baseline }); await settle(page); } async function measure(p = page) { return p.evaluate(() => { const s = document.querySelector('.hm-orgchart-host').shadowRoot; const box = e => { const r = e.getBoundingClientRect(); return { x:r.x, y:r.y, width:r.width, height:r.height }; }; const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => { const cs = getComputedStyle(c), avatar = c.querySelector('.hm-orgchart-avatar'), name = c.querySelector('.hm-orgchart-person-name'), role = c.querySelector('.hm-orgchart-person-role'); const article = c.closest('.hm-orgchart-organization'), endpoint = article?.querySelector(`path[data-employee-id="${c.dataset.employeeId}"]`); let x = 0, y = 0, cur = c; while (article && cur && cur !== article) { x += cur.offsetLeft; y += cur.offsetTop; cur = cur.offsetParent; } const direction = c.dataset.connectionDirection; const target = direction === 'left' ? [x, y+c.offsetHeight/2] : direction === 'right' ? [x+c.offsetWidth, y+c.offsetHeight/2] : [x+c.offsetWidth/2,y]; return { id:c.dataset.employeeId, kind:c.dataset.cardKind, company:c.dataset.companyId, asset:c.dataset.cardAsset, name:name.textContent, role:role.textContent, size:[c.offsetWidth,c.offsetHeight], box:box(c), avatar:[avatar.offsetWidth,avatar.offsetHeight], radius:getComputedStyle(avatar).borderRadius, font:getComputedStyle(name).fontFamily, fontSize:[getComputedStyle(role).fontSize,getComputedStyle(name).fontSize], border:[cs.borderTopColor,cs.borderRightColor,cs.borderBottomColor,cs.borderLeftColor], direction, endpointError:endpoint ? Math.hypot(Number(endpoint.dataset.endX)-target[0],Number(endpoint.dataset.endY)-target[1]) : null, label:c.getAttribute('aria-label'), photo:c.dataset.photoState, image:c.querySelector('.hm-orgchart-avatar img').getAttribute('src'), background:cs.backgroundColor }; }); const v=s.querySelector('.hm-orgchart-viewport'), f=s.querySelector('.hm-orgchart-forest'); return { cards, nodes:[...s.querySelectorAll('.node[data-depth]')].map(n=>({id:n.dataset.organizationId,parent:n.dataset.parentId,domParent:n.parentElement.parentElement.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId ?? '',depth:Number(n.dataset.depth),box:box(n),article:box(n.querySelector('.hm-orgchart-organization')),company:n.dataset.companyId})), titles:[...s.querySelectorAll('.hm-orgchart-organization-name')].map(t=>({text:t.textContent,bg:getComputedStyle(t).backgroundColor,color:getComputedStyle(t).color})), viewport:box(v), forest:box(f), scroll:[v.scrollLeft,v.scrollTop,v.scrollWidth,v.clientWidth,v.scrollHeight,v.clientHeight], document:[document.documentElement.scrollWidth,innerWidth,document.documentElement.scrollHeight,document.documentElement.clientHeight], design:s.querySelector('[data-card-design-status]')?.dataset.cardDesignStatus }; }); } function overlap(cards) { return cards.some((a,i)=>cards.slice(i+1).some(b=>Math.min(a.box.x+a.box.width,b.box.x+b.box.width)-Math.max(a.box.x,b.box.x)>0.5 && Math.min(a.box.y+a.box.height,b.box.y+b.box.height)-Math.max(a.box.y,b.box.y)>0.5)); } async function popup(p, id, key = 'Enter') { const card=p.locator(`${shadow} .hm-orgchart-employee[data-employee-id="${id}"]`); await card.focus(); await settle(p); await card.press(key); await p.locator('dialog[open]').waitFor(); const props=await p.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot,d=s.querySelector('dialog');return {title:s.getElementById(d.getAttribute('aria-labelledby'))?.textContent,close:d.querySelector('button').getAttribute('aria-label'),outside:!d.closest('.hm-orgchart-forest'),focus:d.contains(s.activeElement),scale:getComputedStyle(d).transform};}); check(props.title==='직원 정보' && props.close==='직원 정보 닫기' && props.outside && props.focus && props.scale==='none','Popup title, close name, top layer and focus',props); await p.keyboard.press('Escape'); await settle(p); check(await p.evaluate(id=>document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId===id,id),'Popup Escape restores original focus'); } try { if (consoleOnly) { await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6054')+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(page,20); await page.goto(origin+'/test-results/org-chart-p5-4/react-build/dist/index.html'); await ready(page,20); await page.goto((process.env.ORGCHART_HTML_ORIGIN ?? 'http://127.0.0.1:4189')+'/expanded.html'); await ready(page,20); check(errors.length===0,'No new console/runtime errors',errors); } else { let m; if (!resume) { await mount(fixtures.small,{layoutMode:'auto',displayMode:'readOnly'},true); baseline=await measure(); await mount(fixtures.small,{layoutMode:'auto',displayMode:'readOnly'}); await ready(page,20); m=await measure(); check(JSON.stringify(baseline.cards.map(c=>[c.id,c.kind,c.size,c.avatar,c.fontSize,c.border,c.background]))===JSON.stringify(m.cards.map(c=>[c.id,c.kind,c.size,c.avatar,c.fontSize,c.border,c.background])),'Auto baseline/current card size, photo, typography, color unchanged'); check(m.design==='current-presentation','Current presentation status matches revised requirements'); measurements.push({name:'small geometry',value:m}); const toggle=page.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]'); await toggle.focus(); await settle(page); const normal=await toggle.evaluate(e=>getComputedStyle(e).backgroundColor); await toggle.hover(); const hover=await toggle.evaluate(e=>getComputedStyle(e).backgroundColor); check(normal!==hover,'Group title Hover feedback',{normal,hover}); await page.mouse.move(0,0); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); await settle(page); check(await toggle.evaluate(e=>e.matches(':focus-visible') && getComputedStyle(e).outlineStyle==='solid'),'Group title keyboard Focus outline'); await toggle.press('Enter'); await settle(page); check(await toggle.getAttribute('aria-expanded')==='false','Enter collapse aria-expanded'); await page.screenshot({path:path.join(output,'group-collapsed.png'),fullPage:true}); await toggle.press('Space'); await ready(page,20); check(await toggle.getAttribute('aria-expanded')==='true','Space expand aria-expanded'); check(await toggle.evaluate(e=>e.getRootNode().activeElement===e),'Toggle focus returns after rerender'); await page.screenshot({path:path.join(output,'group-expanded.png'),fullPage:true}); await page.evaluate(()=>{window.handle.zoomTo(1);window.handle.panBy(-4000,-2000);}); const card=page.locator('.hm-orgchart-employee').first(); await card.focus(); await page.keyboard.press('Tab'); await settle(page); const visible=await page.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot,v=s.querySelector('.hm-orgchart-viewport'),a=s.activeElement,b=a.getBoundingClientRect(),r=v.getBoundingClientRect();return {within:b.left>=r.left && b.top>=r.top && b.right<=r.right && b.bottom<=r.bottom,scale:Number(v.dataset.scale),scroll:[v.scrollLeft,v.scrollTop],active:a.className};}); check(visible.within && visible.scale===1 && visible.scroll.every(n=>n===0),'Tab reveals offscreen control without Zoom or hidden scrolling',visible); await page.evaluate(()=>window.handle.fitToView()); await settle(page); const v=page.locator('.hm-orgchart-viewport'); const vb=await v.boundingBox(); const panBefore=await page.evaluate(()=>window.handle.getViewportState()); await page.mouse.move(vb.x+20,vb.y+20); await page.mouse.down(); await page.mouse.move(vb.x+70,vb.y+50); await page.mouse.up(); await settle(page); const panAfter=await page.evaluate(()=>window.handle.getViewportState()); check(panAfter.x!==panBefore.x && panAfter.y!==panBefore.y,'Trusted mouse Pan remains operational'); await popup(page,m.cards[0].id,'Space'); await page.evaluate(()=>window.handle.fitToView()); await settle(page); for (const size of ['medium','large']) { const data=fixtures[size],connections=Object.fromEntries(data.organizations.map((o,i)=>[o.id,['top','left','right'][i%3]])); await mount(data,{layoutMode:'auto',displayMode:'readOnly',employeeConnections:connections}); await ready(page,data.employees.length); m=await measure(); check(m.cards.length===data.employees.length && new Set(m.cards.map(c=>c.id)).size===data.employees.length,size+' unique complete employee rendering'); check(!overlap(m.cards) && m.nodes.every(n=>n.parent===n.domParent && (!n.parent || m.nodes.some(p=>p.id===n.parent && p.article.y+p.article.heightc.endpointError!==null && c.endpointError<1),size+' actual endpoints aligned'); check(m.cards.every(c=>{const i={top:0,right:1,left:3}[c.direction];return c.border[i]!==c.border[2] && c.border.every((b,j)=>j===i || b===c.border[2]);}),size+' top/left/right single-side accent'); check(m.forest.x>=m.viewport.x-1 && m.forest.y>=m.viewport.y-1 && m.forest.x+m.forest.width<=m.viewport.x+m.viewport.width+1 && m.forest.y+m.forest.height<=m.viewport.y+m.viewport.height+1,size+' Fit includes all cards'); measurements.push({name:size,value:m}); await page.screenshot({path:path.join(output,`html-${size}-fit.png`),fullPage:true}); } await page.evaluate(()=>window.handle.setSelectedCompanies(['hanmac','saman'])); await settle(page); m=await measure(); check(new Set(m.nodes.map(n=>n.company)).size===2 && m.nodes.filter(n=>!n.parent).length===2,'Two selected companies retain independent roots'); await page.screenshot({path:path.join(output,'two-companies.png'),fullPage:true}); await page.evaluate(()=>window.handle.setSelectedCompanies(['hanmac'])); await settle(page); check((await measure()).nodes.every(n=>n.company==='hanmac'),'Single company selection'); await page.getByRole('button',{name:'100% 복원',exact:true}).click(); await settle(page); await page.screenshot({path:path.join(output,'cards-100-percent.png'),fullPage:true}); await mount(fixed,{displayMode:'readOnly'},true); const fixedBefore=await measure(); await mount(fixed,{displayMode:'readOnly'}); const fixedAfter=await measure(); const fixedSig=m=>m.cards.map(c=>[c.id,c.asset,c.box,c.name,c.role,c.border,c.avatar]); check(JSON.stringify(fixedSig(fixedBefore))===JSON.stringify(fixedSig(fixedAfter)),'Fixed baseline exact geometry/SVG/text/color preservation'); check(await page.locator('.hm-orgchart-expanded-toggle').count()===0,'Fixed has no Auto toggles'); await popup(page,fixedAfter.cards[0].id); await page.screenshot({path:path.join(output,'fixed-1440.png'),fullPage:true}); } else { // Only the two failed layout assertions are retried, with subtree ancestry // instead of an invalid bound requiring every sibling to overlap its parent. for (const size of ['medium','large']) { await mount(fixtures[size],{layoutMode:'auto',displayMode:'readOnly'}); await ready(page,fixtures[size].employees.length); m=await measure(); check(!overlap(m.cards) && m.nodes.every(n=>n.parent===n.domParent && (!n.parent || m.nodes.some(p=>p.id===n.parent && p.article.y+p.article.heighte.dispatchEvent(new Event('error'))); await settle(page); check((await measure()).cards[0].photo==='placeholder','Image failure returns common placeholder (error-event simulation)'); // Real React Story runtime: same host controls and shared Core. const story=process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6054'; await page.goto(story+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(page,20); m=await measure(); const expected=baseline.cards.map(c=>[c.id,c.size,c.avatar,c.name,c.role,c.fontSize,c.border[2]]); check(JSON.stringify(m.cards.map(c=>[c.id,c.size,c.avatar,c.name,c.role,c.fontSize,c.border[2]]))===JSON.stringify(expected),'HTML/React same shared-card geometry, photo and typography'); check(m.cards.every(c=>c.font.includes('Noto Sans KR') && c.label.includes('직원 정보 보기')),'React computed Noto family and meaningful card names'); check(await page.getByRole('checkbox',{name:'한맥기술'}).count()===1 && await page.getByRole('combobox',{name:'가상 데이터'}).count()===1,'React company and data controls have connected names'); const index=await (await fetch(story+'/index.json')).json(); check(Object.values(index.entries).filter(e=>e.title==='OrgChart' && e.type==='story' && !e.tags?.includes('internal') && !e.tags?.includes('verification')).length===4,'Four public OrgChart Stories preserved'); for(const width of [390,768,1280,1440]) { await page.setViewportSize({width,height:1000}); await settle(page); const mm=await measure(); check(mm.document[0]<=width+1 && mm.cards.length===20 && mm.scroll[0]===0 && mm.scroll[1]===0,`React ${width}px no horizontal document overflow or hidden viewport scroll`,{document:mm.document,scroll:mm.scroll}); await page.screenshot({path:path.join(output,`storybook-${width}.png`),fullPage:true}); } await page.getByRole('combobox').click(); await page.getByRole('option',{name:'Large · 300명',exact:true}).click(); await ready(page,300); await page.screenshot({path:path.join(output,'storybook-large.png'),fullPage:true}); const largeReact=await measure(); check(largeReact.cards.length===300 && !overlap(largeReact.cards),'React Large selection renders 300 cards without overlap'); await popup(page,largeReact.cards[0].id); await page.goto(origin+'/test-results/org-chart-p5-4/react-build/dist/index.html'); await ready(page,20); check((await measure()).design==='current-presentation','Built React bootstrap mounts revised shared Core'); const touchContext=await browser.newContext({viewport:{width:390,height:844},isMobile:true,hasTouch:true}); const touchPage=await touchContext.newPage(); track(touchPage); await touchPage.goto((process.env.ORGCHART_HTML_ORIGIN ?? 'http://127.0.0.1:4189')+'/expanded.html'); await ready(touchPage,20); const touchToggle=touchPage.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]'); await touchToggle.tap(); await settle(touchPage); check(await touchToggle.getAttribute('aria-expanded')==='false','Mobile emulation touch tap collapses title'); await touchToggle.tap(); await ready(touchPage,20); const ts=await touchPage.locator('.hm-orgchart-viewport').boundingBox(),cdp=await touchContext.newCDPSession(touchPage); const touchBefore=await touchPage.evaluate(()=>window.expandedHandle.getViewportState()); await cdp.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x:ts.x+20,y:ts.y+20}]}); await cdp.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x:ts.x+65,y:ts.y+55}]}); await cdp.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]}); await settle(touchPage); const touchAfter=await touchPage.evaluate(()=>window.expandedHandle.getViewportState()); check(touchAfter.x!==touchBefore.x && touchAfter.y!==touchBefore.y,'Mobile emulation CDP touch Pan'); await touchPage.getByRole('button',{name:'Fit to View',exact:true}).tap(); await settle(touchPage); await touchPage.locator('.hm-orgchart-employee').first().tap(); await touchPage.locator('dialog[open]').waitFor(); check(await touchPage.getByRole('button',{name:'직원 정보 닫기',exact:true}).count()===1,'Mobile emulation card tap opens named dialog'); await touchPage.getByRole('button',{name:'직원 정보 닫기',exact:true}).tap(); await settle(touchPage); await touchPage.screenshot({path:path.join(output,'touch-emulation.png'),fullPage:true}); await touchContext.close(); check(errors.length===0,'No new console/runtime errors',errors); } } catch(error) { check(false,'Runner completed',error.stack); } finally { await fs.writeFile(path.join(output,'browser.json'),JSON.stringify({scope:'P5-4 change impact only',results,errors,measurements,notVerified:['Actual screen-reader speech','Physical mobile device','Other browser engines','Unsupported no-connection direction','Historical full regression'],summary:{pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length}},null,2)); await browser.close(); await new Promise(resolve=>server.close(resolve)); } console.log(JSON.stringify({pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length,output})); if(results.some(r=>r.status==='FAIL')) process.exitCode=1;