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 { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; // Quality-only verification. No data/source/lifecycle regression suite is run. const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-quality'); await mkdir(output, { recursive: true }); await loadPlaywright(); const results = [], errors = [], measurements = [], screenshots = []; 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', '.png': 'image/png', '.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'); 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)); return { server, origin: `http://127.0.0.1:${server.address().port}` }; } function captureErrors(page, label) { page.on('pageerror', error => errors.push({ label, type: 'pageerror', message: error.message })); page.on('console', message => { if (message.type() === 'error') errors.push({ label, type: 'console', message: message.text() }); }); } async function ready(page) { await page.waitForFunction(() => { const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot; return shadow?.querySelector('.hm-orgchart-shell') && [...shadow.querySelectorAll('link')].every(link => !!link.sheet) && !shadow.querySelector('.hm-orgchart-state')?.textContent.includes('불러오는'); }, undefined, { timeout: 20000 }); 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('.hm-orgchart-affiliation-mark img')].map(image => image.decode().catch(() => {}))); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); } async function measure(page) { return page.evaluate(() => { const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host'); const shadow = host.shadowRoot; const rect = element => { const box = element.getBoundingClientRect(); return { x: box.x + scrollX, y: box.y + scrollY, width: box.width, height: box.height }; }; const ranges = element => { const range = document.createRange(); range.selectNodeContents(element); return [...range.getClientRects()].map(box => ({ x: box.x + scrollX, y: box.y + scrollY, width: box.width, height: box.height })); }; const dimensions = element => element ? { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, overflowX: getComputedStyle(element).overflowX } : null; const visible = element => element.getBoundingClientRect().width > 0 && element.getBoundingClientRect().height > 0; const text = [...shadow.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-organization-name, .hm-orgchart-contrast-result, .hm-orgchart-affiliation-name')].filter(visible); const textIssues = text.filter(element => element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1).map(element => ({ className: element.className, text: element.textContent, box: rect(element) })); const styles = element => { const css = getComputedStyle(element); return { color: css.color, background: css.backgroundColor, gradient: css.backgroundImage, font: css.fontFamily, fontSize: parseFloat(css.fontSize), fontWeight: css.fontWeight, border: css.borderTopColor, radius: css.borderRadius }; }; const backgroundFor = element => { for (let current = element; current; current = current.parentElement) { const css = getComputedStyle(current); if (css.backgroundImage !== 'none') return { ...styles(current), box: rect(current) }; if (css.backgroundColor !== 'rgba(0, 0, 0, 0)' && css.backgroundColor !== 'transparent') return { ...styles(current), box: rect(current) }; } return null; }; const selectors = '.hm-orgchart-organization-name, .hm-orgchart-hub, [data-contrast-target], .hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-job-title, .hm-orgchart-status-label, .hm-orgchart-summary-title, .hm-orgchart-position-table th, .hm-orgchart-position-table td, .hm-orgchart-toggle'; const colors = [...shadow.querySelectorAll(selectors)].filter(visible).map(element => ({ className: element.className, organizationId: element.closest('[data-organization-id]')?.dataset.organizationId, variant: element.closest('[data-group-variant]')?.dataset.groupVariant, target: element.dataset.contrastTarget ?? null, text: element.textContent.trim(), box: rect(element), ranges: ranges(element), ...styles(element), behind: backgroundFor(element), clip: element.closest('.hm-orgchart-viewport, .hm-orgchart-summary-scroll') ? rect(element.closest('.hm-orgchart-viewport, .hm-orgchart-summary-scroll')) : null, })); const logos = [...shadow.querySelectorAll('.hm-orgchart-affiliation-mark')].map(mark => { const image = mark.querySelector('img'); return { id: mark.dataset.affiliationId, label: mark.getAttribute('aria-label'), box: rect(mark), available: mark.dataset.logoAvailable, fallbackVisibility: getComputedStyle(mark.querySelector('.hm-orgchart-affiliation-fallback')).visibility, loaded: !!image?.complete && image.naturalWidth > 0, naturalWidth: image?.naturalWidth, naturalHeight: image?.naturalHeight, fit: image ? getComputedStyle(image).objectFit : null, imageBackground: image ? getComputedStyle(image).backgroundColor : null, source: image?.src ?? null }; }); const cardStyles = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({ id: element.dataset.employeeId, kind: element.dataset.cardKind, ...styles(element) })); return { documentWidth: document.documentElement.scrollWidth, textIssues, viewport: dimensions(shadow.querySelector('.hm-orgchart-viewport')), summary: dimensions(shadow.querySelector('.hm-orgchart-summary-scroll')), logos, colors, cardStyles, rowResults: [...shadow.querySelectorAll('.hm-orgchart-contrast-result')].map(element => ({ text: element.textContent, ...element.dataset })), policy: [...shadow.querySelectorAll('.hm-orgchart-policy')].map(element => element.textContent), components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component), variables: [...shadow.querySelectorAll('[data-color-variable]')].map(element => ({ variable: element.dataset.colorVariable, property: element.dataset.cssProperty, computed: getComputedStyle(element).getPropertyValue(element.dataset.cssProperty), value: element.parentElement.querySelector('.hm-orgchart-color-value').textContent })), navigation: shadow.querySelectorAll('nav, .hm-dashboard-header, .hm-gnb, .hm-lnb, [data-slot=sidebar]').length }; }); } function compactSources(measurement) { for (const logo of measurement.logos) { logo.sha256 = logo.source?.startsWith('data:image/png;base64,') ? createHash('sha256').update(Buffer.from(logo.source.split(',')[1], 'base64')).digest('hex') : null; delete logo.source; } return measurement; } let browser; const servers = []; try { for (const directory of ['storybook-static', 'examples/org-chart/dist', 'examples/org-chart-react/dist']) servers.push(await serve(path.join(root, directory))); browser = await launchChromium({ headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1, reducedMotion: 'reduce' }); const canonical = ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition']; const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8')); const menu = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart') && !entry.tags?.includes('internal')).map(entry => entry.id).sort(); check(JSON.stringify(menu) === JSON.stringify(canonical.map(name => `orgchart--${name}`).sort()), 'final build retains exactly five public OrgChart menu entries', menu); const internal = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title === 'OrgChart/Content'); check(internal.length === 8 && internal.every(entry => entry.tags.includes('internal')), 'original eight verification Stories remain tagged internal'); const sourceHashes = new Map(); for (const id of ['hanmac', 'saman', 'jangheon', 'jangheon-industry', 'ptc', 'halla', 'baron']) sourceHashes.set(id, createHash('sha256').update(await readFile(path.join(root, `packages/org-chart/src/assets/affiliations/${id}.png`))).digest('hex')); for (const name of canonical) { const page = await context.newPage(); captureErrors(page, name); await page.goto(`${servers[0].origin}/iframe.html?id=orgchart--${name}&viewMode=story`); await ready(page); if (name.endsWith('summary')) check(await page.locator('body').innerText().then(text => text.includes('가상') && text.includes('운영')), `${name}: independent Story identifies virtual headcounts`); for (const width of name === 'full-composition' ? [390, 768, 1280, 1440] : [390, 1440]) { await page.setViewportSize({ width, height: 1000 }); await ready(page); const value = compactSources(await measure(page)); value.label = `storybook-${name}`; value.width = width; measurements.push(value); check(value.documentWidth <= width + 1 && value.textIssues.length === 0, `${name} ${width}px: changed content has no page overflow or clipped text`, value.textIssues); check(value.navigation === 0, `${name} ${width}px: no page Navigation added`); if (value.logos.length) check(value.logos.every(logo => logo.loaded && logo.fit === 'contain' && logo.naturalWidth === 473 && logo.naturalHeight === 473 && logo.sha256 === sourceHashes.get(logo.id) && logo.imageBackground === 'rgba(0, 0, 0, 0)' && logo.fallbackVisibility === 'hidden'), `${name} ${width}px: original transparent logos and ID mappings are preserved`); const file = `${value.label}-${width}.png`; value.screenshot = file; await page.screenshot({ path: path.join(output, file), fullPage: true }); screenshots.push(file); } if (name === 'department-colors') { const value = measurements.at(-1); check(value.variables.length === 30 && value.variables.every(item => item.value.includes('#')), 'Department Colors retains all six HEX and variable legends'); check(value.rowResults.length >= 18 && value.rowResults.every(item => item.contrastStatus && !item.text.includes('NaN')), 'Department Colors exposes current and unapplied candidate contrast results', value.rowResults); } if (name === 'logo') { const result = await page.evaluate(async () => { const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot; const mark = shadow.querySelector('.hm-orgchart-affiliation-mark'); const image = mark.querySelector('img'); const fallback = mark.querySelector('.hm-orgchart-affiliation-fallback'); const before = { label: mark.getAttribute('aria-label'), visibility: getComputedStyle(fallback).visibility }; image.dispatchEvent(new Event('error')); return { before, after: { label: mark.getAttribute('aria-label'), visibility: getComputedStyle(fallback).visibility, available: mark.dataset.logoAvailable, text: fallback.textContent, imageRemoved: !mark.querySelector('img') } }; }); check(result.before.visibility === 'hidden' && result.after.visibility === 'visible' && result.after.available === 'false' && result.after.text && result.after.imageRemoved && result.after.label === result.before.label, 'transparent logo change retains text fallback and accessible company name on error', result); } await page.close(); } for (const [transport, server] of [['html', servers[1]], ['react', servers[2]]]) { const page = await context.newPage(); captureErrors(page, transport); await page.goto(`${server.origin}/p1.html`); await ready(page); for (const width of [390, 768, 1280, 1440]) { await page.setViewportSize({ width, height: 1000 }); await ready(page); const value = compactSources(await measure(page)); value.label = transport; value.width = width; measurements.push(value); check(value.documentWidth <= width + 1 && value.textIssues.length === 0 && value.navigation === 0, `${transport} ${width}px: logo style change preserves layout and text`); check(value.logos.every(logo => logo.loaded && logo.fit === 'contain' && logo.imageBackground === 'rgba(0, 0, 0, 0)' && logo.fallbackVisibility === 'hidden'), `${transport} ${width}px: transparent logo layer applied inside Shadow DOM`); const file = `${transport}-${width}.png`; value.screenshot = file; await page.screenshot({ path: path.join(output, file), fullPage: true }); screenshots.push(file); } 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.cardStyles) === JSON.stringify(react.cardStyles) && JSON.stringify(html.logos.map(logo => [logo.id, logo.sha256, logo.fit, logo.imageBackground])) === JSON.stringify(react.logos.map(logo => [logo.id, logo.sha256, logo.fit, logo.imageBackground])), `${width}px: HTML and React retain identical Core card colors and logo rendering`); } check(errors.length === 0, 'changed five Stories and theme-free HTML/React emit no console/runtime errors', errors); } catch (error) { results.push({ name: 'quality harness completion', status: 'FAIL', detail: error.stack }); } finally { await writeFile(path.join(output, 'browser.json'), JSON.stringify({ browser: browser?.version() ?? null, results, errors, measurements, screenshots, reused: ['Core22/22', 'HTML69/69', 'React79/79', 'P1 selector7/7', 'P1 successful196 plus console/menu4/4'], limits: ['No data/lifecycle regression suites repeated', 'Contrast model and pixel samples are reported separately; no all-screen WCAG certification'] }, null, 2)); if (browser) await browser.close(); for (const { server } of servers) await new Promise(resolve => server.close(resolve)); const failed = results.filter(result => result.status === 'FAIL'); if (failed.length) process.exitCode = 1; console.log(JSON.stringify({ passed: results.filter(result => result.status === 'PASS').length, failed, report: path.join(output, 'browser.json') }, null, 2)); }