// P9.3 browser-state checks on the preview-only Expanded Story (2026-10-22). Drag & Drop is fixed // OFF and company/data/layout settings were removed, so Story editing sections were retired; // move, pointer drag, invalid-move and cross-company coverage stays in verify-p9-3-core.mjs. import fs from 'node:fs/promises'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-p9-3'); const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064'; const only = process.argv.slice(2), suffix = only.length ? `-${only.join('-')}` : ''; const results = [], measurements = [], errors = [], startedAt = new Date().toISOString(); await fs.mkdir(output, { recursive: true }); const persist = () => writeFileSync(path.join(output, `browser-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2)); const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); persist(); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; const browser = await launchChromium({ headless: true }); const page = await browser.newPage({ viewport: { width: 1920, height: 1700 } }); page.setDefaultTimeout(15000); page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.message }); persist(); }); page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } }); const viewport = () => page.locator('.hm-orgchart-viewport'); const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`); const title = id => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`); const dialog = () => page.locator('[data-component="HMOrgChartEmployeeDialog"]'); const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); }); async function ready(count) { await page.waitForFunction(count => { const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; return shadow && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) && (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count); }, count); await settle(); } async function setup() { await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await ready(140); await viewport().scrollIntoViewIfNeeded(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); } async function state() { return page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; const viewport = shadow.querySelector('.hm-orgchart-viewport'), expanded = shadow.querySelector('.hm-orgchart-expanded'); const rect = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; }; const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(element => ({ id: element.dataset.organizationId, parentId: element.dataset.parentId, companyId: element.dataset.companyId, domParentId: element.closest('.hierarchy')?.parentElement?.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId ?? '', direct: Number(element.dataset.directEmployeeCount), inclusive: Number(element.dataset.inclusiveEmployeeCount), collapsed: element.dataset.collapsed, })); const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({ id: element.dataset.employeeId, organizationId: element.closest('.node').dataset.organizationId, companyId: element.dataset.companyId, direction: element.dataset.connectionDirection, kind: element.dataset.cardKind, border: { top: getComputedStyle(element).borderTopColor, left: getComputedStyle(element).borderLeftColor, right: getComputedStyle(element).borderRightColor }, })); const paths = [...shadow.querySelectorAll('.hm-orgchart-organization-connectors path')].map(path => ({ id: path.dataset.organizationId, parentId: path.dataset.parentId, d: path.getAttribute('d') })); const employeePaths = [...shadow.querySelectorAll('.hm-orgchart-employee-connectors path')].map(path => ({ id: path.dataset.employeeId, organizationId: path.closest('.node').dataset.organizationId, direction: path.dataset.direction, d: path.getAttribute('d') })); const feedback = shadow.querySelector('.hm-orgchart-move-feedback'); return { layout: expanded?.dataset.autoLayout, scale: Number(viewport.dataset.scale), panX: Number(viewport.dataset.panX), panY: Number(viewport.dataset.panY), viewport: rect(viewport), forest: rect(shadow.querySelector('.hm-orgchart-forest')), nodes, cards, paths, employeePaths, feedback: { status: feedback?.dataset.moveStatus, text: feedback?.textContent }, drop: [...shadow.querySelectorAll('[data-drop-state]')].map(element => ({ id: element.dataset.organizationId, state: element.dataset.dropState, hover: element.dataset.dropHover })), dragging: viewport.dataset.editDragging, panDragging: viewport.dataset.dragging, activeEmployee: shadow.activeElement?.dataset.employeeId, activeOrganization: shadow.activeElement?.dataset.organizationId, dialogs: document.querySelectorAll('[data-component="HMOrgChartEmployeeDialog"]').length, employeesVisible: cards.length, employeeCount: cards.length, organizationCount: nodes.length, companyRoots: nodes.filter(org => org.parentId === '').map(org => org.companyId), }; }); } const relationState = s => JSON.stringify({ nodes: s.nodes, cards: s.cards.map(({ id, organizationId, direction }) => ({ id, organizationId, direction })) }); async function blankPoint() { return page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport'), box = viewport.getBoundingClientRect(); for (const [dx, dy] of [[8, 8], [8, box.height - 8], [box.width - 8, 8], [box.width - 8, box.height - 8], [box.width / 2, box.height - 8]]) { const point = { x: box.left + dx, y: box.top + dy }, hit = shadow.elementFromPoint(point.x, point.y); if (hit && !hit.closest('button,a,.hm-orgchart-employee,.node')) return point; } throw new Error('No visible blank Pan point found'); }); } async function pan(dx, dy) { const p = await blankPoint(); await page.mouse.move(p.x, p.y); await page.mouse.down(); const v = await viewport().boundingBox(); // Cross the six-pixel Pan threshold inside the viewport before the requested // motion. A large first upward step from its top edge never reaches the // viewport's pointermove listener and therefore cannot establish capture. await page.mouse.move(p.x + Math.sign(v.x + v.width / 2 - p.x) * 8, p.y, { steps: 2 }); await page.mouse.move(p.x + dx, p.y + dy, { steps: 8 }); await page.mouse.up(); await settle(); } async function centerPair(source, target) { await viewport().scrollIntoViewIfNeeded(); await source.focus(); await settle(); for (let pass = 0; pass < 3; pass++) { const a = await source.boundingBox(), b = await target.boundingBox(), v = await viewport().boundingBox(); if (!a || !b || !v) throw new Error('Drag source or target has no geometry'); const left = Math.min(a.x, b.x), right = Math.max(a.x + a.width, b.x + b.width); const top = Math.min(a.y, b.y), bottom = Math.max(a.y + a.height, b.y + b.height); if (right - left > v.width - 24 || bottom - top > v.height - 24) throw new Error(`Drag pair exceeds viewport at current scale: ${JSON.stringify({ a, b, v })}`); const dx = v.x + v.width / 2 - (left + right) / 2, dy = v.y + v.height / 2 - (top + bottom) / 2; if (Math.abs(dx) < 1 && Math.abs(dy) < 1) break; await pan(dx, dy); } } async function snap(name) { await page.screenshot({ path: path.join(output, `${name}.png`), fullPage: false }); } async function section(name, action) { if (only.length && !only.includes(name)) return; try { await action(); } catch (error) { check(false, `${name}: execution completed`, { message: error.message, stack: error.stack }); await page.mouse.up().catch(() => {}); await snap(`failure-${name}`).catch(() => {}); } } await section('pan-click', async () => { await setup(); await centerPair(employee('virtual-022'), title('hanmac-office-sibling')); const before = await state(); await pan(85, -45); const after = await state(); check(Math.abs(after.panX - before.panX - 85) < 1 && Math.abs(after.panY - before.panY + 45) < 1 && after.scale === before.scale && relationState(after) === relationState(before), 'Blank-space pointer Pan preserves Zoom and every relationship'); await centerPair(employee('virtual-022'), title('hanmac-office-sibling')); const box = await employee('virtual-022').boundingBox(); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(box.x + box.width / 2 + 2, box.y + box.height / 2 + 1); const small = await state(); await page.mouse.up(); await dialog().waitFor(); check(!small.dragging && await dialog().getAttribute('data-employee-id') === 'virtual-022', 'Movement below six-pixel threshold remains employee click'); await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await title('hanmac-office-sibling').click(); await ready(); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'false', 'Simple group click collapses'); await title('hanmac-office-sibling').click(); await ready(140); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'true', 'Simple group click expands'); }); check(errors.length === 0, 'New console/page errors: zero', errors); const result = { startedAt, completedAt: new Date().toISOString(), browser: browser.version(), origin, sections: only, results, measurements, errors, notVerified: ['Physical touch device', 'Actual screen reader'], pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length }; await fs.writeFile(path.join(output, `browser-editing${suffix}.json`), JSON.stringify(result, null, 2)); console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9-3/browser-editing${suffix}.json` })); await browser.close(); if (result.fail) process.exitCode = 1;