569 lines
53 KiB
JavaScript
569 lines
53 KiB
JavaScript
import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
|
||
import http from 'node:http';
|
||
import path from 'node:path';
|
||
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
// Verification tooling is supplied by the environment, never shipped in Core.
|
||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||
const output = path.join(root, 'test-results/org-chart-p1');
|
||
await mkdir(output, { recursive: true });
|
||
const fixture = JSON.parse(await readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
|
||
await loadPlaywright();
|
||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.ttf': 'font/ttf', '.woff2': 'font/woff2', '.png': 'image/png', '.svg': 'image/svg+xml' };
|
||
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)) { response.writeHead(403).end(); return; }
|
||
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}` };
|
||
}
|
||
|
||
const results = [], errors = [], metrics = [], snapshots = [];
|
||
const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
|
||
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
||
const clone = data => structuredClone(data);
|
||
const dataWith = (data, version) => { const next = clone(data); next.metadata.dataVersion = version; return next; };
|
||
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, transport, version) {
|
||
await page.waitForFunction(({ transport, version }) => {
|
||
const api = transport === 'vanilla' ? window.OrgChartP1 : window.OrgChartP1React;
|
||
const state = transport === 'vanilla' ? api?.chart?.getState() : api?.events?.at(-1);
|
||
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
|
||
return state?.status === 'ready' && state.dataVersion === version && shadow?.querySelector('.hm-orgchart-composition .node');
|
||
}, { transport, version }, { timeout: 20000 });
|
||
await page.evaluate(async () => {
|
||
await document.fonts.ready;
|
||
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
|
||
await Promise.all([...shadow.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(image => image.complete ? Promise.resolve() : new Promise(resolve => {
|
||
image.addEventListener('load', resolve, { once: true }); image.addEventListener('error', resolve, { once: true });
|
||
})));
|
||
});
|
||
}
|
||
async function setData(page, transport, data) {
|
||
await page.evaluate(async ({ transport, data }) => {
|
||
const api = transport === 'vanilla' ? window.OrgChartP1 : window.OrgChartP1React;
|
||
await api.setData(data);
|
||
}, { transport, data });
|
||
await ready(page, transport, data.metadata.dataVersion);
|
||
}
|
||
|
||
/** Transport-neutral snapshot: no generated IDs, stylesheet hashes or host URLs. */
|
||
async function snapshot(page) {
|
||
return page.evaluate(() => {
|
||
const host = document.querySelector('.hm-orgchart-host');
|
||
const shadow = host.shadowRoot;
|
||
const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
|
||
const employees = scope => [...scope.querySelectorAll('.hm-orgchart-employee')].map(card => ({
|
||
id: card.dataset.employeeId, kind: card.dataset.cardKind, status: card.dataset.workStatus,
|
||
name: text(card.querySelector('.hm-orgchart-person-name')), position: text(card.querySelector('.hm-orgchart-person-role')),
|
||
jobTitle: text(card.querySelector('.hm-orgchart-job-title')),
|
||
affiliation: card.querySelector('.hm-orgchart-affiliation-mark')?.dataset.affiliationId ?? null,
|
||
}));
|
||
return {
|
||
heading: text(shadow.querySelector('.hm-orgchart-heading')),
|
||
title: text(shadow.querySelector('.hm-orgchart-title')),
|
||
hub: text(shadow.querySelector('.hm-orgchart-hub')),
|
||
nodes: [...shadow.querySelectorAll('.node')].map(node => {
|
||
const hierarchy = node.closest('.hierarchy');
|
||
const parent = hierarchy?.parentElement?.closest('.hierarchy')?.querySelector(':scope > .node');
|
||
return { id: node.dataset.organizationId, type: node.dataset.organizationType, depth: Number(node.dataset.depth), parentId: parent?.dataset.organizationId ?? null,
|
||
name: text(node.querySelector('.hm-orgchart-organization-name')), employees: employees(node) };
|
||
}),
|
||
topLeaders: employees(shadow.querySelector('.hm-orgchart-top-leaders') ?? document.createElement('div')),
|
||
components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component),
|
||
positions: shadow.querySelector('.hm-orgchart-position-summary') ? {
|
||
total: text(shadow.querySelector('.hm-orgchart-position-summary .hm-orgchart-raw-total')),
|
||
policy: [...shadow.querySelectorAll('.hm-orgchart-position-summary .hm-orgchart-policy')].map(text),
|
||
headers: [...shadow.querySelectorAll('.hm-orgchart-position-table thead th')].map(text),
|
||
rows: [...shadow.querySelectorAll('.hm-orgchart-position-table tbody tr')].map(row => ({ id: row.dataset.organizationId, name: text(row.querySelector('th')), cells: [...row.querySelectorAll('td')].map(text) })),
|
||
totals: [...shadow.querySelectorAll('.hm-orgchart-position-table tfoot td')].map(text),
|
||
statuses: text(shadow.querySelector('.hm-orgchart-status-totals')),
|
||
} : null,
|
||
affiliations: [...shadow.querySelectorAll('.hm-orgchart-affiliation-item')].map(item => ({
|
||
id: item.dataset.affiliationId, count: text(item.querySelector('dd')),
|
||
label: item.querySelector('.hm-orgchart-affiliation-mark')?.getAttribute('aria-label'),
|
||
logo: item.querySelector('.hm-orgchart-affiliation-mark img')?.getAttribute('src') ?? null,
|
||
})),
|
||
affiliationTotal: text(shadow.querySelector('.hm-orgchart-affiliation-summary .hm-orgchart-raw-total')),
|
||
statuses: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(group => ({ status: group.dataset.workStatus,
|
||
heading: text(group.querySelector('h3')), employees: employees(group), empty: text(group.querySelector('.hm-orgchart-content-empty')) })),
|
||
};
|
||
});
|
||
}
|
||
|
||
function expectedCounts(data) {
|
||
const employees = [...new Map(data.employees.map(employee => [employee.id, employee])).values()];
|
||
const organizations = new Map(data.organizations.map(org => [org.id, org]));
|
||
const positions = new Map(), affiliations = new Map(data.affiliations.map(item => [item.id, 0]));
|
||
const direct = new Map(data.organizations.map(org => [org.id, 0]));
|
||
const inclusive = new Map(direct), matrix = new Map(data.organizations.map(org => [org.id, new Map()]));
|
||
const statuses = { active: 0, dispatched: 0, onLeave: 0 };
|
||
for (const employee of employees) {
|
||
positions.set(employee.position, (positions.get(employee.position) ?? 0) + 1);
|
||
affiliations.set(employee.affiliationId, (affiliations.get(employee.affiliationId) ?? 0) + 1);
|
||
direct.set(employee.organizationId, direct.get(employee.organizationId) + 1);
|
||
const row = matrix.get(employee.organizationId); row.set(employee.position, (row.get(employee.position) ?? 0) + 1);
|
||
statuses[employee.workStatus]++;
|
||
let org = organizations.get(employee.organizationId);
|
||
while (org) { inclusive.set(org.id, inclusive.get(org.id) + 1); org = organizations.get(org.parentId); }
|
||
}
|
||
return { total: employees.length, employees, positions, affiliations, direct, inclusive, matrix, statuses };
|
||
}
|
||
function checkCounts(current, data, label) {
|
||
const expected = expectedCounts(data);
|
||
const positionNames = current.positions.headers.slice(1, -2);
|
||
check(current.positions.rows.length === data.organizations.length, `${label}: every organization has a cross-table row`);
|
||
check(current.positions.rows.every(row => {
|
||
const cells = row.cells.map(Number);
|
||
return positionNames.every((position, index) => cells[index] === (expected.matrix.get(row.id)?.get(position) ?? 0))
|
||
&& cells.at(-2) === expected.direct.get(row.id) && cells.at(-1) === expected.inclusive.get(row.id);
|
||
}), `${label}: direct organization×position cells and inclusive counts match unique primary membership`, current.positions.rows);
|
||
check(positionNames.every((position, index) => Number(current.positions.totals[index]) === expected.positions.get(position))
|
||
&& Number(current.positions.totals.at(-2)) === expected.total && current.positions.totals.at(-1) === '—', `${label}: direct totals are disjoint; inclusive counts are not added`, current.positions.totals);
|
||
check(current.positions.total.includes(`${expected.total}명`) && current.affiliationTotal.includes(`${expected.total}명`), `${label}: Position and Affiliation totals reuse the same Raw Count`);
|
||
check(current.affiliations.length === data.affiliations.length && current.affiliations.every(item => parseInt(item.count) === expected.affiliations.get(item.id)), `${label}: every declared affiliation count is correct`, current.affiliations.map(({ id, count }) => ({ id, count })));
|
||
check(current.statuses.every(group => {
|
||
const matching = expected.employees.filter(employee => employee.workStatus === group.status);
|
||
return parseInt(group.heading.split('·').at(-1)) === matching.length && same(group.employees.map(employee => employee.id).sort(), matching.map(employee => employee.id).sort());
|
||
}), `${label}: status groups use original employee IDs without inflating total`, current.statuses.map(group => ({ status: group.status, ids: group.employees.map(employee => employee.id) })));
|
||
check(current.positions.policy.some(text => text.includes('총원 미확정')) && current.positions.policy.some(text => text.includes('조직장') && text.includes('파견') && text.includes('휴직')), `${label}: Raw inclusion policy is visible; business total remains unapproved`);
|
||
}
|
||
|
||
async function measure(page) {
|
||
return page.evaluate(() => {
|
||
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host');
|
||
const shadow = host.shadowRoot;
|
||
const viewport = shadow.querySelector('.hm-orgchart-viewport');
|
||
const summary = shadow.querySelector('.hm-orgchart-summary-scroll');
|
||
const dimensions = element => element && ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX });
|
||
const textElements = [...shadow.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-organization-name')].filter(element => element.getBoundingClientRect().height > 0);
|
||
const cards = [...shadow.querySelectorAll('.node')].map(element => element.getBoundingClientRect()).filter(rect => rect.height > 0);
|
||
const sample = shadow.querySelector('.hm-orgchart-employee, .hm-orgchart-position-summary');
|
||
return {
|
||
document: dimensions(document.documentElement), host: dimensions(host), viewport: dimensions(viewport), summary: dimensions(summary),
|
||
ids: [...shadow.querySelectorAll('.hm-orgchart-domain-id')].map(element => ({ text: element.textContent, title: element.title, whiteSpace: getComputedStyle(element).whiteSpace, height: element.clientHeight, scrollHeight: element.scrollHeight })),
|
||
textFits: textElements.every(element => element.scrollWidth <= element.clientWidth + 1 && element.scrollHeight <= element.clientHeight + 1),
|
||
textIssues: textElements.filter(element => element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1).map(element => ({ className: element.className, text: element.textContent, ...dimensions(element) })),
|
||
nodeOverlap: cards.some((a, index) => cards.slice(index + 1).some(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)),
|
||
font: sample ? getComputedStyle(sample).fontFamily : null, foreground: sample ? getComputedStyle(sample).color : null,
|
||
background: sample ? getComputedStyle(sample).backgroundColor : null, radius: sample ? getComputedStyle(sample).borderRadius : null,
|
||
tokens: [...getComputedStyle(shadow.querySelector('.hm-orgchart-shell'))].filter(key => key.startsWith('--hm-orgchart-')).map(key => ({ key, value: getComputedStyle(shadow.querySelector('.hm-orgchart-shell')).getPropertyValue(key).trim() })),
|
||
cssLoaded: [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet),
|
||
portraitsFallback: [...shadow.querySelectorAll('.hm-orgchart-avatar')].every(avatar => avatar.textContent.trim() && !avatar.querySelector('img')),
|
||
logos: [...shadow.querySelectorAll('.hm-orgchart-affiliation-item .hm-orgchart-affiliation-mark')].map(mark => ({ id: mark.dataset.affiliationId, available: mark.dataset.logoAvailable, src: mark.querySelector('img')?.getAttribute('src'), loaded: !!mark.querySelector('img')?.complete && mark.querySelector('img').naturalWidth > 0 })),
|
||
navigationCount: shadow.querySelectorAll('nav, .hm-dashboard-header, [data-slot=sidebar], .hm-gnb, .hm-lnb').length,
|
||
};
|
||
});
|
||
}
|
||
|
||
async function styleIsolation(page, transport) {
|
||
const evidence = await page.evaluate(async () => {
|
||
const host = document.querySelector('.hm-orgchart-host');
|
||
const shadow = host.shadowRoot;
|
||
const sample = shadow.querySelector('.hm-orgchart-employee[data-card-kind=employee]');
|
||
const signature = () => {
|
||
const style = getComputedStyle(sample);
|
||
return { foreground: style.color, background: style.backgroundColor, font: style.fontFamily, radius: style.borderRadius, border: style.borderTopColor };
|
||
};
|
||
const initial = signature();
|
||
// Document CSS must be optional; private Shadow stylesheet links stay mounted.
|
||
const documentLinks = [...document.querySelectorAll('link[rel=stylesheet]')];
|
||
documentLinks.forEach(link => link.remove());
|
||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||
const withoutTheme = signature();
|
||
for (const [property, value] of [['--hm-sem-bg-surface', '#ff0000'], ['--hm-sem-fg-primary', '#ff0000'], ['--hm-radius-container', '99px'], ['--hm-font-family-base', 'monospace']]) host.style.setProperty(property, value);
|
||
const alteredHM = signature();
|
||
const sentinel = document.createElement('span');
|
||
sentinel.textContent = 'Host style isolation sentinel';
|
||
sentinel.style.color = 'rgb(55, 66, 77)'; sentinel.style.backgroundColor = 'rgb(88, 99, 110)';
|
||
document.body.append(sentinel);
|
||
const sentinelBefore = { color: getComputedStyle(sentinel).color, background: getComputedStyle(sentinel).backgroundColor };
|
||
// Public inputs intentionally remain undeclared; defaults resolve on the
|
||
// shadow shell so a host input can inherit without being masked by :host.
|
||
const token = '--hm-orgchart-bg-surface';
|
||
host.style.setProperty(token, 'rgb(7, 23, 41)');
|
||
const scopedOverride = signature();
|
||
const sentinelAfter = { color: getComputedStyle(sentinel).color, background: getComputedStyle(sentinel).backgroundColor };
|
||
if (token) host.style.removeProperty(token);
|
||
for (const property of ['--hm-sem-bg-surface', '--hm-sem-fg-primary', '--hm-radius-container', '--hm-font-family-base']) host.style.removeProperty(property);
|
||
sentinel.remove();
|
||
await Promise.all(documentLinks.map(link => new Promise(resolve => {
|
||
link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', resolve, { once: true }); document.head.append(link);
|
||
})));
|
||
return { initial, withoutTheme, alteredHM, scopedOverride, token, sentinelBefore, sentinelAfter, restored: signature(), removedDocumentStyles: documentLinks.length };
|
||
});
|
||
metrics.push({ transport, stage: 'private-style-isolation', ...evidence });
|
||
check(same(evidence.initial, evidence.withoutTheme), `${transport}: Core style is unchanged without host HM Theme CSS`, evidence);
|
||
check(same(evidence.withoutTheme, evidence.alteredHM), `${transport}: unrelated host HM color/radius/font variables cannot change private OrgChart style`, evidence);
|
||
check(evidence.token && evidence.scopedOverride.background === 'rgb(7, 23, 41)' && same(evidence.sentinelBefore, evidence.sentinelAfter), `${transport}: scoped OrgChart surface override affects its instance only`, evidence);
|
||
check(same(evidence.initial, evidence.restored), `${transport}: removing the scoped override restores standalone defaults`, evidence);
|
||
}
|
||
|
||
async function standaloneFactories(page) {
|
||
const evidence = await page.evaluate(async data => {
|
||
const api = window.OrgChartP1.api;
|
||
const stylesheetUrls = window.OrgChartP1.stylesheetUrls;
|
||
const existing = document.querySelector('.hm-orgchart-host');
|
||
const originalBackground = getComputedStyle(existing.shadowRoot.querySelector('.hm-orgchart-employee[data-card-kind=employee]')).backgroundColor;
|
||
const results = [];
|
||
for (const [factory, selector] of [['createPositionSummary', '.hm-orgchart-position-summary'], ['createAffiliationSummary', '.hm-orgchart-affiliation-summary'], ['createStatusSummary', '.hm-orgchart-status-summary']]) {
|
||
const container = document.createElement('div'); container.style.maxWidth = '760px'; document.body.append(container);
|
||
const events = [];
|
||
const handle = api[factory]({ container, dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrl: stylesheetUrls.core, onStateChange: state => events.push(state) });
|
||
await handle.mount();
|
||
const shadow = container.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||
const mounted = { state: handle.getState().status, total: handle.getState().summary?.raw.total,
|
||
modules: shadow.querySelectorAll(selector).length, nodes: shadow.querySelectorAll('.node').length,
|
||
foreign: shadow.querySelectorAll('.hm-orgchart-composition, .hm-orgchart-heading, .hm-orgchart-hub, .hm-orgchart-viewport').length,
|
||
links: [...shadow.querySelectorAll('link')].map(link => link.getAttribute('href')),
|
||
headings: shadow.textContent,
|
||
};
|
||
const zero = structuredClone(data); zero.metadata.dataVersion = `${factory}-zero`;
|
||
zero.employees = []; for (const org of zero.organizations) org.leaderId = null;
|
||
await handle.update(zero);
|
||
const currentShadow = container.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||
const updated = { state: handle.getState().status, version: handle.getState().dataVersion, total: handle.getState().summary?.raw.total,
|
||
employees: currentShadow.querySelectorAll('[data-employee-id]').length,
|
||
affiliations: [...currentShadow.querySelectorAll('.hm-orgchart-affiliation-count')].map(element => element.textContent),
|
||
emptyGroups: [...currentShadow.querySelectorAll('.hm-orgchart-content-empty')].map(element => element.textContent),
|
||
};
|
||
const sibling = document.createElement('p'); sibling.textContent = 'Host-owned sibling'; container.append(sibling);
|
||
handle.destroy(); handle.destroy();
|
||
results.push({ factory, mounted, updated, eventStatuses: events.map(state => state.status),
|
||
destroyed: handle.getState().status, remaining: container.children.length, siblingPreserved: sibling.isConnected });
|
||
container.remove();
|
||
}
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const tree = api.createOrgChartTree({ container, dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrls });
|
||
await tree.mount();
|
||
const treeShadow = container.querySelector('.hm-orgchart-host').shadowRoot;
|
||
const onlyTree = { ready: tree.getState().status, nodes: treeShadow.querySelectorAll('.node').length,
|
||
employeeIDs: [...new Set([...treeShadow.querySelectorAll('[data-employee-id]')].map(element => element.dataset.employeeId))].sort(),
|
||
forbidden: treeShadow.querySelectorAll('.hm-orgchart-heading,.hm-orgchart-hub,.hm-orgchart-composition,.hm-orgchart-position-summary,.hm-orgchart-affiliation-summary,.hm-orgchart-status-summary').length,
|
||
viewport: treeShadow.querySelectorAll('.hm-orgchart-viewport').length,
|
||
};
|
||
const moved = structuredClone(data); moved.metadata.dataVersion = 'standalone-tree-updated';
|
||
moved.employees.find(employee => employee.id === 'mock-employee-01').organizationId = 'solution-sales-1';
|
||
await tree.update(moved);
|
||
const nextShadow = container.querySelector('.hm-orgchart-host').shadowRoot;
|
||
onlyTree.updatedVersion = tree.getState().dataVersion;
|
||
onlyTree.employeeMoved = !![...nextShadow.querySelectorAll('.node')].find(node => node.dataset.organizationId === 'solution-sales-1')?.querySelector('[data-employee-id="mock-employee-01"]');
|
||
tree.destroy(); onlyTree.destroyed = container.children.length === 0; container.remove();
|
||
const existingBackground = getComputedStyle(existing.shadowRoot.querySelector('.hm-orgchart-employee[data-card-kind=employee]')).backgroundColor;
|
||
return { summaries: results, tree: onlyTree, originalBackground, existingBackground };
|
||
}, fixture);
|
||
metrics.push({ stage: 'standalone-public-factories', ...evidence });
|
||
for (const item of evidence.summaries) {
|
||
check(item.mounted.state === 'ready' && item.mounted.total === 41 && item.mounted.modules === 1 && item.mounted.nodes === 0 && item.mounted.foreign === 0 && item.mounted.links.length === 1, `${item.factory}: public factory mounts only its own summary without engine, heading or composition`, item.mounted);
|
||
check(item.updated.state === 'ready' && item.updated.version === `${item.factory}-zero` && item.updated.total === 0 && item.updated.employees === 0
|
||
&& (item.factory !== 'createAffiliationSummary' || item.updated.affiliations.length === 7 && item.updated.affiliations.every(text => text === '0명'))
|
||
&& (item.factory !== 'createStatusSummary' || item.updated.emptyGroups.length === 2), `${item.factory}: update refreshes shared Raw Count and removes stale cards`, item.updated);
|
||
check(item.destroyed === 'destroyed' && item.remaining === 1 && item.siblingPreserved, `${item.factory}: destroy releases only owned auxiliary DOM and preserves host siblings`, item);
|
||
}
|
||
check(evidence.tree.ready === 'ready' && evidence.tree.nodes === 18 && evidence.tree.viewport === 1 && evidence.tree.forbidden === 0
|
||
&& same(evidence.tree.employeeIDs, fixture.employees.map(employee => employee.id).sort()), 'createOrgChartTree: public standalone tree preserves all source employees with no heading/hub/summary composition', evidence.tree);
|
||
check(evidence.tree.updatedVersion === 'standalone-tree-updated' && evidence.tree.employeeMoved && evidence.tree.destroyed, 'createOrgChartTree: standalone update and destroy retain engine lifecycle', evidence.tree);
|
||
check(evidence.originalBackground === evidence.existingBackground, 'standalone factories do not alter the existing full-composition instance');
|
||
}
|
||
|
||
const expectedOldIds = ['basic-organization', 'organization-hierarchy', 'employee-cards', 'organization-summary', 'json-data-update', 'loading-empty-error', 'responsive-container', 'multiple-instances'].map(id => `orgchart-content--${id}`);
|
||
const canonicalStoryIds = ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition'].map(id => `orgchart--${id}`);
|
||
const internalStoryIds = [
|
||
...['business-management', 'business-planning', 'talent-growth', 'integrated-system', 'design-planning', 'solution-sales', 'top-leader', 'organization-leader', 'employee'].map(id => `orgchart-components-cards--${id}`),
|
||
...['position-summary', 'affiliation-summary', 'status-summary'].map(id => `orgchart-components-summaries--${id}`),
|
||
];
|
||
// Canonical entries cover every component once; only the three distinct employee
|
||
// kinds additionally need their internal preview. Registry preservation is static.
|
||
const renderedStoryIds = [...canonicalStoryIds, ...internalStoryIds.slice(6, 9)];
|
||
const measuredGradients = [
|
||
['#194d82', '#1d395c', 'rgb(25, 77, 130)', 'rgb(29, 57, 92)'],
|
||
['#2164a8', '#254975', 'rgb(33, 100, 168)', 'rgb(37, 73, 117)'],
|
||
['#0f6ace', '#044a94', 'rgb(15, 106, 206)', 'rgb(4, 74, 148)'],
|
||
['#8c67b6', '#5d3494', 'rgb(140, 103, 182)', 'rgb(93, 52, 148)'],
|
||
['#09a7a2', '#00847f', 'rgb(9, 167, 162)', 'rgb(0, 132, 127)'],
|
||
['#5a963c', '#3f7629', 'rgb(90, 150, 60)', 'rgb(63, 118, 41)'],
|
||
];
|
||
|
||
/** A Story metadata fix only invalidates its console checks. Preserve the full
|
||
* browser.json run and recheck those five iframes plus the newly requested
|
||
* actual manager sidebar, without repeating Core/React/summary/width checks. */
|
||
async function recheckStorybookConsole() {
|
||
const retryResults = [], retryErrors = [], rendered = [];
|
||
const checkRetry = (condition, name, detail) => retryResults.push({ name, status: condition ? 'PASS' : 'FAIL', detail });
|
||
const server = await serve(path.join(root, 'storybook-static'));
|
||
let retryBrowser;
|
||
let sidebar;
|
||
try {
|
||
retryBrowser = await launchChromium({ headless: true });
|
||
const context = await retryBrowser.newContext({ viewport: { width: 1440, height: 1080 } });
|
||
const iframe = await context.newPage();
|
||
iframe.on('pageerror', error => retryErrors.push({ surface: 'iframe', type: 'pageerror', message: error.message }));
|
||
iframe.on('console', message => { if (message.type() === 'error') retryErrors.push({ surface: 'iframe', type: 'console', message: message.text() }); });
|
||
for (const id of canonicalStoryIds) {
|
||
await iframe.goto(`${server.origin}/iframe.html?id=${id}&viewMode=story`);
|
||
await iframe.waitForFunction(() => {
|
||
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
|
||
return shadow?.querySelector('[data-component]') && [...shadow.querySelectorAll('link')].every(link => link.sheet);
|
||
}, undefined, { timeout: 20000 });
|
||
rendered.push(id);
|
||
}
|
||
checkRetry(retryErrors.filter(error => error.surface === 'iframe').length === 0 && rendered.length === 5, 'five corrected canonical Story iframes emit no console/runtime errors', { rendered, errors: retryErrors.filter(error => error.surface === 'iframe') });
|
||
const manager = await context.newPage();
|
||
manager.on('pageerror', error => retryErrors.push({ surface: 'manager', type: 'pageerror', message: error.message }));
|
||
manager.on('console', message => { if (message.type() === 'error') retryErrors.push({ surface: 'manager', type: 'console', message: message.text() }); });
|
||
await manager.goto(`${server.origin}/?path=/story/orgchart--full-composition`);
|
||
await manager.waitForSelector('#storybook-explorer-tree [data-item-id="orgchart--full-composition"]', { timeout: 20000 });
|
||
sidebar = await manager.evaluate(() => [...document.querySelectorAll('#storybook-explorer-tree [data-item-id]')].filter(row => row.dataset.itemId.startsWith('orgchart') && row.getBoundingClientRect().height > 0).map(row => ({ id: row.dataset.itemId, label: row.textContent.trim() })));
|
||
const visibleStories = sidebar.filter(row => row.id.startsWith('orgchart--')).map(row => row.id).sort();
|
||
checkRetry(same(visibleStories, [...canonicalStoryIds].sort()), 'actual Storybook sidebar exposes exactly the approved five OrgChart Story entries', sidebar);
|
||
checkRetry(sidebar.every(row => !row.id.startsWith('orgchart-content') && !row.id.startsWith('orgchart-components')),
|
||
'actual default sidebar hides the original/internal reference groups while preserving their direct routes', sidebar);
|
||
await manager.screenshot({ path: path.join(output, 'storybook-sidebar.png'), fullPage: true });
|
||
checkRetry(retryErrors.filter(error => error.surface === 'manager').length === 0, 'actual Storybook manager and its preview emit no console/runtime errors', retryErrors.filter(error => error.surface === 'manager'));
|
||
} catch (error) {
|
||
retryResults.push({ name: 'selected Storybook console/sidebar recheck completion', status: 'FAIL', detail: error.stack });
|
||
} finally {
|
||
const report = { browser: retryBrowser?.version() ?? null, mode: 'storybook-console-only', preservedInitialReport: path.join(output, 'browser.json'), results: retryResults, errors: retryErrors, rendered, sidebar,
|
||
limits: ['196 previously successful Core/React/component checks were not repeated', 'This run only rechecks corrected Story metadata and newly verifies the default manager sidebar'] };
|
||
const reportPath = path.join(output, 'storybook-console-recheck.json');
|
||
await writeFile(reportPath, JSON.stringify(report, null, 2));
|
||
if (retryBrowser) await retryBrowser.close();
|
||
await new Promise(resolve => server.server.close(resolve));
|
||
const failed = retryResults.filter(result => result.status === 'FAIL');
|
||
if (failed.length) process.exitCode = 1;
|
||
console.log(JSON.stringify({ passed: retryResults.filter(result => result.status === 'PASS').length, failed, report: reportPath, sidebarScreenshot: path.join(output, 'storybook-sidebar.png') }, null, 2));
|
||
}
|
||
}
|
||
|
||
if (process.argv.includes('--storybook-console-only')) {
|
||
await recheckStorybookConsole();
|
||
process.exit(process.exitCode ?? 0);
|
||
}
|
||
|
||
let browser;
|
||
const servers = [];
|
||
try {
|
||
const productionStyles = await readFile(path.join(root, 'packages/org-chart/dist/style.css'), 'utf8');
|
||
const foreignHMVariables = [...productionStyles.matchAll(/var\(\s*(--hm-(?!orgchart-)[a-z0-9-]+)/g)].map(match => match[1]);
|
||
check(foreignHMVariables.length === 0, 'shipped Core stylesheet references only the private OrgChart namespace rather than host HM Theme variables', [...new Set(foreignHMVariables)]);
|
||
const vanilla = await serve(path.join(root, 'examples/org-chart/dist')); servers.push(vanilla);
|
||
const react = await serve(path.join(root, 'examples/org-chart-react/dist')); servers.push(react);
|
||
const storybook = await serve(path.join(root, 'storybook-static')); servers.push(storybook);
|
||
browser = await launchChromium({ headless: true });
|
||
const context = await browser.newContext({ viewport: { width: 1440, height: 1080 } });
|
||
const pages = new Map();
|
||
for (const [transport, origin] of [['vanilla', vanilla.origin], ['react', react.origin]]) {
|
||
const page = await context.newPage(); pages.set(transport, page); captureErrors(page, transport);
|
||
await page.goto(`${origin}/p1.html`);
|
||
await ready(page, transport, fixture.metadata.dataVersion);
|
||
const initial = await snapshot(page); snapshots.push({ transport, stage: 'initial', value: initial });
|
||
check(initial.nodes.length === 18 && expectedCounts(fixture).total === 41, `${transport}: P1 fixture contains 18 real-name organizations and 41 virtual employee IDs`);
|
||
check(initial.title === fixture.presentation.title && initial.hub === fixture.presentation.hubText, `${transport}: heading and independent hub come from JSON`);
|
||
check(initial.nodes.every(node => fixture.organizations.find(org => org.id === node.id)?.parentId === node.parentId), `${transport}: rendered hierarchy retains every original parentId`, initial.nodes.map(({ id, parentId }) => ({ id, parentId })));
|
||
const topEmployee = fixture.organizations.find(org => org.parentId === null)?.leaderId;
|
||
check(initial.topLeaders.length === 1 && initial.topLeaders[0].id === topEmployee && initial.topLeaders[0].kind === 'topLeader'
|
||
&& initial.nodes.every(node => node.employees.every(employee => employee.id !== topEmployee)), `${transport}: top leader is independent and is not duplicated in the tree`);
|
||
check(initial.nodes.filter(node => node.depth === 1).length === 6 && initial.components.includes('OrgChartTree'), `${transport}: six primary organization groups preserve the engine tree boundary`);
|
||
check(['PositionSummary', 'AffiliationSummary', 'StatusSummary'].every(name => initial.components.filter(value => value === name).length === 1), `${transport}: three independent summary modules are composed once`);
|
||
checkCounts(initial, fixture, `${transport}: initial`);
|
||
for (const width of [390, 768, 1280, 1440]) {
|
||
await page.setViewportSize({ width, height: 1080 });
|
||
const measurement = await measure(page); metrics.push({ transport, stage: 'initial', width, ...measurement });
|
||
check(measurement.document.scrollWidth <= measurement.document.clientWidth + 1, `${transport}: ${width}px document has no horizontal overflow`, measurement.document);
|
||
check(measurement.viewport.scrollWidth > measurement.viewport.clientWidth && measurement.viewport.overflowX === 'auto', `${transport}: ${width}px wide tree uses internal scrolling`, measurement.viewport);
|
||
check(measurement.textFits && !measurement.nodeOverlap && measurement.ids.every(item => item.whiteSpace === 'nowrap' && item.title === item.text && item.scrollHeight <= item.height + 1), `${transport}: ${width}px text bounds, node separation and one-line IDs`, { textIssues: measurement.textIssues, overlap: measurement.nodeOverlap, ids: measurement.ids });
|
||
check(measurement.cssLoaded && measurement.tokens.length >= 10 && measurement.tokens.every(item => item.value) && measurement.font && measurement.background !== 'rgba(0, 0, 0, 0)' && measurement.radius !== '0px', `${transport}: ${width}px independent OrgChart defaults and loaded Shadow styles`, { tokens: measurement.tokens, font: measurement.font, background: measurement.background, radius: measurement.radius });
|
||
check(measurement.logos.length === 7 && measurement.logos.every(item => item.available === 'true' && item.loaded && item.src.startsWith('data:image/')), `${transport}: ${width}px all seven acquired affiliation logos load`, measurement.logos.map(({ id, available, loaded }) => ({ id, available, loaded })));
|
||
check(measurement.portraitsFallback && measurement.navigationCount === 0, `${transport}: ${width}px virtual portrait fallback and content-only UI`);
|
||
await page.screenshot({ path: path.join(output, `${transport}-${width}.png`), fullPage: true });
|
||
}
|
||
await page.setViewportSize({ width: 1440, height: 1080 });
|
||
await styleIsolation(page, transport);
|
||
const toggle = page.locator('.hm-orgchart-toggle').first();
|
||
await toggle.focus(); await toggle.press('Enter');
|
||
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding'));
|
||
check(await toggle.getAttribute('aria-expanded') === 'false' && await toggle.evaluate(element => element.getRootNode().activeElement === element), `${transport}: keyboard Enter collapses hierarchy and keeps focus`);
|
||
await toggle.press('Space');
|
||
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding'));
|
||
check(await toggle.getAttribute('aria-expanded') === 'true', `${transport}: keyboard Space expands hierarchy`);
|
||
}
|
||
check(same(snapshots.find(item => item.transport === 'vanilla').value, snapshots.find(item => item.transport === 'react').value), 'HTML and React initial Core DOM and automatic summary snapshots are identical');
|
||
|
||
const changed = dataWith(fixture, 'p1-browser-json-v2');
|
||
const moved = changed.employees.find(employee => employee.id === 'mock-employee-01');
|
||
moved.organizationId = 'solution-sales-1'; moved.affiliationId = 'halla'; moved.workStatus = 'onLeave';
|
||
moved.name = '가상직원 긴 이름과 콘텐츠 줄바꿈 검증 01'; moved.position = '가상 장기 프로젝트 연구 및 운영 담당 수석';
|
||
moved.jobTitle = '가상 프로젝트 담당';
|
||
changed.presentation.title = 'JSON 갱신 · 가상 직원 이동 및 상태 변경';
|
||
for (const [transport, page] of pages) {
|
||
await page.locator('.hm-orgchart-summary-scroll').focus();
|
||
await setData(page, transport, changed);
|
||
check(await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.classList.contains('hm-orgchart-viewport')), `${transport}: summary keyboard focus returns to stable tree after composition regeneration`);
|
||
const current = await snapshot(page); snapshots.push({ transport, stage: 'changed', value: current });
|
||
check(current.title === changed.presentation.title && current.nodes.find(node => node.id === moved.organizationId)?.employees.some(employee => employee.id === moved.id && employee.name === moved.name && employee.position === moved.position), `${transport}: changed JSON moves and updates the same employee card`);
|
||
checkCounts(current, changed, `${transport}: changed JSON`);
|
||
for (const width of [390, 768, 1280, 1440]) {
|
||
await page.setViewportSize({ width, height: 1080 });
|
||
const measurement = await measure(page); metrics.push({ transport, stage: 'long-text', width, ...measurement });
|
||
check(measurement.textFits && measurement.document.scrollWidth <= width + 1, `${transport}: ${width}px long employee name, position and job title fit with contained table scrolling`, { textIssues: measurement.textIssues, document: measurement.document, summary: measurement.summary });
|
||
}
|
||
check(current.statuses.find(group => group.status === 'onLeave').employees.some(employee => employee.id === moved.id) && !current.statuses.find(group => group.status === 'dispatched').employees.some(employee => employee.id === moved.id), `${transport}: state groups update without stale employee cards`);
|
||
// Flags select independent components rather than alter the employee source or counts.
|
||
const cases = [
|
||
['hide-position', { showSummary: false }, { position: false, affiliations: true, dispatched: true, onLeave: true }],
|
||
['hide-affiliation', { showAffiliations: false }, { position: true, affiliations: false, dispatched: true, onLeave: true }],
|
||
['hide-dispatched', { showDispatched: false }, { position: true, affiliations: true, dispatched: false, onLeave: true }],
|
||
['hide-onleave', { showOnLeave: false }, { position: true, affiliations: true, dispatched: true, onLeave: false }],
|
||
['hide-both-status', { showDispatched: false, showOnLeave: false }, { position: true, affiliations: true, dispatched: false, onLeave: false }],
|
||
];
|
||
for (const [name, flags, expected] of cases) {
|
||
const data = dataWith(changed, `${transport}-${name}`); Object.assign(data.presentation.groups, flags);
|
||
await setData(page, transport, data);
|
||
const current = await snapshot(page);
|
||
check(!!current.positions === expected.position && !!current.affiliations.length === expected.affiliations
|
||
&& current.statuses.some(group => group.status === 'dispatched') === expected.dispatched
|
||
&& current.statuses.some(group => group.status === 'onLeave') === expected.onLeave
|
||
&& current.components.includes('StatusSummary') === (expected.dispatched || expected.onLeave), `${transport}: ${name} changes only its independent component`, current.components);
|
||
check(!current.positions || Number(current.positions.totals.at(-2)) === 41, `${transport}: ${name} cannot alter Raw Count`);
|
||
}
|
||
const zeroAffiliation = dataWith(changed, `${transport}-zero-affiliation`);
|
||
for (const employee of zeroAffiliation.employees) if (employee.affiliationId === 'baron') employee.affiliationId = 'hanmac';
|
||
await setData(page, transport, zeroAffiliation);
|
||
check((await snapshot(page)).affiliations.find(item => item.id === 'baron')?.count === '0명', `${transport}: zero-count declared affiliations stay visible`);
|
||
const zeroStatus = dataWith(changed, `${transport}-zero-status`);
|
||
for (const employee of zeroStatus.employees) employee.workStatus = 'active';
|
||
await setData(page, transport, zeroStatus);
|
||
check((await snapshot(page)).statuses.every(group => group.employees.length === 0 && group.empty && group.heading.includes('0명')), `${transport}: enabled empty status groups have explicit empty content`);
|
||
// A successful HTTP response containing non-image bytes triggers img.error,
|
||
// avoiding expected network-console failures while testing the actual fallback.
|
||
await page.route('**/p1-invalid-portrait.png', route => route.fulfill({ status: 200, contentType: 'image/png', body: 'not a png' }));
|
||
const brokenImage = dataWith(changed, `${transport}-image-fallback`);
|
||
brokenImage.employees.find(employee => employee.id === moved.id).profileImage = './p1-invalid-portrait.png';
|
||
await setData(page, transport, brokenImage);
|
||
const brokenCards = page.locator(`.hm-orgchart-employee[data-employee-id="${moved.id}"]`);
|
||
for (let index = 0; index < await brokenCards.count(); index++) await brokenCards.nth(index).scrollIntoViewIfNeeded();
|
||
await page.waitForFunction(id => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(card => card.dataset.employeeId === id).every(card => !card.querySelector('.hm-orgchart-avatar img')), moved.id);
|
||
check(await page.evaluate(id => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(card => card.dataset.employeeId === id).every(card => card.querySelector('.hm-orgchart-avatar').textContent.trim()), moved.id), `${transport}: invalid profile images safely return to initials`);
|
||
await page.unroute('**/p1-invalid-portrait.png');
|
||
await setData(page, transport, dataWith(changed, 'p1-browser-final-changed'));
|
||
snapshots.push({ transport, stage: 'restored-changed', value: await snapshot(page) });
|
||
check(transport !== 'react' || await page.evaluate(() => window.OrgChartP1React.errors.length === 0), `${transport}: auxiliary regeneration has no lifecycle error`);
|
||
}
|
||
check(same(...snapshots.filter(item => item.stage === 'restored-changed').map(item => item.value)), 'HTML and React Core snapshots remain identical after JSON and independent visibility updates');
|
||
|
||
const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8'));
|
||
const old = Object.values(index.entries).filter(entry => entry.title === 'OrgChart/Content' && entry.type === 'story');
|
||
check(old.length === 8 && same(old.map(entry => entry.id).sort(), [...expectedOldIds].sort()) && old.every(entry => entry.tags.includes('internal')), 'all original eight Storybook entries remain registered as internal without re-running their baseline suite', old.map(entry => entry.id));
|
||
check(internalStoryIds.every(id => index.entries[id]?.type === 'story' && index.entries[id].tags.includes('internal')), 'twelve isolated card/summary Stories are preserved as internal references', internalStoryIds);
|
||
const visible = Object.values(index.entries).filter(entry => entry.title === 'OrgChart' && entry.type === 'story' && !entry.tags.includes('internal')).map(entry => entry.id).sort();
|
||
check(same(visible, [...canonicalStoryIds].sort()), 'canonical OrgChart offers exactly the five approved user-visible entries', visible);
|
||
const sb = await context.newPage(); captureErrors(sb, 'storybook');
|
||
for (const id of renderedStoryIds) {
|
||
if (!index.entries[id]) continue;
|
||
await sb.goto(`${storybook.origin}/iframe.html?id=${id}&viewMode=story`);
|
||
await sb.waitForFunction(() => {
|
||
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host');
|
||
const shadow = host?.shadowRoot;
|
||
return shadow?.querySelector('[data-component]') && [...shadow.querySelectorAll('link')].every(link => link.sheet);
|
||
}, undefined, { timeout: 20000 });
|
||
await sb.waitForFunction(() => [...document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-affiliation-mark img')].every(image => image.complete));
|
||
const component = await sb.evaluate(() => {
|
||
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host'), shadow = host.shadowRoot;
|
||
const organizationName = shadow.querySelector('.hm-orgchart-organization-name');
|
||
const node = shadow.querySelector('.node');
|
||
return { components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component), nodeCount: shadow.querySelectorAll('.node').length,
|
||
employees: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, kind: card.dataset.cardKind })),
|
||
gradients: organizationName ? { background: getComputedStyle(organizationName).backgroundImage, variable: getComputedStyle(node).getPropertyValue('--hm-orgchart-group-background').trim() } : null,
|
||
body: document.body.textContent, title: organizationName?.textContent,
|
||
hasPosition: !!shadow.querySelector('.hm-orgchart-position-summary'), hasAffiliation: !!shadow.querySelector('.hm-orgchart-affiliation-summary'), hasStatus: !!shadow.querySelector('.hm-orgchart-status-summary'),
|
||
linksLoaded: [...shadow.querySelectorAll('link')].every(link => link.sheet),
|
||
};
|
||
});
|
||
snapshots.push({ transport: 'storybook', stage: id, value: component });
|
||
check(component.linksLoaded, `${id}: real Core preview and Shadow CSS load`);
|
||
if (id === 'orgchart--position-summary' || id === 'orgchart--affiliation-summary') {
|
||
const kind = id.endsWith('position-summary') ? 'PositionSummary' : id.endsWith('affiliation-summary') ? 'AffiliationSummary' : 'StatusSummary';
|
||
check(component.nodeCount === 0 && same(component.components, [kind])
|
||
&& component.hasPosition === (kind === 'PositionSummary') && component.hasAffiliation === (kind === 'AffiliationSummary') && component.hasStatus === (kind === 'StatusSummary'), `${id}: standalone summary has exactly its Core module and no engine tree`, component.components);
|
||
} else if (id.endsWith('full-composition')) {
|
||
check(component.nodeCount === 18 && ['OrgChartTree', 'PositionSummary', 'AffiliationSummary', 'StatusSummary'].every(name => component.components.includes(name)), `${id}: composition contains shared tree plus all independent summaries`);
|
||
for (const width of [390, 768, 1280, 1440]) {
|
||
await sb.setViewportSize({ width, height: 1080 });
|
||
const measurement = await measure(sb); metrics.push({ transport: 'storybook', stage: id, width, ...measurement });
|
||
check(measurement.document.scrollWidth <= width + 1 && measurement.textFits && measurement.navigationCount === 0, `${id}: ${width}px content composition has no document overflow or clipped text`, { document: measurement.document, textIssues: measurement.textIssues });
|
||
await sb.screenshot({ path: path.join(output, `${id}-${width}.png`), fullPage: true });
|
||
}
|
||
await sb.setViewportSize({ width: 1440, height: 1080 });
|
||
await sb.getByRole('button', { name: 'PositionSummary 숨기기', exact: true }).click();
|
||
await sb.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-position-summary'));
|
||
check(await sb.locator('.hm-orgchart-affiliation-summary').count() === 1 && await sb.locator('.hm-orgchart-status-summary').count() === 1, `${id}: Story button independently hides PositionSummary`);
|
||
await sb.getByRole('button', { name: '가상 JSON v2 반영', exact: true }).click();
|
||
await sb.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('[data-work-status=onLeave] [data-employee-id]')].some(element => element.dataset.employeeId === 'mock-employee-01'));
|
||
check(await sb.locator('.hm-orgchart-affiliation-summary .hm-orgchart-raw-total').textContent() === 'Raw Count · 전체 41명', `${id}: Story JSON update reuses 41 unique virtual employees`);
|
||
} else if (id.endsWith('top-leader') || id.endsWith('organization-leader') || id.endsWith('--employee')) {
|
||
const kind = id.endsWith('top-leader') ? 'topLeader' : id.endsWith('organization-leader') ? 'leader' : 'employee';
|
||
check(component.nodeCount === 0 && component.employees.length === 1 && component.employees[0].kind === kind && same(component.components, ['EmployeeCard']), `${id}: standalone ${kind} uses one actual Core employee card`, component.employees);
|
||
} else if (id === 'orgchart--department-colors') {
|
||
await sb.waitForFunction(() => {
|
||
const values = [...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-color-value')];
|
||
return values.length === 30 && values.every(element => element.textContent.trim());
|
||
});
|
||
const colors = await sb.evaluate(() => [...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-color-preview[data-group-variant]')].map(row => ({
|
||
variant: Number(row.dataset.groupVariant),
|
||
text: row.textContent,
|
||
background: getComputedStyle(row.querySelector('[data-color-variable$="-gradient"]')).backgroundImage,
|
||
variable: getComputedStyle(row).getPropertyValue('--hm-orgchart-group-background').trim(),
|
||
declarations: [...row.querySelectorAll('[data-color-variable]')].map(element => ({ variable: element.dataset.colorVariable, property: element.dataset.cssProperty, applied: getComputedStyle(element).getPropertyValue(element.dataset.cssProperty), valueText: element.parentElement.querySelector('.hm-orgchart-color-value')?.textContent })),
|
||
card: (() => { const card = row.querySelector('.hm-orgchart-employee'); const style = getComputedStyle(card); return { background: style.backgroundColor, color: style.color, border: style.borderTopColor }; })(),
|
||
})));
|
||
check(colors.length === 6, `${id}: all six department color components are rendered`, colors);
|
||
for (const color of colors) {
|
||
const [start, end, rgbStart, rgbEnd] = measuredGradients[color.variant];
|
||
check(color.background.includes('linear-gradient') && color.background.includes(rgbStart) && color.background.includes(rgbEnd)
|
||
&& color.variable === `linear-gradient(180deg, ${start} 0%, ${end} 100%)`
|
||
&& color.text.includes(start) && color.text.includes(end) && color.declarations.length === 5
|
||
&& color.declarations.every(item => item.variable.startsWith('--hm-orgchart-') && item.valueText?.includes('#')), `${id}: variant ${color.variant} applies exact measured gradient and documents HEX/CSS variables`, color);
|
||
check(color.declarations.find(item => item.variable.endsWith('-card-background')).applied === color.card.background
|
||
&& color.declarations.find(item => item.variable.endsWith('-card-color')).applied === color.card.color
|
||
&& color.declarations.find(item => item.variable.endsWith('-border')).applied === color.card.border, `${id}: variant ${color.variant} legend values match the actual card styles`, color.card);
|
||
}
|
||
} else if (id === 'orgchart--logo') {
|
||
const logos = await sb.evaluate(() => {
|
||
const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot;
|
||
return { marks: [...shadow.querySelectorAll('.hm-orgchart-affiliation-mark')].map(mark => ({ id: mark.dataset.affiliationId, label: mark.getAttribute('aria-label'), src: mark.querySelector('img')?.getAttribute('src'), loaded: !!mark.querySelector('img')?.complete && mark.querySelector('img').naturalWidth > 0 })), text: shadow.textContent,
|
||
counts: shadow.querySelectorAll('.hm-orgchart-affiliation-count, .hm-orgchart-raw-total, .hm-orgchart-position-table').length };
|
||
});
|
||
check(component.nodeCount === 0 && component.components.includes('Logo') && logos.marks.length === 7 && logos.marks.every(mark => mark.loaded && mark.src.startsWith('data:image/') && mark.label)
|
||
&& fixture.affiliations.every(affiliation => logos.text.includes(affiliation.name)) && logos.counts === 0, `${id}: standalone Logo uses all seven named assets without statistics`, logos.marks.map(({ id, label, loaded }) => ({ id, label, loaded })));
|
||
}
|
||
}
|
||
await standaloneFactories(pages.get('vanilla'));
|
||
await pages.get('vanilla').evaluate(() => window.OrgChartP1.chart.destroy());
|
||
check(await pages.get('vanilla').locator('.hm-orgchart-host').count() === 0, 'destroy releases composed tree and all auxiliary summary DOM');
|
||
check(await pages.get('react').evaluate(() => window.OrgChartP1React.errors.length === 0), 'React wrapper lifecycle error boundary remains clear');
|
||
check(errors.length === 0, 'targeted P1 Vanilla, React and canonical/employee Stories have no console/runtime errors', errors);
|
||
} catch (error) {
|
||
results.push({ name: 'P1 browser harness completion', status: 'FAIL', detail: error.stack });
|
||
} finally {
|
||
const report = { browser: browser?.version() ?? null, results, errors, metrics, snapshots,
|
||
limits: ['Figma Dev Mode detailed properties NOT VERIFIED', 'Workdesk pixel parity NOT VERIFIED; gradient checks verify the recorded measured header sources only', 'Screen-reader announcements and heap/GC behavior NOT VERIFIED', 'Original 69/79 browser suites deliberately not repeated'] };
|
||
await writeFile(path.join(output, 'browser.json'), JSON.stringify(report, 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));
|
||
}
|