// P9.3 public Vanilla Core API and engine bridge checks only. // Requires the parent-controlled static server and an already built Core. 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_CORE_ORIGIN ?? 'http://127.0.0.1:6064'; const only = process.argv.slice(2), run = section => !only.length || only.includes(section); const suffix = only.length ? '-' + only.join('-') : ''; const startedAt = new Date().toISOString(), results = [], measurements = [], errors = []; await fs.mkdir(output, { recursive: true }); const persist = () => writeFileSync(path.join(output, `core-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: 1440, height: 1000 } }); 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 settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 6; i++) await new Promise(requestAnimationFrame); }); async function ready() { await page.waitForFunction(() => window.fixtureReady && window.chart?.getState().status === 'ready' && [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)); await settle(); } async function fresh(extra) { await page.goto(`${origin}/vanilla.html`); await ready(); if (extra) { await page.evaluate(extra => window.mountChart('auto', extra), extra); await ready(); } } 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}"]`); async function pointerMove(source, target) { const from = await source.boundingBox(), to = await target.boundingBox(); if (!from || !to) throw new Error('Pointer move source/target has no rendered bounds'); const start = { x: from.x + from.width / 2, y: from.y + from.height / 2 }, end = { x: to.x + to.width / 2, y: to.y + to.height / 2 }; await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(start.x + 8, start.y + 8, { steps: 2 }); await page.mouse.move(end.x, end.y, { steps: 10 }); const feedback = await page.evaluate(() => [...window.shadow.querySelectorAll('.node[data-drop-state]')].map(node => ({ id: node.dataset.organizationId, state: node.dataset.dropState, hover: node.dataset.dropHover }))); await page.mouse.up(); await ready(); return feedback; } async function snapshot() { return page.evaluate(() => { const data = window.chart.getData(), state = window.chart.getState(), shadow = window.shadow; return { data, summary: state.status === 'ready' ? state.summary.raw : undefined, viewport: window.chart.getViewportState(), moves: window.moves, events: window.bridgeEvents, cards: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, organization: card.closest('.node').dataset.organizationId, direction: card.dataset.connectionDirection })), groups: [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => ({ id: node.dataset.organizationId, parent: node.dataset.parentId, collapsed: node.dataset.collapsed })), organizationPaths: [...shadow.querySelectorAll('.hm-orgchart-organization-connectors path')].map(node => ({ id: node.dataset.organizationId, parent: node.dataset.parentId })), dialogCount: shadow.querySelectorAll('dialog[open]').length, feedback: shadow.querySelector('.hm-orgchart-move-feedback')?.textContent }; }); } try { if (run('api')) { await fresh(); const initial = await snapshot(); check(initial.data.employees.length === 20 && initial.data.organizations.length === 15, 'Vanilla public Core mounts raw multi-company fixture'); const frozen = await page.evaluate(() => { const data = window.chart.getData(), before = JSON.stringify(data); const deep = value => value === null || typeof value !== 'object' || (Object.isFrozen(value) && Object.values(value).every(deep)); try { data.employees[0].name = 'consumer mutation'; } catch { /* Frozen snapshots may throw in strict mode. */ } return { deepFrozen: deep(data), unchanged: JSON.stringify(data) === before, copied: data !== window.fixture, fixtureUnchanged: JSON.stringify(window.fixture) === window.fixtureOriginal }; }); check(frozen.deepFrozen && frozen.unchanged && frozen.copied && frozen.fixtureUnchanged, 'getData is a deeply frozen owned snapshot; consumer input preserved', frozen); const moved = await page.evaluate(() => window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' })); await ready(); const afterEmployee = await snapshot(); check(moved.moved && moved.previousOrganizationId === 'hanmac-office-0' && moved.request.sourceId === 'virtual-006', 'Public employee move reports original relation and request', moved.moved ? { previousOrganizationId: moved.previousOrganizationId, request: moved.request } : moved); const beforePerson = initial.data.employees.find(person => person.id === 'virtual-006'), afterPerson = afterEmployee.data.employees.find(person => person.id === 'virtual-006'); check(afterPerson.organizationId === 'hanmac-division-1' && JSON.stringify({ ...afterPerson, organizationId: beforePerson.organizationId }) === JSON.stringify(beforePerson), 'Employee identity, affiliation and personal fields survive new organization'); check(afterEmployee.data.employees.length === initial.data.employees.length && new Set(afterEmployee.cards.map(card => card.id)).size === 20 && afterEmployee.cards.filter(card => card.id === 'virtual-006').length === 1 && afterEmployee.cards.find(card => card.id === 'virtual-006').organization === 'hanmac-division-1', 'Employee count unchanged and one rendered card under new group'); check(afterEmployee.summary.total === initial.summary.total && afterEmployee.summary.byOrganization['hanmac-office-0'].direct === initial.summary.byOrganization['hanmac-office-0'].direct - 1 && afterEmployee.summary.byOrganization['hanmac-division-1'].direct === initial.summary.byOrganization['hanmac-division-1'].direct + 1, 'Public summary direct totals update without changing overall count'); const group = await page.evaluate(() => window.chart.move({ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'hanmac-division-1' })); await ready(); const afterGroup = await snapshot(); check(group.moved && group.previousOrganizationId === 'hanmac-root' && afterGroup.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'hanmac-division-1' && afterGroup.data.organizations.find(org => org.id === 'hanmac-office-0').parentId === 'hanmac-division-0' && JSON.stringify(afterGroup.data.employees) === JSON.stringify(afterEmployee.data.employees), 'Public organization move updates parent only and retains descendants/staff', group.moved ? group.request : group); const unchanged = JSON.stringify(afterGroup.data); const invalid = [ [{ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'hanmac-office-0' }, 'DESCENDANT_TARGET'], [{ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'hanmac-division-0' }, 'SELF_TARGET'], [{ kind: 'organization', sourceId: 'hanmac-root', targetOrganizationId: 'hanmac-division-1' }, 'ROOT_PROTECTED'], [{ kind: 'employee', sourceId: 'virtual-001', targetOrganizationId: 'hanmac-division-1' }, 'LEADER_PROTECTED'], [{ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' }, 'SAME_ORGANIZATION'], [{ kind: 'employee', sourceId: 'missing-employee', targetOrganizationId: 'hanmac-division-1' }, 'SOURCE_NOT_FOUND'], [{ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'missing-group' }, 'TARGET_NOT_FOUND'], ]; for (const [request, code] of invalid) { const result = await page.evaluate(request => window.chart.move(request), request); check(!result.moved && result.code === code && await page.evaluate(expected => JSON.stringify(window.chart.getData()) === expected, unchanged), `Public move rejects ${code} and preserves complete dataset`, result); } check(await page.evaluate(expected => window.moves.length === expected && window.moves.filter(move => move.moved).length === 2, 2 + invalid.length), 'onMove receives successful moves and every policy rejection exactly once'); const callback = await page.evaluate(async () => { window.throwCallback = true; const result = await window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-office-1' }); window.throwCallback = false; return { moved: result.moved, status: window.chart.getState().status }; }); await ready(); check(callback.moved && callback.status === 'ready', 'Consumer onMove exception cannot fail Core transaction', callback); check(await page.evaluate(() => JSON.stringify(window.fixture) === window.fixtureOriginal), 'All public edits leave original JSON input unchanged'); measurements.push({ section: 'api', state: await snapshot() }); } if (run('cross')) { // User's later instruction permits cross-company moves. The earlier // CROSS_COMPANY rejection evidence remains preserved as superseded history. await fresh(); const before = await snapshot(); const employeeResult = await page.evaluate(() => window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'saman-division-1' })); await ready(); const afterEmployee = await snapshot(); const oldEmployee = before.data.employees.find(person => person.id === 'virtual-006'), newEmployee = afterEmployee.data.employees.find(person => person.id === 'virtual-006'); check(employeeResult.moved && employeeResult.previousOrganizationId === 'hanmac-office-0' && newEmployee.organizationId === 'saman-division-1' && newEmployee.affiliationId === 'saman' && JSON.stringify({ ...newEmployee, organizationId: oldEmployee.organizationId, affiliationId: oldEmployee.affiliationId }) === JSON.stringify(oldEmployee), 'Cross-company public employee move updates actual organizationId/affiliationId and preserves identity', employeeResult.moved ? { request: employeeResult.request, person: newEmployee } : employeeResult); check(afterEmployee.data.employees.length === 20 && afterEmployee.cards.length === 20 && new Set(afterEmployee.cards.map(card => card.id)).size === 20 && afterEmployee.summary.total === before.summary.total && afterEmployee.summary.byAffiliation.hanmac === before.summary.byAffiliation.hanmac - 1 && afterEmployee.summary.byAffiliation.saman === before.summary.byAffiliation.saman + 1 && afterEmployee.summary.byOrganization['hanmac-root'].inclusive === before.summary.byOrganization['hanmac-root'].inclusive - 1 && afterEmployee.summary.byOrganization['saman-root'].inclusive === before.summary.byOrganization['saman-root'].inclusive + 1, 'Cross-company employee move retains total and updates source/target company plus affiliation counts'); const employeeColor = await employee('virtual-006').evaluate(card => { const probe = document.createElement('span'); probe.style.color = window.mod.companyColorPresets.saman.primary; window.shadow.append(probe); const expected = getComputedStyle(probe).color; probe.remove(); return { company: card.dataset.companyId, organization: card.closest('.node').dataset.organizationId, border: getComputedStyle(card).borderTopColor, expected }; }); check(employeeColor.company === 'saman' && employeeColor.organization === 'saman-division-1' && employeeColor.border === employeeColor.expected, 'Cross-company employee card uses destination existing company preset', employeeColor); await employee('virtual-006').click(); const popup = page.locator('.hm-orgchart-employee-dialog[open]'); await popup.waitFor(); const popupState = await popup.evaluate(dialog => { const field = name => [...dialog.querySelectorAll('dt')].find(term => term.textContent === name)?.nextElementSibling?.textContent; return { id: dialog.dataset.employeeId, organization: field('소속'), affiliation: field('관계사') }; }); check(popupState.id === 'virtual-006' && popupState.organization === afterEmployee.data.organizations.find(org => org.id === 'saman-division-1').name && popupState.affiliation === afterEmployee.data.affiliations.find(affiliation => affiliation.id === 'saman').name, 'Vanilla employee Dialog reflects destination company and organization after cross move', popupState); await page.keyboard.press('Escape'); await popup.waitFor({ state: 'hidden' }); await settle(); const groupResult = await page.evaluate(() => window.chart.move({ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'saman-division-1' })); await ready(); const afterGroup = await snapshot(); const subtree = new Set(['hanmac-division-0', 'hanmac-office-0']); const subtreePeople = afterEmployee.data.employees.filter(person => subtree.has(person.organizationId)); check(groupResult.moved && groupResult.previousOrganizationId === 'hanmac-root' && afterGroup.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'saman-division-1' && afterGroup.data.organizations.find(org => org.id === 'hanmac-office-0').parentId === 'hanmac-division-0' && afterGroup.data.organizations.filter(org => subtree.has(org.id)).every(org => org.companyId === 'saman') && afterGroup.data.employees.filter(person => subtree.has(person.organizationId)).every(person => person.affiliationId === 'saman') && subtreePeople.every(old => JSON.stringify(afterGroup.data.employees.find(person => person.id === old.id)) === JSON.stringify({ ...old, affiliationId: 'saman' })), 'Cross-company public group move retargets subtree companyId and staff affiliationId while retaining hierarchy and identities', groupResult.moved ? { request: groupResult.request, subtreePeople } : groupResult); check(afterGroup.data.employees.length === 20 && afterGroup.cards.length === 20 && new Set(afterGroup.cards.map(card => card.id)).size === 20 && afterGroup.summary.byAffiliation.hanmac === afterEmployee.summary.byAffiliation.hanmac - subtreePeople.length && afterGroup.summary.byAffiliation.saman === afterEmployee.summary.byAffiliation.saman + subtreePeople.length && afterGroup.summary.byOrganization['hanmac-root'].inclusive === afterEmployee.summary.byOrganization['hanmac-root'].inclusive - subtreePeople.length && afterGroup.summary.byOrganization['saman-root'].inclusive === afterEmployee.summary.byOrganization['saman-root'].inclusive + subtreePeople.length, 'Cross-company group transfer updates company/affiliation aggregate totals without duplication'); const groupColors = await page.evaluate(() => { const probe = document.createElement('span'); probe.style.color = window.mod.companyColorPresets.saman.primary; window.shadow.append(probe); const expected = getComputedStyle(probe).color; probe.remove(); return [...window.shadow.querySelectorAll('.node')].filter(node => ['hanmac-division-0', 'hanmac-office-0'].includes(node.dataset.organizationId)) .map(node => ({ id: node.dataset.organizationId, company: node.dataset.companyId, title: getComputedStyle(node.querySelector('.hm-orgchart-organization-name')).backgroundColor, expected, cards: [...node.querySelectorAll('.hm-orgchart-employee')].map(card => ({ company: card.dataset.companyId, border: getComputedStyle(card).borderTopColor })) })); }); check(groupColors.length === 2 && groupColors.every(node => node.company === 'saman' && node.title === node.expected && node.cards.every(card => card.company === 'saman' && card.border === node.expected)), 'Transferred subtree uses destination presets on group headings and employee borders', groupColors); check(await page.evaluate(() => window.moves.length === 2 && window.moves.every(result => result.moved) && JSON.stringify(window.fixture) === window.fixtureOriginal && window.mod.validateOrgChartDataset(window.chart.getData()).valid && Object.isFrozen(window.chart.getData()) && Object.isFrozen(window.chart.getData().employees[0])), 'Cross-company API callbacks occur once per success; data remains valid/frozen and original fixture unchanged'); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready(); check(await employee('virtual-006').count() === 0 && await title('hanmac-division-0').count() === 0, 'Destination-company filter removes transferred employee/subtree from source-company view'); await page.evaluate(() => window.chart.setSelectedCompanies(['saman'])); await ready(); check(await employee('virtual-006').count() === 1 && await title('hanmac-division-0').count() === 1 && await title('hanmac-office-0').count() === 1, 'Destination-company filter restores transferred employee/subtree exactly once'); measurements.push({ section: 'cross-api', state: await snapshot() }); await fresh(); const employeeMarks = await pointerMove(employee('virtual-006'), title('saman-division-1')); const employeePointer = await snapshot(); check(employeeMarks.some(node => node.id === 'saman-division-1' && node.state === 'allowed' && node.hover === 'allowed') && employeePointer.events.some(event => event.type === 'otherdropped.orgchart' && event.defaultPrevented && event.targetId === 'saman-division-1') && employeePointer.data.employees.find(person => person.id === 'virtual-006').organizationId === 'saman-division-1' && employeePointer.data.employees.find(person => person.id === 'virtual-006').affiliationId === 'saman', 'Real cross-tree employee pointer marks destination allowed and reuses canceled upstream external-drop bridge', { marks: employeeMarks, events: employeePointer.events }); await page.evaluate(() => window.chart.fitToView()); await settle(); const groupMarks = await pointerMove(title('hanmac-division-0'), title('saman-division-1')); const groupPointer = await snapshot(); check(groupMarks.some(node => node.id === 'saman-division-1' && node.state === 'allowed' && node.hover === 'allowed') && groupPointer.events.some(event => event.type === 'nodedrop.orgchart' && event.defaultPrevented && event.targetId === 'saman-division-1') && groupPointer.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'saman-division-1' && groupPointer.data.organizations.find(org => org.id === 'hanmac-office-0').companyId === 'saman', 'Real cross-tree group pointer reuses canceled upstream node-drop bridge and retargets subtree', { marks: groupMarks, events: groupPointer.events }); check(groupPointer.moves.length === 2 && groupPointer.moves.every(move => move.moved) && groupPointer.dialogCount === 0 && groupPointer.data.employees.length === 20 && new Set(groupPointer.cards.map(card => card.id)).size === 20 && await page.evaluate(() => JSON.stringify(window.fixture) === window.fixtureOriginal), 'Cross-tree pointer transactions have one callback each, no popup/duplicate and immutable original fixture'); measurements.push({ section: 'cross-pointer', state: groupPointer }); await page.screenshot({ path: path.join(output, 'vanilla-cross-company.png'), fullPage: true }); } if (run('bridge')) { await fresh(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready(); const before = await snapshot(); const marks = await pointerMove(employee('virtual-006'), title('hanmac-division-1')); const staff = await snapshot(); check(marks.some(group => group.id === 'hanmac-division-1' && group.state === 'allowed' && group.hover === 'allowed') && marks.some(group => group.id === 'hanmac-office-0' && group.state === 'blocked'), 'Vanilla pointer card gesture exposes valid/invalid/hover targets', marks); check(staff.events.some(event => event.type === 'otherdropped.orgchart' && event.defaultPrevented && event.sourceId === 'virtual-006' && event.targetId === 'hanmac-division-1') && staff.data.employees.find(person => person.id === 'virtual-006').organizationId === 'hanmac-division-1', 'Real employee pointer uses canceled upstream otherdropped bridge and updates data', staff.events); check(staff.dialogCount === 0 && JSON.stringify(staff.viewport) === JSON.stringify(before.viewport), 'Employee drag opens no Dialog and retains Pan/Zoom transform', { before: before.viewport, after: staff.viewport, dialogs: staff.dialogCount }); await page.evaluate(() => window.chart.fitToView()); await settle(); const groupMarks = await pointerMove(title('hanmac-division-0'), title('hanmac-division-1')); const groups = await snapshot(); check(groupMarks.some(group => group.id === 'hanmac-office-0' && group.state === 'blocked') && groups.events.some(event => event.type === 'nodedrop.orgchart' && event.defaultPrevented && event.sourceId === 'hanmac-division-0' && event.targetId === 'hanmac-division-1') && groups.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'hanmac-division-1', 'Real organization pointer uses canceled upstream nodedrop bridge and protects descendant', { marks: groupMarks, events: groups.events }); check(groups.data.employees.length === 20 && new Set(groups.groups.map(group => group.id)).size === groups.groups.length && groups.groups.find(group => group.id === 'hanmac-office-0').parent === 'hanmac-division-0' && groups.groups.find(group => group.id === 'hanmac-division-0').collapsed === 'false', 'Group pointer move keeps complete subtree and avoids post-drag collapse'); await page.evaluate(() => window.chart.setAutoLayout({ type: 'lr' })); await ready(); const routed = await snapshot(); check(routed.organizationPaths.some(route => route.id === 'hanmac-division-0' && route.parent === 'hanmac-division-1') && !routed.organizationPaths.some(route => route.id === 'hanmac-division-0' && route.parent === 'hanmac-root') && routed.cards.every(card => card.direction === 'left'), 'LR regenerated connector follows changed parent and card incoming borders'); await page.screenshot({ path: path.join(output, 'vanilla-core-lr.png'), fullPage: true }); measurements.push({ section: 'bridge', state: routed }); } if (run('lifecycle')) { await fresh(); await page.evaluate(() => window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' })); await ready(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready(); await title('hanmac-division-1').click(); await ready(); const collapsed = await snapshot(); check(collapsed.groups.find(group => group.id === 'hanmac-division-1').collapsed === 'true', 'Moved employee target remains ordinarily collapsible'); for (const type of ['lr', 'hybrid', 'tb']) { await page.evaluate(type => window.chart.setAutoLayout({ type }), type); await ready(); check(await title('hanmac-division-1').getAttribute('aria-expanded') === 'false' && await page.evaluate(() => window.chart.getData().employees.find(person => person.id === 'virtual-006').organizationId === 'hanmac-division-1'), `Local relation and collapse survive layout ${type}`); } const hiddenState = await page.evaluate(() => JSON.stringify(window.chart.getData())); await page.evaluate(() => window.chart.setSelectedCompanies(['saman'])); await ready(); check(await page.locator('.node[data-company-id="hanmac"]').count() === 0 && await page.evaluate(expected => JSON.stringify(window.chart.getData()) === expected, hiddenState), 'Hiding a company preserves all browser-state relations'); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready(); check(await title('hanmac-division-1').getAttribute('aria-expanded') === 'false', 'Company restore keeps saved collapsed target'); await title('hanmac-division-1').click(); await ready(); check(await employee('virtual-006').count() === 1 && await employee('virtual-006').evaluate(card => card.closest('.node').dataset.organizationId) === 'hanmac-division-1', 'Company restore shows moved employee exactly once in new group'); const raced = await page.evaluate(async () => { let resolveSource; const delayed = { load: () => new Promise(resolve => { resolveSource = resolve; }) }; const first = window.chart.reload(delayed); const rejected = await window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-office-1' }); const fresh = structuredClone(window.fixture); fresh.metadata.dataVersion = 'p9.3-newer-request'; await window.chart.update(fresh); resolveSource(window.fixture); await first; return { rejected, version: window.chart.getData().metadata.dataVersion, organization: window.chart.getData().employees.find(person => person.id === 'virtual-006').organizationId, status: window.chart.getState().status }; }); await ready(); check(!raced.rejected.moved && raced.rejected.code === 'INVALID_DATA', 'Move during pending source request is safely rejected', raced.rejected); check(raced.version === 'p9.3-newer-request' && raced.organization === 'hanmac-office-0' && raced.status === 'ready', 'Stale asynchronous reload cannot overwrite newer dataset request', raced); const simultaneous = await page.evaluate(async () => { const one = window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' }); const two = window.chart.move({ kind: 'employee', sourceId: 'virtual-007', targetOrganizationId: 'hanmac-office-1' }); const outcomes = await Promise.all([one, two]); return { outcomes: outcomes.map(result => ({ moved: result.moved, code: result.code })), data: window.chart.getData(), valid: window.mod.validateOrgChartDataset(window.chart.getData()).valid, status: window.chart.getState().status }; }); await ready(); check(simultaneous.outcomes[0].moved && !simultaneous.outcomes[1].moved && simultaneous.outcomes[1].code === 'INVALID_DATA' && simultaneous.valid && simultaneous.status === 'ready' && simultaneous.data.employees.length === 20 && simultaneous.data.employees.find(person => person.id === 'virtual-006').organizationId === 'hanmac-division-1' && simultaneous.data.employees.find(person => person.id === 'virtual-007').organizationId === 'hanmac-division-1', 'Concurrent public moves serialize safely; loading request rejects without losing data', { outcomes: simultaneous.outcomes, status: simultaneous.status, valid: simultaneous.valid }); measurements.push({ section: 'lifecycle', state: await snapshot() }); } if (run('touch')) { await fresh(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready(); await page.evaluate(() => { window.touchPointers = []; for (const type of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'gotpointercapture', 'lostpointercapture']) { window.addEventListener(type, event => window.touchPointers.push({ type, pointerType: event.pointerType, isPrimary: event.isPrimary, x: event.clientX, y: event.clientY, editing: window.shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging, feedback: window.shadow.querySelector('.hm-orgchart-move-feedback').textContent })); } }); const from = await employee('virtual-006').boundingBox(), to = await title('hanmac-division-1').boundingBox(); const start = { x: from.x + from.width / 2, y: from.y + from.height / 2 }, end = { x: to.x + to.width / 2, y: to.y + to.height / 2 }; const session = await page.context().newCDPSession(page); await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [start] }); for (let step = 1; step <= 8; step++) { await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start.x + (end.x - start.x) * step / 8, y: start.y + (end.y - start.y) * step / 8 }] }); } await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await ready(); const touch = await page.evaluate(() => ({ pointers: window.touchPointers, organization: window.chart.getData().employees.find(person => person.id === 'virtual-006').organizationId, events: window.bridgeEvents, dialogs: window.shadow.querySelectorAll('dialog[open]').length })); check(touch.pointers.some(event => event.pointerType === 'touch' && event.isPrimary) && touch.organization === 'hanmac-division-1' && touch.events.some(event => event.type === 'otherdropped.orgchart' && event.defaultPrevented) && touch.dialogs === 0, 'Synthetic browser touch input generates primary touch pointer, moves card and suppresses Dialog', touch); measurements.push({ section: 'touch', state: touch, realDevice: 'NOT VERIFIED' }); await session.send('Emulation.setTouchEmulationEnabled', { enabled: false }); await session.detach(); } if (run('touch-pan')) { await fresh(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready(); const setup = await page.evaluate(() => { const viewport = window.shadow.querySelector('.hm-orgchart-viewport'), box = viewport.getBoundingClientRect(); const left = box.left + 24, right = Math.min(box.right - 72, innerWidth - 72), top = box.top + 24, bottom = Math.min(box.bottom - 72, innerHeight - 72); for (let y = top; y <= bottom; y += 40) { for (let x = left; x <= right; x += 40) { const hit = window.shadow.elementFromPoint(x, y); if (hit && viewport.contains(hit) && !hit.closest('button,a,.hm-orgchart-employee,.node')) { window.touchPanPointers = []; viewport.addEventListener('pointerdown', event => window.touchPanPointers.push({ pointerType: event.pointerType, isPrimary: event.isPrimary })); return { point: { x, y }, sourceClass: String(hit.className), before: window.chart.getViewportState(), moves: window.moves.length }; } } } throw new Error('No safe visible blank point for touch Pan'); }); const session = await page.context().newCDPSession(page); await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [setup.point] }); for (let step = 1; step <= 8; step++) { await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: setup.point.x + 40 * step / 8, y: setup.point.y + 30 * step / 8 }] }); } await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await settle(); const after = await page.evaluate(() => ({ viewport: window.chart.getViewportState(), moves: window.moves.length, pointers: window.touchPanPointers, editing: window.shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging, panDragging: window.shadow.querySelector('.hm-orgchart-viewport').dataset.dragging })); check(after.pointers.some(event => event.pointerType === 'touch' && event.isPrimary) && Math.abs(after.viewport.x - setup.before.x - 40) < 1 && Math.abs(after.viewport.y - setup.before.y - 30) < 1 && after.viewport.scale === setup.before.scale && after.moves === setup.moves && !after.editing && !after.panDragging, 'Synthetic touch blank-space Pan translates forest, retains scale and triggers no card move', { setup, after }); measurements.push({ section: 'touch-pan', setup, after, realDevice: 'NOT VERIFIED' }); await session.send('Emulation.setTouchEmulationEnabled', { enabled: false }); await session.detach(); } if (run('fixed')) { await fresh(); await page.evaluate(() => window.mountChart('fixed')); await ready(); const fixed = await page.evaluate(async () => { const before = window.shadow.querySelector('.hm-orgchart-stage')?.outerHTML; let rejection; try { await window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' }); } catch (error) { rejection = error.message; } return { rejection, unchanged: before === window.shadow.querySelector('.hm-orgchart-stage')?.outerHTML, expanded: window.shadow.querySelectorAll('.hm-orgchart-expanded-toggle,.hm-orgchart-move-feedback,[data-edit-source],[data-drop-state]').length, draggable: window.shadow.querySelectorAll('[draggable="true"]').length, status: window.chart.getState().status, data: window.chart.getData(), events: window.moves.length }; }); check(fixed.rejection?.includes('Auto mode') && fixed.unchanged && fixed.status === 'ready' && fixed.events === 0, 'Fixed public handle rejects editing and retains rendered stage', fixed); check(fixed.expanded === 0 && fixed.draggable === 0 && fixed.data === undefined, 'Fixed has no editing controls, native drag or Auto browser-state API'); } check(errors.length === 0, 'Vanilla Core change-only verification has no console/page errors', errors); } catch (error) { check(false, 'Unexpected verification error', { message: error.message, stack: error.stack }); await page.screenshot({ path: path.join(output, `vanilla-error${suffix}.png`), fullPage: true }).catch(() => {}); } finally { await browser.close(); const completedAt = new Date().toISOString(); const counts = { PASS: results.filter(result => result.status === 'PASS').length, FAIL: results.filter(result => result.status === 'FAIL').length }; await fs.writeFile(path.join(output, `core-browser${suffix}.json`), JSON.stringify({ startedAt, completedAt, origin, counts, results, measurements, errors }, null, 2)); console.log(JSON.stringify({ counts, origin, output: `test-results/org-chart-p9-3/core-browser${suffix}.json` })); if (counts.FAIL || errors.length) process.exitCode = 1; }