import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; import http from 'node:http'; import path from 'node:path'; import fs from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { fileURLToPath, pathToFileURL } from 'node:url'; // P3.1 change-specific checks. The prior 732 checks and their evidence are retained. const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-p3.1'); const fixtureBytes = await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json')); const fixture = JSON.parse(fixtureBytes); await loadPlaywright(); const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]; const results = [], measurements = [], fontEvidence = [], errors = [], servers = []; const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); const near = (a, b, tolerance = .4) => Number.isFinite(a) && Math.abs(a - b) <= tolerance; const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' }; await fs.mkdir(output, { recursive: true }); async function serve(directory) { const server = http.createServer(async (request, response) => { try { const url = new URL(request.url, 'http://127.0.0.1'); if (url.pathname === '/__p31_link') return response.end('

Group link reached

'); if (url.pathname === '/__p31_photo.svg') { response.setHeader('Content-Type', 'image/svg+xml'); return response.end(''); } if (url.pathname === '/__p31_bad_photo.png') { response.setHeader('Content-Type', 'image/png'); return response.end('intentional invalid image bytes'); } const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); const relative = path.relative(directory, file); if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); response.end(await fs.readFile(file)); } catch { response.writeHead(404).end(); } }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server); return `http://127.0.0.1:${server.address().port}`; } async function ready(page, mode = 'readOnly') { await page.waitForFunction(mode => { const s = document.querySelector('.hm-orgchart-host')?.shadowRoot; return s?.querySelector(`.hm-orgchart-composition[data-display-mode="${mode}"] [data-card-asset]`) && [...s.querySelectorAll('link[rel=stylesheet]')].every(l => l.sheet); }, mode, { timeout: 25000 }); await page.evaluate(async () => { await document.fonts.load('700 20px "Noto Sans KR"', '홍 길 동 협업 소통 허브 경영진 통합시스템'); await document.fonts.ready; await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(i => i.decode().catch(() => {}))); await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); }); } async function measure(page) { return page.evaluate(() => { const s = document.querySelector('.hm-orgchart-host').shadowRoot, stage = s.querySelector('.hm-orgchart-stage'); const origin = stage.getBoundingClientRect(), scale = origin.width / 1920; const rect = e => { const r = e.getBoundingClientRect(); return { x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }; }; const style = (e, props) => Object.fromEntries(props.map(p => [p, getComputedStyle(e)[p]])); const props = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']; const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => ({ id: c.dataset.employeeId, size: c.dataset.cardAssetSize, kind: c.dataset.cardKind, box: rect(c), name: c.querySelector('.hm-orgchart-person-name').textContent, role: c.querySelector('.hm-orgchart-person-role').textContent, badge: c.querySelector('.hm-orgchart-job-title')?.textContent ?? '', photo: c.dataset.photoState, image: c.querySelector('.hm-orgchart-avatar img') && { src: c.querySelector('.hm-orgchart-avatar img').src, naturalWidth: c.querySelector('.hm-orgchart-avatar img').naturalWidth, placeholder: c.querySelector('.hm-orgchart-avatar img').dataset.photoPlaceholder }, avatar: { box: rect(c.querySelector('.hm-orgchart-avatar')), ...style(c.querySelector('.hm-orgchart-avatar'), ['borderRadius']) }, emptyAttributes: !!c.querySelector('.hm-orgchart-person-attributes:empty'), text: [...c.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-job-title')].map(e => ({ text: e.textContent, box: rect(e), clientWidth: e.clientWidth, scrollWidth: e.scrollWidth, ...style(e, props) })), })); const headers = [...s.querySelectorAll('.node[data-organization-id]')].map(n => { const h = n.querySelector('.hm-orgchart-organization-name'), first = n.querySelector('.hm-orgchart-employee'); return { id: n.dataset.organizationId, depth: Number(n.dataset.organizationDepth), box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, [...props, 'backgroundImage', 'textAlign', 'borderRadius', 'boxShadow']) }; }); const hub = s.querySelector('.hm-orgchart-hub'), label = hub.querySelector('.hm-orgchart-hub-label'), icon = hub.querySelector('.hm-orgchart-hub-icon'); const cells = [...s.querySelectorAll('.hm-orgchart-workdesk-position-table th,.hm-orgchart-workdesk-position-table td')].map(e => { const r = document.createRange(); r.selectNodeContents(e); const ranges = [...r.getClientRects()].filter(r => r.width > 0).map(r => ({ x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale })); return { text: e.textContent, box: rect(e), ranges, ...style(e, ['whiteSpace', 'textOverflow', 'overflowX', 'paddingLeft', 'paddingRight', ...props]) }; }); const fontViolations = [...s.querySelectorAll('.hm-orgchart-shell *')].filter(e => [...e.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()) && !getComputedStyle(e).fontFamily.includes('Noto Sans KR')).map(e => ({ class: e.className, text: e.textContent, fontFamily: getComputedStyle(e).fontFamily })); const table = s.querySelector('.hm-orgchart-workdesk-position-table'); return { scale, cards, headers, hub: { box: rect(hub), icon: rect(icon), label: rect(label), axis: rect(s.querySelector('.hm-orgchart-axis')), ...style(hub, ['paddingLeft', 'paddingRight', 'gap']), font: style(label, props), paths: icon.querySelectorAll('path').length }, cells, fontViolations, table: { box: rect(table), rows: [...table.rows].map(r => [...r.cells].map(c => c.textContent)), total: table.querySelector('[data-total-count]').textContent }, document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight }, outside: cards.filter(c => c.box.x < -.5 || c.box.y < -.5 || c.box.x + c.box.width > 1920.5 || c.box.y + c.box.height > 915), connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length, footer: s.querySelector('.hm-orgchart-affiliation-bar').textContent, }; }); } async function glyphFonts(page, route, selectors) { const session = await page.context().newCDPSession(page); await session.send('DOM.enable'); await session.send('CSS.enable'); for (const [name, selector] of selectors) { const target = page.locator(selector).first(); await target.evaluate(e => e.setAttribute('data-p31-font-proof', 'yes')); const { root: dom } = await session.send('DOM.getDocument', { depth: -1, pierce: true }); const find = node => node.attributes?.some((a, i) => i % 2 === 0 && a === 'data-p31-font-proof') ? node : [...(node.children ?? []), ...(node.shadowRoots ?? [])].map(find).find(Boolean); const node = find(dom); const value = await session.send('CSS.getPlatformFontsForNode', { nodeId: node.nodeId }); fontEvidence.push({ route, name, ...value }); // This variable font's internal family is "Noto Sans KR Thin" even at bold weights. check(value.fonts.length > 0 && value.fonts.every(f => f.familyName.startsWith('Noto Sans KR') && f.postScriptName.startsWith('NotoSansKR') && f.isCustomFont && f.glyphCount > 0), `${route}: actual glyph font ${name}`, value.fonts); await target.evaluate(e => e.removeAttribute('data-p31-font-proof')); } await session.detach(); } function verify(m, route, v) { const name = `${route}/${v.width}×${v.height}`; check(!m.fontViolations.length, `${name}: every text family is scoped Noto Sans KR`, m.fontViolations); check(m.cards.length === 43 && m.cards.every(c => c.name === '홍 길 동' && c.role === fixture.employees.find(e => e.id === c.id).position.replace(/\s+/gu, '')), `${name}: display-only name/position; 43 cards`, m.cards.map(c => [c.id,c.name,c.role])); check(m.cards.every(c => c.badge !== '경영진' && c.badge === ((fixture.employees.find(e => e.id === c.id).jobTitle?.trim() === '경영진') ? '' : fixture.employees.find(e => e.id === c.id).jobTitle ?? '')), `${name}: executive badge absent; other raw badges retained`); check(m.cards.every(c => !c.emptyAttributes), `${name}: no empty badge/attribute container`); check(m.cards.every(c => { const [w,h] = c.size === 'representative' ? [146,74] : c.size === 'leader' ? [146,68] : c.size === 'employee' ? [108,52] : [114,54]; return near(c.box.width,w) && near(c.box.height,h); }), `${name}: approved card sizes and existing P3 status sizes preserved`); check(m.cards.every(c => { const [w,h] = c.size === 'representative' ? [36,62] : c.size === 'leader' ? [32,56] : c.size === 'employee' ? [24,44] : [28,28]; return c.photo === 'placeholder' && c.image?.placeholder === 'true' && c.image.naturalWidth > 0 && near(c.avatar.box.width,w) && near(c.avatar.box.height,h) && c.avatar.borderRadius !== '50%'; }) && new Set(m.cards.map(c => c.image?.src)).size === 1, `${name}: common decoded square icon in every noncircular slot`); check(m.headers.filter(h => h.depth === 1).every(h => h.fontSize === '20px' && h.fontWeight === '700' && h.lineHeight === '36px' && near(h.box.height,36)), `${name}: 1Depth 20/700/36 and header36`, m.headers.filter(h => h.depth === 1)); check(m.headers.filter(h => h.depth >= 2).every(h => { const small = h.id.startsWith('talent-growth-') || h.id.startsWith('business-planning-'); return h.fontSize === (small ? '18px':'20px') && h.fontWeight === '700' && h.lineHeight === (small?'16.9px':'19.7px') && near(h.box.width,114) && near(h.box.height,30.4); }), `${name}: 2Depth source typography/surface`, m.headers.filter(h => h.depth >= 2)); check(m.headers.every(h => h.gap === null || near(h.gap,8)), `${name}: all title-to-first-card gaps 8px`, m.headers.map(h => [h.id,h.gap])); check(m.hub.font.fontSize === '21px' && m.hub.font.fontWeight === '700' && m.hub.font.lineHeight === '24px' && m.hub.paddingLeft === '22px' && m.hub.paddingRight === '24px' && m.hub.gap === '8px' && near(m.hub.box.height,42) && near(m.hub.icon.width,20) && near(m.hub.icon.height,20) && near(m.hub.icon.y+10,m.hub.label.y+m.hub.label.height/2) && near(m.hub.box.x+m.hub.box.width/2,m.hub.axis.x+m.hub.axis.width/2) && m.hub.paths === 3, `${name}: hub font/padding/icon/axis alignment`, m.hub); check(m.cells.every(c => c.whiteSpace === 'nowrap' && c.textOverflow !== 'ellipsis' && c.ranges.length === 1 && c.ranges[0].x >= c.box.x + parseFloat(c.paddingLeft) - .5 && c.ranges[0].x+c.ranges[0].width <= c.box.x+c.box.width-parseFloat(c.paddingRight)+.5), `${name}: every table label fits on one line without clipping`, m.cells.filter(c => c.ranges.length !== 1 || c.ranges[0].width > c.box.width-parseFloat(c.paddingLeft)-parseFloat(c.paddingRight)+.5)); check(near(m.table.box.width,330,1) && m.table.total === '41' && m.footer.includes('41'), `${name}: table width/count and footer retained`, { table:m.table.box,total:m.table.total }); check(m.document.scrollWidth === m.document.clientWidth && m.document.scrollHeight === m.document.clientHeight && !m.outside.length, `${name}: no page scroll; every card inside stage`, m.document); check(m.cards.every(c => c.text.every(t => t.scrollWidth <= t.clientWidth+1 && t.box.x >= c.box.x-.5 && t.box.x+t.box.width <= c.box.x+c.box.width+.5)), `${name}: card typography has no new horizontal overflow`); } const dialogTargets = [['representative','.hm-orgchart-top-leaders .hm-orgchart-employee'],['depth1','.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'],['depth2','.node[data-organization-depth="2"] .hm-orgchart-employee[data-card-kind="leader"]'],['employee','.hm-orgchart-engine .hm-orgchart-employee[data-card-kind="employee"]'],['dispatched','.hm-orgchart-status-group[data-work-status="dispatched"] .hm-orgchart-employee'],['onLeave','.hm-orgchart-status-group[data-work-status="onLeave"] .hm-orgchart-employee']]; async function dialogs(page, route, viewport) { await page.setViewportSize(viewport); await ready(page); for (const [kind, selector] of dialogTargets) { const card = page.locator(selector).first(), dialog = page.locator('dialog[open]'); const id = await card.getAttribute('data-employee-id'); await card.click(); await dialog.waitFor({ state:'visible' }); const value = await dialog.evaluate(d => ({ fields:Object.fromEntries([...d.querySelectorAll('dt')].map(t=>[t.textContent,t.nextElementSibling.textContent])), font:getComputedStyle(d).fontFamily, width:d.getBoundingClientRect().width, scaled:!!d.closest('.hm-orgchart-stage'), transform:getComputedStyle(d).transform })); const employee = fixture.employees.find(e=>e.id===id); check(value.fields['이름']==='홍 길 동' && value.fields['직위']===employee.position && (!employee.jobTitle || value.fields['직책']===employee.jobTitle) && value.font.includes('Noto Sans KR') && !value.scaled && value.transform==='none' && near(value.width,Math.min(420,viewport.width-32)), `${route}/${viewport.width}: ${kind} popup retains data/font/unscaled size`, value); if (kind==='representative' && viewport.width===1920) await glyphFonts(page,route,[['popup name','dialog[open] dd']]); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); check(await dialog.evaluate(d=>d.contains(d.getRootNode().activeElement)),`${route}/${viewport.width}: ${kind} modal focus trap`); await page.keyboard.press('Escape'); await dialog.waitFor({state:'hidden'}); check(await card.evaluate(e=>e.getRootNode().activeElement===e),`${route}/${viewport.width}: ${kind} ESC focus return`); if(kind==='representative') { await card.press('Enter'); await dialog.waitFor({state:'visible'}); await dialog.locator('button').click(); await dialog.waitFor({state:'hidden'}); await card.press('Space'); await dialog.waitFor({state:'visible'}); await page.keyboard.press('Escape'); check(await card.evaluate(e=>e.getRootNode().activeElement===e),`${route}/${viewport.width}: Enter/Space/close preserve activation`); } } } async function setData(page,data,transport) { await page.evaluate(async({data,transport})=>{ if(transport==='react')window.OrgChartP1React.setData(data);else await window.OrgChartP1.setData(data); },{data,transport}); await ready(page); } let browser; try { const htmlOrigin = await serve(path.join(root,'examples/org-chart/dist')); const reactOrigin = await serve(path.join(root,'examples/org-chart-react/dist')); browser = await launchChromium({ headless: true }); const context = await browser.newContext(); const routes = [{name:'html',url:`${htmlOrigin}/p1.html?mode=readOnly`},{name:'file-classic',url:pathToFileURL(path.join(root,'examples/org-chart/dist/p1-classic.html')).href+'?mode=readOnly'},{name:'react',url:`${reactOrigin}/p1.html?mode=readOnly`},{name:'storybook',url:'http://127.0.0.1:6041/iframe.html?id=orgchart--full-composition&viewMode=story'}]; for(const route of routes) { const page = await context.newPage(); page.on('pageerror',e=>errors.push({route:route.name,type:'runtime',message:e.message})); page.on('console',e=>{if(e.type()==='error')errors.push({route:route.name,type:'console',message:e.text()});}); try { await page.goto(route.url); await ready(page); for(const viewport of viewports) { await page.setViewportSize(viewport); await ready(page); const m = await measure(page); measurements.push({route:route.name,...viewport,...m}); verify(m,route.name,viewport); if([1920,390].includes(viewport.width))await page.screenshot({path:path.join(output,`${route.name}-${viewport.width}x${viewport.height}.png`)}); } await page.setViewportSize(viewports[0]); await ready(page); await glyphFonts(page,route.name,[['card name','.hm-orgchart-person-name'],['card position','.hm-orgchart-person-role'],['1Depth','.node[data-organization-depth="1"] .hm-orgchart-organization-name'],['2Depth','.node[data-organization-depth="2"] .hm-orgchart-organization-name'],['hub','.hm-orgchart-hub-label'],['table','.hm-orgchart-workdesk-position-table th'],['footer','.hm-orgchart-affiliation-bar-item dt'],['status','.hm-orgchart-status-group .hm-orgchart-summary-title']]); await dialogs(page,route.name,viewports[0]); await dialogs(page,route.name,viewports.at(-1)); if(['html','react'].includes(route.name)) { const data = structuredClone(fixture); data.employees[0].profileImage=`${htmlOrigin}/__p31_photo.svg`; data.employees[1].profileImage=`${htmlOrigin}/__p31_bad_photo.png`; data.employees[2].position='책 임'; await setData(page,data,route.name); const imageState = await page.evaluate(ids=>ids.map(id=>{const c=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`[data-employee-id="${id}"]`),i=c.querySelector('.hm-orgchart-avatar img');return {id,photo:c.dataset.photoState,src:i.src,width:i.naturalWidth,role:c.querySelector('.hm-orgchart-person-role').textContent};}),data.employees.slice(0,3).map(e=>e.id)); check(imageState[0].photo==='image' && imageState[0].width===80 && imageState[1].photo==='placeholder' && imageState[1].width===64 && imageState[2].role==='책임' && data.employees[2].position==='책 임',`${route.name}: real image precedence/load-failure fallback/display whitespace`,imageState); await setData(page,fixture,route.name); // The preserved fixture has position=경영진 but no jobTitle=경영진. // Exercise the requested badge policy with in-memory variants, never edit JSON. const executiveIds = await page.evaluate(selectors=>selectors.map(selector=>document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(selector).dataset.employeeId),dialogTargets.map(t=>t[1])); const executiveData = structuredClone(fixture); for(const employee of executiveData.employees)if(executiveIds.includes(employee.id))employee.jobTitle='경영진'; await setData(page,executiveData,route.name); const executiveState = await page.evaluate(ids=>[...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(c=>ids.includes(c.dataset.employeeId)).map(c=>({id:c.dataset.employeeId,badge:c.querySelector('.hm-orgchart-job-title')?.textContent,role:c.querySelector('.hm-orgchart-person-role').textContent,empty:!!c.querySelector('.hm-orgchart-person-attributes:empty')})),executiveIds); check(executiveState.length>=6 && executiveState.every(c=>!c.badge&&!c.empty&&c.role===fixture.employees.find(e=>e.id===c.id).position),`${route.name}: executive badge suppressed in all six card roles; positions preserved`,executiveState); await page.locator(dialogTargets[0][1]).click();await page.locator('dialog[open]').waitFor({state:'visible'}); const rawRole=await page.locator('dialog[open]').evaluate(d=>[...d.querySelectorAll('dt')].find(t=>t.textContent==='직책')?.nextElementSibling.textContent); check(rawRole==='경영진' && executiveData.employees.find(e=>e.id===executiveIds[0]).jobTitle==='경영진',`${route.name}: hidden badge remains in data and popup contract`);await page.keyboard.press('Escape'); await setData(page,fixture,route.name); const id=fixture.organizations.find(o=>o.parentId===fixture.organizations.find(o=>o.parentId===null).id).id; const links={[id]:`${route.name==='html'?htmlOrigin:reactOrigin}/__p31_link`}; await page.evaluate(async({links,transport})=>{if(transport==='react')window.OrgChartP1React.setOptions({organizationLinks:links});else {const s=window.OrgChartP1;s.chart.destroy();s.chart=s.api.createOrgChart({container:document.getElementById('chart-host'),dataSource:new s.api.JsonOrgChartDataSource(s.data),stylesheetUrls:s.stylesheetUrls,displayMode:'readOnly',organizationLinks:links});await s.chart.mount();}},{links,transport:route.name}); await ready(page); const link=page.locator('.hm-orgchart-organization-link');check(await link.count()===1,`${route.name}: only configured 1Depth title links`);await link.focus();await Promise.all([page.waitForURL(links[id]),page.keyboard.press('Enter')]);check(await page.locator('#proof').textContent()==='Group link reached',`${route.name}: keyboard group link navigation`); } } catch(error) { check(false,`${route.name}: focused verification completed`,{message:error.message,stack:error.stack}); } await page.close(); } for(const v of viewports) { const variants=measurements.filter(m=>m.width===v.width); const base=variants.find(m=>m.route==='html'); const equal=(a,b)=>['x','y','width','height'].every(k=>near(a[k],b[k])); check(variants.length===4 && variants.every(m=>m.cards.length===base.cards.length && m.cards.every((c,i)=>equal(c.box,base.cards[i].box)&&c.name===base.cards[i].name&&c.role===base.cards[i].role&&c.badge===base.cards[i].badge&&c.image.src===base.cards[i].image.src)&&equal(m.hub.box,base.hub.box)&&JSON.stringify(m.table.rows)===JSON.stringify(base.table.rows)&&equal(m.table.box,base.table.box)),`HTML/classic/React/Storybook identical content/geometry/${v.width}`); } const standalone=await context.newPage();await standalone.goto(`${htmlOrigin}/p1.html`);await ready(standalone,'interactive'); const standaloneData=structuredClone(fixture);standaloneData.employees[0].jobTitle='경영진';await standalone.evaluate(async data=>window.OrgChartP1.setData(data),standaloneData);await ready(standalone,'interactive'); check((await standalone.locator('.hm-orgchart-job-title').allTextContents()).includes('경영진'),'shared renderer preserves executive badge outside readOnly Full Composition');await standalone.close(); const index=await fetch('http://127.0.0.1:6041/index.json').then(r=>r.json());const userStories=Object.values(index.entries).filter(e=>e.title==='OrgChart'&&e.type==='story'&&!e.tags?.includes('internal'));check(userStories.length===3,'existing Storybook three user menus retained',userStories.map(e=>e.name)); check(!errors.length,'no new browser console/runtime errors',errors); } finally { await browser?.close(); for(const server of servers)await new Promise(r=>server.close(r)); const summary={total:results.length,pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length}; await fs.writeFile(path.join(output,'browser.json'),JSON.stringify({scope:'P3.1 only; P3 732 checks retained without rerun',fixtureSha256:createHash('sha256').update(fixtureBytes).digest('hex'),summary,results,measurements,fontEvidence,errors},null,2)); console.log(JSON.stringify({summary,errors,failures:results.filter(r=>r.status==='FAIL').map(r=>r.name)},null,2));if(summary.fail)process.exitCode=1; }