206 lines
17 KiB
JavaScript
206 lines
17 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 { 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('<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><circle cx="20" cy="20" r="20" fill="#45639f"/></svg>');
|
|
}
|
|
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;
|