Files
HMDesign/examples/org-chart/verify-quality-p1.mjs
T

185 lines
15 KiB
JavaScript

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));
}