255 lines
26 KiB
JavaScript
255 lines
26 KiB
JavaScript
import http from 'node:http';
|
||
import path from 'node:path';
|
||
import fs from 'node:fs/promises';
|
||
import { createHash } from 'node:crypto';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
|
||
// P3.2 change-specific evidence. Existing P3.1 486-check evidence is preserved.
|
||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||
const output = path.join(root, 'test-results/org-chart-p3.2');
|
||
const baselineMode = process.argv.includes('--baseline');
|
||
const policiesOnly = process.argv.includes('--policies-only');
|
||
const fixtureBytes = await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'));
|
||
const fixture = JSON.parse(fixtureBytes);
|
||
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/index.mjs').href);
|
||
const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }];
|
||
const storyOrigin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6041';
|
||
const results = [], measurements = [], errors = [], servers = [];
|
||
const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
|
||
const near = (a, b, tolerance = .4) => Number.isFinite(a) && Math.abs(a - b) <= tolerance;
|
||
const sameBox = (a, b) => a && b && ['x', 'y', 'width', 'height'].every(k => near(a[k], b[k]));
|
||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' };
|
||
await fs.mkdir(output, { recursive: true });
|
||
const previous = baselineMode || policiesOnly ? null : JSON.parse(await fs.readFile(path.join(output, 'baseline-browser.json'), 'utf8'));
|
||
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 === '/__p32_link') return response.end('<!doctype html><link rel="icon" href="data:,"><p id="proof">Group link reached</p>');
|
||
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 fs.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 s = document.querySelector('.hm-orgchart-host')?.shadowRoot;
|
||
return s?.querySelector('.hm-orgchart-composition[data-display-mode="readOnly"] [data-card-asset]') && [...s.querySelectorAll('link[rel=stylesheet]')].every(l => l.sheet);
|
||
}, null, { timeout: 25000 });
|
||
await page.evaluate(async () => {
|
||
await document.fonts.load('600 18px "Noto Sans KR"', '홍 길 동');
|
||
await document.fonts.ready;
|
||
await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(i => i.decode().catch(() => {})));
|
||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||
});
|
||
}
|
||
async function measure(page) {
|
||
return page.evaluate(() => {
|
||
const s = document.querySelector('.hm-orgchart-host').shadowRoot, stage = s.querySelector('.hm-orgchart-stage');
|
||
const origin = stage.getBoundingClientRect(), scale = origin.width / 1920;
|
||
const rect = e => { const r = e.getBoundingClientRect(); return { x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }; };
|
||
const relative = (r, c) => ({ x: r.x - c.x, y: r.y - c.y, width: r.width, height: r.height });
|
||
const style = (e, props) => Object.fromEntries(props.map(p => [p, getComputedStyle(e)[p]]));
|
||
const typography = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'letterSpacing'];
|
||
const contained = (a, b, tolerance = .5) => a.x >= b.x - tolerance && a.y >= b.y - tolerance && a.x + a.width <= b.x + b.width + tolerance && a.y + a.height <= b.y + b.height + tolerance;
|
||
const overlap = (a, b) => a && b && Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > .5 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > .5;
|
||
const inks = new Map();
|
||
// Reuse the approved P3 logo test method: natural PNG alpha bounds (>16),
|
||
// transformed through object-fit:contain. Transparent image padding is excluded.
|
||
const logoPixels = img => {
|
||
if (!img?.naturalWidth) return null;
|
||
if (!inks.has(img.src)) {
|
||
const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
|
||
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
|
||
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||
let left = canvas.width, right = -1, top = canvas.height, bottom = -1;
|
||
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) if (pixels[(y * canvas.width + x) * 4 + 3] > 16) {
|
||
left = Math.min(left, x); right = Math.max(right, x + 1); top = Math.min(top, y); bottom = Math.max(bottom, y + 1);
|
||
}
|
||
inks.set(img.src, { left, right, top, bottom });
|
||
}
|
||
const a = inks.get(img.src), b = rect(img), fit = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight);
|
||
const x = b.x + (b.width - img.naturalWidth * fit) / 2, y = b.y + (b.height - img.naturalHeight * fit) / 2;
|
||
return { x: x + a.left * fit, y: y + a.top * fit, width: (a.right - a.left) * fit, height: (a.bottom - a.top) * fit };
|
||
};
|
||
const textMeasure = e => {
|
||
const box = rect(e), r = document.createRange(); r.selectNodeContents(e);
|
||
const ranges = [...r.getClientRects()].filter(r => r.width > 0).map(r => ({ x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }));
|
||
return { text: e.textContent, box, ranges, clientWidth: e.clientWidth, scrollWidth: e.scrollWidth, ...style(e, typography) };
|
||
};
|
||
const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => {
|
||
const box = rect(c), status = c.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? null;
|
||
const avatar = rect(c.querySelector('.hm-orgchart-avatar')), mark = c.querySelector('.hm-orgchart-affiliation-mark'), img = mark?.querySelector('img');
|
||
const imageBox = img && rect(img), fit = img?.naturalWidth ? Math.min(imageBox.width / img.naturalWidth, imageBox.height / img.naturalHeight) : 0;
|
||
const contentBox = img ? { x: imageBox.x + (imageBox.width - img.naturalWidth * fit) / 2, y: imageBox.y + (imageBox.height - img.naturalHeight * fit) / 2, width: img.naturalWidth * fit, height: img.naturalHeight * fit } : null;
|
||
const logo = img ? { box: imageBox, contentBox, local: relative(imageBox, box), ink: logoPixels(img), naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, src: img.src, ...style(img, ['objectFit', 'filter', 'opacity', 'backgroundColor']) } : null;
|
||
const text = [...c.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-job-title')].map(textMeasure);
|
||
const collisions = [], outside = [];
|
||
if (!contained(avatar, box)) outside.push('avatar');
|
||
if (logo && !contained(logo.box, box)) outside.push('logo image box');
|
||
if (logo?.ink && overlap(logo.ink, avatar)) collisions.push('logo/avatar');
|
||
for (const t of text) {
|
||
if (t.scrollWidth > t.clientWidth + 1) outside.push(`${t.text}: horizontal overflow`);
|
||
for (const r of t.ranges) {
|
||
// Text Range includes font ascent/descent, and is conservative for logo
|
||
// and avatar tests. Adjacent Range-to-Range intersections are recorded
|
||
// separately; they are not treated as painted glyph collisions.
|
||
const visible = { x: Math.max(r.x, t.box.x), y: r.y, width: Math.max(0, Math.min(r.x + r.width, t.box.x + t.box.width) - Math.max(r.x, t.box.x)), height: r.height };
|
||
if (!contained(r, box)) outside.push(`${t.text}: text Range outside card`);
|
||
if (overlap(visible, avatar)) collisions.push(`${t.text}/avatar`);
|
||
if (logo?.ink && overlap(visible, logo.ink)) collisions.push(`${t.text}/logo ink`);
|
||
}
|
||
}
|
||
const fontRangeOverlaps = [];
|
||
for (const [i, a] of text.entries()) for (const b of text.slice(i + 1)) for (const ar of a.ranges) for (const br of b.ranges) if (overlap(ar, br)) fontRangeOverlaps.push([a.text, b.text]);
|
||
const name = textMeasure(c.querySelector('.hm-orgchart-person-name')), role = textMeasure(c.querySelector('.hm-orgchart-person-role'));
|
||
const photo = c.querySelector('.hm-orgchart-avatar img');
|
||
return { key: `${status ?? 'tree'}:${c.dataset.employeeId}`, id: c.dataset.employeeId, status, size: c.dataset.cardAssetSize ?? null, kind: c.dataset.cardKind, box,
|
||
name, role, badge: c.querySelector('.hm-orgchart-job-title')?.textContent ?? '', statusLabel: c.querySelector('.hm-orgchart-status-label')?.textContent ?? '',
|
||
avatar: { box: avatar, local: relative(avatar, box), photoState: c.dataset.photoState, placeholder: photo.dataset.photoPlaceholder, naturalWidth: photo.naturalWidth },
|
||
nameLocal: relative(name.box, box), roleLocal: relative(role.box, box), text, logo, mark: mark ? { box: rect(mark), ...style(mark, ['backgroundColor', 'border', 'boxShadow']) } : null,
|
||
surface: style(c, ['backgroundImage', 'border', 'borderRadius', 'padding', 'columnGap']), collisions, outside, fontRangeOverlaps };
|
||
});
|
||
const headers = [...s.querySelectorAll('.node[data-organization-id]')].map(n => {
|
||
const h = n.querySelector('.hm-orgchart-organization-name'), first = n.querySelector('.hm-orgchart-employee');
|
||
return { id: n.dataset.organizationId, depth: Number(n.dataset.organizationDepth), box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, typography) };
|
||
});
|
||
const statusHeaders = [...s.querySelectorAll('.hm-orgchart-status-group')].map(g => {
|
||
const h = g.querySelector('.hm-orgchart-summary-title'), first = g.querySelector('.hm-orgchart-employee');
|
||
return { status: g.dataset.workStatus, text: h.textContent, box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, [...typography, 'backgroundImage']) };
|
||
});
|
||
const footer = s.querySelector('.hm-orgchart-affiliation-bar'), table = s.querySelector('.hm-orgchart-workdesk-position-table');
|
||
return { scale, cards, headers, statusHeaders,
|
||
footer: { box: rect(footer), text: footer.textContent, logos: [...footer.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(img => ({ id: img.parentElement.dataset.affiliationId, box: rect(img), ...style(img, ['width', 'height']) })) },
|
||
table: { box: rect(table), text: table.textContent, total: table.querySelector('[data-total-count]').textContent },
|
||
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
|
||
policies: { displayMode: s.querySelector('.hm-orgchart-composition').dataset.displayMode, collapseButtons: s.querySelectorAll('.hm-orgchart-toggle').length, connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length, arrows: s.querySelectorAll('.hm-orgchart-workdesk-overlay [data-arrow]').length, background: getComputedStyle(s.querySelector('.hm-orgchart-composition')).backgroundColor },
|
||
stage: rect(stage) };
|
||
});
|
||
}
|
||
async function measurePolicies(page) {
|
||
return page.evaluate(() => {
|
||
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||
const composition = s.querySelector('.hm-orgchart-composition');
|
||
return { displayMode: composition.dataset.displayMode, collapseButtons: s.querySelectorAll('.hm-orgchart-toggle').length,
|
||
connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length,
|
||
arrows: s.querySelectorAll('.hm-orgchart-workdesk-overlay [data-arrow]').length,
|
||
background: getComputedStyle(composition).backgroundColor };
|
||
});
|
||
}
|
||
function verify(m, before, route, viewport) {
|
||
const label = `${route}/${viewport.width}×${viewport.height}`;
|
||
check(m.cards.length === before.cards.length && m.cards.every(c => before.cards.some(b => b.key === c.key)), `${label}: same employee IDs and rendered count`);
|
||
check(m.cards.every(c => c.name.text === '홍 길 동' && !/\s/u.test(c.role.text) && !/\s/u.test(c.badge) && c.name.fontFamily.includes('Noto Sans KR') && c.role.fontFamily.includes('Noto Sans KR')), `${label}: Noto Sans KR, spaced names and compact position/badge text`);
|
||
check(m.cards.every(c => {
|
||
const b = before.cards.find(b => b.key === c.key);
|
||
const preserved = ['fontWeight', 'lineHeight', 'color', 'letterSpacing'];
|
||
if (c.status) return c.name.fontSize === '18px' && c.role.fontSize === '15px';
|
||
if (c.size === 'representative') return [...preserved, 'fontSize'].every(p => c.name[p] === b.name[p] && c.role[p] === b.role[p]);
|
||
return c.name.fontSize === (c.size === 'leader' ? '24px' : '18px') && c.role.fontSize === (c.size === 'leader' ? '18px' : '15px') && preserved.every(p => c.name[p] === b.name[p] && c.role[p] === b.role[p]);
|
||
}), `${label}: requested card typography; representative and other approved properties preserved`);
|
||
const general = m.headers.find(h => h.id === 'integrated-system-1');
|
||
check(general && m.statusHeaders.every(h => ['fontSize', 'fontWeight', 'lineHeight'].every(p => h[p] === general[p]) && h.backgroundImage === before.statusHeaders.find(b => b.status === h.status).backgroundImage), `${label}: status title equals standard 2Depth typography; gray gradient retained`, m.statusHeaders);
|
||
const normal = m.cards.find(c => !c.status && c.size === 'employee' && c.kind === 'employee' && !c.badge);
|
||
const common = ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'];
|
||
check(normal && m.cards.filter(c => c.status).every(c => sameBox(c.avatar.local, normal.avatar.local) && sameBox(c.nameLocal, normal.nameLocal) && sameBox(c.roleLocal, normal.roleLocal) && common.every(p => c.name[p] === normal.name[p] && c.role[p] === normal.role[p]) && c.statusLabel === '' && c.name.color === before.cards.find(b => b.key === c.key).name.color && c.role.color === before.cards.find(b => b.key === c.key).role.color), `${label}: status photo/text geometry and typography equals compact cards; own colors retained; no status line`, m.cards.filter(c => c.status).map(c => ({ key: c.key, avatar: c.avatar.local, name: c.nameLocal, role: c.roleLocal })));
|
||
check(m.cards.filter(c => c.status).every(c => { const b = before.cards.find(b => b.key === c.key); return ['backgroundImage', 'border', 'borderRadius'].every(p => c.surface[p] === b.surface[p]) && c.logo.filter === b.logo.filter && c.logo.opacity === b.logo.opacity; }), `${label}: status card surface and image-only grayscale/opacity retained`);
|
||
check(m.cards.every(c => { const [w, h] = c.size === 'representative' ? [146, 74] : c.size === 'leader' ? [146, 68] : [108, 52]; return near(c.box.width, w) && near(c.box.height, h); }), `${label}: card sizes remain 146×74/146×68/108×52; status corrected to requested108×52`);
|
||
check([...m.headers, ...m.statusHeaders].every(h => h.gap === null || near(h.gap, 8)), `${label}: every title↔first card gap8px`);
|
||
check(m.cards.every(c => c.logo && c.logo.naturalWidth && c.logo.objectFit === 'contain' && c.logo.src === before.cards.find(b => b.key === c.key).logo?.src && ['width', 'height'].every(p => { const old = before.cards.find(b => b.key === c.key).logo; const fitted = old.contentBox ?? { width: old.naturalWidth * Math.min(old.box.width / old.naturalWidth, old.box.height / old.naturalHeight), height: old.naturalHeight * Math.min(old.box.width / old.naturalWidth, old.box.height / old.naturalHeight) }; return near(c.logo.contentBox[p] / fitted[p], 1.5, .005); }) && ['width', 'height'].every(p => near(c.logo.ink[p] / before.cards.find(b => b.key === c.key).logo.ink[p], 1.5, .005))), `${label}: all actual card logo contained images and painted ink scale1.5×; assets/ratio retained`, m.cards.map(c => ({ key: c.key, image: c.logo?.box, fitted: c.logo?.contentBox, ink: c.logo?.ink })));
|
||
check(m.cards.every(c => c.mark.backgroundColor === 'rgba(0, 0, 0, 0)' && c.mark.border.startsWith('0px none') && c.mark.boxShadow === 'none'), `${label}: card logos have no added frame/background/shadow`);
|
||
check(m.cards.every(c => !c.collisions.length && !c.outside.length), `${label}: no logo/avatar/visible text overlap or clipping`, m.cards.filter(c => c.collisions.length || c.outside.length).map(c => ({ key: c.key, collisions: c.collisions, outside: c.outside })));
|
||
check(m.cards.every(c => c.avatar.photoState === 'placeholder' && c.avatar.placeholder === 'true' && c.avatar.naturalWidth > 0 && c.badge !== '경영진'), `${label}: common photo placeholder and no executive badge`);
|
||
check(m.footer.text === before.footer.text && sameBox(m.footer.box, before.footer.box) && m.footer.logos.length === before.footer.logos.length && m.footer.logos.every((l, i) => sameBox(l.box, before.footer.logos[i].box)), `${label}: footer counts/geometry/logos unchanged`);
|
||
check(m.table.text === before.table.text && m.table.total === '41' && sameBox(m.table.box, before.table.box) && m.policies.displayMode === 'readOnly' && m.policies.collapseButtons === 0 && m.policies.connectorPaths === before.policies.connectorPaths && m.policies.arrows === 5 && m.policies.background === 'rgb(245, 248, 251)', `${label}: readOnly/background/connectors/statistics policies retained`, m.policies);
|
||
check(m.document.scrollWidth === m.document.clientWidth && m.document.scrollHeight === m.document.clientHeight && m.cards.every(c => c.box.x >= -.5 && c.box.y >= -.5 && c.box.x + c.box.width <= 1920.5 && c.box.y + c.box.height <= 915), `${label}: no page scroll; cards contained in scaled stage`, m.document);
|
||
}
|
||
const dialogTargets = [['representative', '.hm-orgchart-top-leaders .hm-orgchart-employee'], ['depth1', '.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'], ['small', '.hm-orgchart-engine .hm-orgchart-employee[data-card-asset-size="employee"]'], ['dispatched', '.hm-orgchart-status-group[data-work-status="dispatched"] .hm-orgchart-employee'], ['onLeave', '.hm-orgchart-status-group[data-work-status="onLeave"] .hm-orgchart-employee']];
|
||
async function dialogs(page, route, viewport) {
|
||
await page.setViewportSize(viewport); await ready(page);
|
||
for (const [kind, selector] of dialogTargets) {
|
||
const card = page.locator(selector).first(), dialog = page.locator('dialog[open]'), id = await card.getAttribute('data-employee-id');
|
||
await card.click(); await dialog.waitFor({ state: 'visible' });
|
||
const value = await dialog.evaluate(d => ({ fields: Object.fromEntries([...d.querySelectorAll('dt')].map(t => [t.textContent, t.nextElementSibling.textContent])), scaled: !!d.closest('.hm-orgchart-stage'), transform: getComputedStyle(d).transform, width: d.getBoundingClientRect().width }));
|
||
const employee = fixture.employees.find(e => e.id === id);
|
||
check(value.fields['이름'] === '홍 길 동' && value.fields['직위'] === employee.position && (!employee.jobTitle || value.fields['직책'] === employee.jobTitle) && !value.scaled && value.transform === 'none' && near(value.width, Math.min(420, viewport.width - 32)), `${route}/${viewport.width}: ${kind} popup retains data/unscaled size`, value);
|
||
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
|
||
check(await dialog.evaluate(d => d.contains(d.getRootNode().activeElement)), `${route}/${viewport.width}: ${kind} popup focus trap`);
|
||
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' });
|
||
check(await card.evaluate(e => e.getRootNode().activeElement === e), `${route}/${viewport.width}: ${kind} ESC focus return`);
|
||
}
|
||
}
|
||
async function links(page, route, origin) {
|
||
const id = fixture.organizations.find(o => o.parentId === fixture.organizations.find(o => o.parentId === null).id).id;
|
||
const configured = { [id]: `${origin}/__p32_link` };
|
||
await page.evaluate(async ({ configured, transport }) => {
|
||
if (transport === 'react') window.OrgChartP1React.setOptions({ organizationLinks: configured });
|
||
else { const s = window.OrgChartP1; s.chart.destroy(); s.chart = s.api.createOrgChart({ container: document.getElementById('chart-host'), dataSource: new s.api.JsonOrgChartDataSource(s.data), stylesheetUrls: s.stylesheetUrls, displayMode: 'readOnly', organizationLinks: configured }); await s.chart.mount(); }
|
||
}, { configured, transport: route });
|
||
await ready(page);
|
||
const link = page.locator('.hm-orgchart-organization-link'); check(await link.count() === 1, `${route}: only configured 1Depth title becomes link`);
|
||
await link.focus(); await Promise.all([page.waitForURL(configured[id]), page.keyboard.press('Enter')]);
|
||
check(await page.locator('#proof').textContent() === 'Group link reached', `${route}: real1Depth keyboard link navigation`);
|
||
}
|
||
let browser;
|
||
try {
|
||
const htmlOrigin = await serve(path.join(root, 'examples/org-chart/dist'));
|
||
const reactOrigin = await serve(path.join(root, 'examples/org-chart-react/dist'));
|
||
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
|
||
const context = await browser.newContext();
|
||
const routes = [{ name: 'html', url: `${htmlOrigin}/p1.html?mode=readOnly`, origin: htmlOrigin }, { name: 'file-classic', url: pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href + '?mode=readOnly' }, { name: 'react', url: `${reactOrigin}/p1.html?mode=readOnly`, origin: reactOrigin }, { name: 'storybook', url: `${storyOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story` }];
|
||
for (const route of routes) {
|
||
const page = await context.newPage();
|
||
page.on('pageerror', e => errors.push({ route: route.name, type: 'runtime', message: e.message }));
|
||
page.on('console', e => { if (e.type() === 'error') errors.push({ route: route.name, type: 'console', message: e.text() }); });
|
||
try {
|
||
await page.goto(route.url); await ready(page);
|
||
if (policiesOnly) {
|
||
await page.setViewportSize(viewports[0]); await ready(page);
|
||
const policies = await measurePolicies(page);
|
||
measurements.push({ route: route.name, ...viewports[0], policies });
|
||
check(policies.displayMode === 'readOnly' && policies.collapseButtons === 0, `${route.name}: readOnly has no actual hm-orgchart-toggle controls`, policies);
|
||
check(policies.background === 'rgb(245, 248, 251)', `${route.name}: actual composition background is #F5F8FB`, policies.background);
|
||
check(policies.connectorPaths === 5 && policies.arrows === 5, `${route.name}: actual overlay retains five connector paths and five collaboration arrows`, policies);
|
||
} else {
|
||
for (const viewport of viewports) {
|
||
await page.setViewportSize(viewport); await ready(page);
|
||
const m = await measure(page); measurements.push({ route: route.name, ...viewport, ...m });
|
||
if (!baselineMode) verify(m, previous.measurements.find(b => b.route === route.name && b.width === viewport.width), route.name, viewport);
|
||
if ([1920, 390].includes(viewport.width)) await page.screenshot({ path: path.join(output, `${baselineMode ? 'baseline-' : ''}${route.name}-${viewport.width}x${viewport.height}.png`) });
|
||
}
|
||
if (!baselineMode) {
|
||
await dialogs(page, route.name, viewports[0]); await dialogs(page, route.name, viewports.at(-1));
|
||
if (['html', 'react'].includes(route.name)) await links(page, route.name, route.origin);
|
||
}
|
||
}
|
||
} catch (error) { check(false, `${route.name}: focused browser capture completed`, { message: error.message, stack: error.stack }); }
|
||
await page.close();
|
||
}
|
||
if (policiesOnly) check(!errors.length, 'no new console/runtime errors during policy-only capture', errors);
|
||
else if (!baselineMode) {
|
||
for (const viewport of viewports) {
|
||
const variants = measurements.filter(m => m.width === viewport.width), base = variants.find(m => m.route === 'html');
|
||
check(variants.length === 4 && variants.every(m => m.cards.length === base.cards.length && m.cards.every((c, i) => sameBox(c.box, base.cards[i].box) && sameBox(c.nameLocal, base.cards[i].nameLocal) && sameBox(c.roleLocal, base.cards[i].roleLocal) && c.name.fontSize === base.cards[i].name.fontSize && c.role.fontSize === base.cards[i].role.fontSize && sameBox(c.logo.box, base.cards[i].logo.box))), `HTML/classic/React/Storybook same card typography/geometry/${viewport.width}`);
|
||
}
|
||
const index = await fetch(`${storyOrigin}/index.json`).then(r => r.json());
|
||
const stories = Object.values(index.entries).filter(e => e.title === 'OrgChart' && e.type === 'story' && !e.tags?.includes('internal'));
|
||
check(stories.length === 3, 'existing Storybook three user menus retained', stories.map(e => e.name));
|
||
check(!errors.length, 'no new console/runtime errors', errors);
|
||
}
|
||
} finally {
|
||
await browser?.close(); for (const server of servers) await new Promise(resolve => server.close(resolve));
|
||
const summary = { total: results.length, pass: results.filter(r => r.status === 'PASS').length, fail: results.filter(r => r.status === 'FAIL').length };
|
||
const report = { scope: policiesOnly ? 'P3.2 measurement-selector correction only; retain browser.json and do not repeat passed typography/logo/layout/dialog/link checks' : 'P3.2 changes only; prior P3.1 486 checks retained without rerun', baselineMode, policiesOnly, fixtureSha256: createHash('sha256').update(fixtureBytes).digest('hex'), summary, results, measurements, errors,
|
||
collisionMethod: 'Natural PNG alpha bounds >16 transformed through object-fit contain, versus visible text Range and avatar rectangles. Adjacent font Range envelopes are recorded only; painted font glyph interpenetration is not certified by this test.' };
|
||
await fs.writeFile(path.join(output, policiesOnly ? 'policy-browser.json' : baselineMode ? 'baseline-browser.json' : 'browser.json'), JSON.stringify(report, null, 2));
|
||
console.log(JSON.stringify({ baselineMode, policiesOnly, captures: measurements.length, summary, errors, failures: results.filter(r => r.status === 'FAIL').map(r => r.name) }, null, 2));
|
||
if (summary.fail || (baselineMode && measurements.length !== routesLength())) process.exitCode = 1;
|
||
}
|
||
function routesLength() { return 4 * viewports.length; }
|