import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; import http from 'node:http'; import path from 'node:path'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; // Stage 2 only: typography, data placement, overflow and accessible tooltip. // Do not repeat Stage 1's gradient/data/Summary/engine suites. const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-card-content'); await mkdir(output, { recursive: true }); await loadPlaywright(); const results = [], errors = [], measurements = [], servers = []; const check = (value, name, detail) => results.push({ name, status: value ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf' }; 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 === '/profile-fixture.svg') { response.setHeader('Content-Type', 'image/svg+xml'); return response.end(''); } 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 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) { await page.waitForFunction(() => { const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot; return shadow?.querySelector('[data-card-asset] .hm-orgchart-person-name[data-name-overflow]') && [...shadow.querySelectorAll('link')].every(link => !!link.sheet); }); await page.evaluate(async () => { await document.fonts.ready; const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; await Promise.all([...shadow.querySelectorAll('img')].map(img => img.decode().catch(() => {}))); }); } async function measure(page) { return page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; const rect = element => { const b = element.getBoundingClientRect(); return { left: b.left, right: b.right, top: b.top, bottom: b.bottom, width: b.width, height: b.height }; }; const typography = element => { const style = getComputedStyle(element); return Object.fromEntries(['color', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'margin', 'padding', 'whiteSpace', 'textOverflow', 'fontFamily'].map(key => [key, style[key]])); }; const cards = [...shadow.querySelectorAll('[data-card-asset]')].map(card => { const role = card.querySelector('.hm-orgchart-person-role'), name = card.querySelector('.hm-orgchart-person-name'); const avatar = card.querySelector('.hm-orgchart-avatar'), logo = card.querySelector('.hm-orgchart-affiliation-mark'); const bounds = rect(card), roleBounds = rect(role), nameBounds = rect(name), issues = []; const overlap = (a, b) => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1; const contained = b => b.left >= bounds.left - 1 && b.right <= bounds.right + 1 && b.top >= bounds.top - 1 && b.bottom <= bounds.bottom + 1; for (const [label, element] of [['role', role], ['name', name], ['avatar', avatar], ['logo', logo]]) if (!contained(rect(element))) issues.push(`${label} outside card`); for (const element of [role, name]) { // A clipped name's DOM Range intentionally retains the full text width; // visible text is intersected with its clipping box for collision checks. const range = document.createRange(); range.selectNodeContents(element); for (const r of range.getClientRects()) { const box = rect(element); const visible = { left: Math.max(r.left, box.left), right: Math.min(r.right, box.right), top: Math.max(r.top, box.top), bottom: Math.min(r.bottom, box.bottom) }; if (overlap(visible, rect(avatar))) issues.push(`${element.className}: avatar overlap`); if (overlap(visible, rect(logo))) issues.push(`${element.className}: logo overlap`); } } const badge = card.querySelector('.hm-orgchart-job-title'); if (badge) { if (!contained(rect(badge))) issues.push('badge outside card'); if (overlap(rect(badge), rect(avatar)) || overlap(rect(badge), nameBounds)) issues.push('badge overlap'); } return { id: card.dataset.employeeId, size: card.dataset.cardAssetSize, name: name.textContent, role: typography(role), person: typography(name), width: bounds.width, height: bounds.height, avatarWidth: rect(avatar).width, avatarHeight: rect(avatar).height, textGap: nameBounds.top - roleBounds.bottom, cssGap: getComputedStyle(card.querySelector('.hm-orgchart-person-text')).gap, overflow: name.dataset.nameOverflow, focusable: name.getAttribute('tabindex'), description: !!name.getAttribute('aria-describedby'), nameWidth: name.clientWidth, nameScrollWidth: name.scrollWidth, issues, imageLoaded: !!avatar.querySelector('img')?.naturalWidth, logoTransparent: getComputedStyle(logo.querySelector('img')).backgroundColor === 'rgba(0, 0, 0, 0)', fallbackHidden: getComputedStyle(logo.querySelector('.hm-orgchart-affiliation-fallback')).visibility === 'hidden' }; }); const viewport = shadow.querySelector('.hm-orgchart-viewport'); return { cards, pageOverflow: document.documentElement.scrollWidth > innerWidth + 1, extents: { document: [document.documentElement.scrollWidth, document.documentElement.scrollHeight, document.documentElement.clientWidth, document.documentElement.clientHeight], viewport: viewport ? [viewport.scrollWidth, viewport.scrollHeight, viewport.clientWidth, viewport.clientHeight] : null }, orphanTooltips: [...shadow.querySelectorAll('[role=tooltip]')].filter(tip => !shadow.querySelector(`[aria-describedby="${tip.id}"]`)).length }; }); } function verifyTypography(value, label) { for (const size of ['representative', 'leader', 'employee']) { const cards = value.cards.filter(card => card.size === size), small = size === 'employee'; check(cards.length > 0 && cards.every(card => card.role.color === (size === 'representative' ? 'rgb(34, 67, 135)' : 'rgb(121, 121, 121)') && card.role.fontSize === (small ? '15px' : '20px') && card.role.fontWeight === '400' && card.role.lineHeight === (small ? '18px' : '22px') && card.role.letterSpacing === (small ? '-0.6px' : '-0.8px')), `${label}/${size}: position exact computed typography`); check(cards.every(card => card.person.color === 'rgb(7, 29, 58)' && card.person.fontSize === (small ? '20px' : '24px') && card.person.fontWeight === '700' && card.person.lineHeight === (small ? '24px' : '30px') && card.person.letterSpacing === (size === 'leader' ? '-0.96px' : '-0.8px')), `${label}/${size}: name exact computed typography`); check(cards.every(card => card.cssGap === '0px' && card.textGap === 0 && card.role.margin === '0px' && card.role.padding === '0px' && card.person.margin === '0px' && card.person.padding === '0px'), `${label}/${size}: zero gap/margins/padding between two text rows`); check(cards.every(card => card.width === (small ? 108 : 146) && card.height === (size === 'representative' ? 74 : small ? 52 : 68) && card.avatarWidth === (size === 'representative' ? 36 : small ? 24 : 32) && card.avatarHeight === card.avatarWidth), `${label}/${size}: original card/profile dimensions retained`); } check(value.cards.every(card => card.issues.length === 0), `${label}: content collisions/bounds`, value.cards.filter(card => card.issues.length)); check(value.cards.every(card => card.logoTransparent && card.fallbackHidden), `${label}: transparent original logos/fallback retained`); check(!value.pageOverflow && value.orphanTooltips === 0, `${label}: no new page overflow/orphan tooltip`); } async function tooltipChecks(page, label) { const names = page.locator('[data-card-asset] .hm-orgchart-person-name[data-name-overflow=true]'); for (const size of ['representative', 'leader', 'employee']) { const name = page.locator(`[data-card-asset-size="${size}"] .hm-orgchart-person-name[data-name-overflow=true]`).first(); await name.scrollIntoViewIfNeeded(); await name.hover(); const description = await name.getAttribute('aria-describedby'), tip = page.locator(`#${description}`); check(await tip.isVisible() && await tip.innerText() === await name.innerText(), `${label}/${size}: hover shows complete original name`); await tip.hover(); check(await tip.isVisible(), `${label}/${size}: tooltip hoverable`); await page.mouse.move(0, 0); await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot.querySelectorAll('[role=tooltip]')].every(tip => tip.hidden)); // Tab through the real keyboard order from this explicit preceding control. await name.evaluate(element => { const previous = document.createElement('button'); previous.textContent = 'keyboard test start'; element.before(previous); previous.focus(); }); await page.keyboard.press('Tab'); check(await name.evaluate(element => element.getRootNode().activeElement === element) && await tip.isVisible(), `${label}/${size}: Tab focus reveals full name`); check((await name.ariaSnapshot()).includes(await name.innerText()) && await name.getAttribute('aria-describedby') === await tip.getAttribute('id'), `${label}/${size}: full text retained in Chromium accessibility representation/description`); await page.keyboard.press('Escape'); check(!(await tip.isVisible()) && await name.evaluate(element => element.getRootNode().activeElement === element), `${label}/${size}: Escape dismisses, focus retained`); const tipBox = await tip.evaluate(element => { element.hidden = false; const b = element.getBoundingClientRect(); element.hidden = true; return { left: b.left, right: b.right, top: b.top, bottom: b.bottom }; }); const screen = page.viewportSize(); check(tipBox.left >= 0 && tipBox.right <= screen.width + 1 && tipBox.top >= 0 && tipBox.bottom <= screen.height + 1, `${label}/${size}: tooltip inside browser viewport`); await name.evaluate(element => element.previousElementSibling.remove()); } check(await names.count() === 3, `${label}: only the three explicit long-name cases are focusable`); } let browser; try { const origins = await Promise.all(['storybook-static', 'examples/org-chart/dist', 'examples/org-chart-react/dist'].map(directory => serve(path.join(root, directory)))); browser = await launchChromium({ headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce' }); const routes = [['department-colors', `${origins[0]}/iframe.html?id=orgchart--department-colors&viewMode=story`], ['full-composition', `${origins[0]}/iframe.html?id=orgchart--full-composition&viewMode=story`], ['html', `${origins[1]}/p1.html`], ['react', `${origins[2]}/p1.html`]]; for (const [label, url] of routes) { const page = await context.newPage(); page.on('pageerror', error => errors.push({ label, message: error.message })); page.on('console', message => { if (message.type() === 'error') errors.push({ label, message: message.text() }); }); await page.goto(url); await ready(page); for (const width of [390, 768, 1280, 1440]) { await page.setViewportSize({ width, height: 1000 }); await ready(page); const value = await measure(page); measurements.push({ label, width, ...value }); verifyTypography(value, `${label}/${width}`); check(value.cards.every(card => /^[가-힣]{2,3}$/.test(card.name) && card.overflow === 'false' && card.focusable === null && card.person.textOverflow === 'clip'), `${label}/${width}: natural short names remain complete and outside tab order`); } if (label === 'department-colors') { await page.locator('.hm-orgchart-representative-preview').screenshot({ path: path.join(output, 'representative-normal.png') }); for (const [index, department] of [[0, 'business-management'], [1, 'business-planning'], [3, 'integrated-system']]) await page.locator('.hm-orgchart-preview-grid').nth(index).screenshot({ path: path.join(output, `${department}-normal.png`) }); await page.getByRole('button', { name: '긴 이름 검증 시작' }).click(); await ready(page); for (const width of [390, 1440]) { await page.setViewportSize({ width, height: 1000 }); await ready(page); const value = await measure(page); measurements.push({ label: 'storybook-long', width, ...value }); verifyTypography(value, `storybook-long/${width}`); check(value.cards.filter(card => card.overflow === 'true').length === 3 && value.cards.filter(card => card.overflow === 'true').every(card => card.person.textOverflow === 'ellipsis' && card.focusable === '0' && card.description), `storybook-long/${width}: ellipsis and focus only on overflowing names`); await tooltipChecks(page, `storybook-long/${width}`); } await page.locator('.hm-orgchart-representative-preview').scrollIntoViewIfNeeded(); await page.locator('[data-card-asset-size=representative] .hm-orgchart-person-name').focus(); await page.screenshot({ path: path.join(output, 'representative-long-focus.png') }); await page.getByRole('button', { name: '긴 이름 검증 종료' }).click(); await ready(page); check((await measure(page)).orphanTooltips === 0 && await page.locator('[role=tooltip]').count() === 0, 'Story long -> normal disposes tooltip nodes'); } if (label === 'full-composition') { await page.locator('.node[data-organization-id=business-management]').scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(output, 'composition-1440.png') }); } if (label === 'html') { await page.evaluate(async () => { const next = structuredClone(window.OrgChartP1.data); for (const id of ['mock-leader-00', 'mock-leader-01', 'mock-employee-01']) { const person = next.employees.find(item => item.id === id); person.name = '아주긴가상이름표시검증사례'; person.profileImage = `${location.origin}/profile-fixture.svg`; } await window.OrgChartP1.setData(next); }); await ready(page); const value = await measure(page); verifyTypography(value, 'html-long-images'); check(value.cards.filter(card => card.overflow === 'true').every(card => card.imageLoaded), 'long names keep original-size profile images'); await tooltipChecks(page, 'html-long-images'); } await page.close(); } for (const width of [390, 768, 1280, 1440]) { const html = measurements.find(item => item.label === 'html' && item.width === width); const react = measurements.find(item => item.label === 'react' && item.width === width); check(JSON.stringify(html.cards) === JSON.stringify(react.cards), `HTML/React computed typography/content parity/${width}`); } const before = JSON.parse(await readFile(path.join(output, 'data-p1-before.json'), 'utf8')); const after = JSON.parse(await readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); for (const employee of before.employees) employee.name = after.employees.find(item => item.id === employee.id).name; check(JSON.stringify(before) === JSON.stringify(after), 'fixture change is exclusively synthetic display names; contract/count/membership/profile preserved'); check(errors.length === 0, 'browser console/runtime errors', errors); } finally { await writeFile(path.join(output, 'browser.json'), JSON.stringify({ browser: browser?.version(), results, errors, measurements }, null, 2)); await browser?.close(); for (const server of servers) await new Promise(resolve => server.close(resolve)); } const failures = results.filter(result => result.status === 'FAIL'); console.log(JSON.stringify({ checks: results.length, failures: failures.map(result => ({ name: result.name, issues: result.detail?.length })) }, null, 2)); if (failures.length) process.exitCode = 1;