// Build non-destructive fixtures and verify full-name identity and error disclosure via CDP. // Run with Windows Node while the local app and a dedicated Chrome CDP port 9444 are running. const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict'); const XLSX = require('xlsx'); const root = path.resolve(__dirname, '..'); const out = path.join(root, '.verify', 'abut-names-20261105'); const report = { checks: [], errors: [], started: new Date().toISOString() }; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); fs.mkdirSync(out, { recursive: true }); const base = XLSX.readFile(path.resolve(root, '../../RAG/운곡교P1입력.xlsx')); const blocks = { info: [], ips: [], bearings: [] }; for (const sheet of base.SheetNames) { const rows = XLSX.utils.sheet_to_json(base.Sheets[sheet], { header: 1, defval: null }); for (let h = 0; h < rows.length; h++) { if (rows[h][0] !== '교대명') continue; const kind = ({ 형식: 'info', IP점: 'ips', 받침번호: 'bearings' })[rows[h][1]]; if (!kind) continue; let name; for (let i = h + 1; i < rows.length && rows[i][1] != null && rows[i][1] !== ''; i++) { name = rows[i][0] || name; if (name === 'A1') blocks[kind].push(['A2-완도', ...rows[i].slice(1)]); } } for (const row of rows) if (row[0] === 'A1') row[0] = 'A1-완도'; base.Sheets[sheet] = XLSX.utils.aoa_to_sheet(rows); } assert.equal(blocks.info.length, 1); blocks.info[0][1] = 'A2'; XLSX.utils.book_append_sheet(base, XLSX.utils.aoa_to_sheet([ ['교대명', '형식'], ...blocks.info, [], ['교대명', 'IP점'], ...blocks.ips, [], ['교대명', '받침번호'], ...blocks.bearings, ]), '종점교대'); const validFile = path.join(out, 'full-names.xlsx'); XLSX.writeFile(base, validFile); const bad = XLSX.readFile(validFile); for (const sheet of bad.SheetNames) { const rows = XLSX.utils.sheet_to_json(bad.Sheets[sheet], { header: 1, defval: null }); for (const row of rows) if (row[0] === 'A1-완도' && row[1] === 'A1') row[1] = 'A3'; bad.Sheets[sheet] = XLSX.utils.aoa_to_sheet(rows); } const badFile = path.join(out, 'invalid-type.xlsx'); XLSX.writeFile(bad, badFile); let ws; let send; async function main() { const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json(); ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); let seq = 0; let loaded; let downloadGuid; const done = new Set(); const pending = new Map(); send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout ${method}`)); }, 20000); pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params })); }); ws.onmessage = ({ data }) => { const msg = JSON.parse(data); if (msg.id) { const wait = pending.get(msg.id); if (!wait) return; clearTimeout(wait.timer); pending.delete(msg.id); if (msg.error) wait.reject(new Error(JSON.stringify(msg.error))); else wait.resolve(msg.result); } else if (msg.method === 'Page.loadEventFired') loaded?.(); else if (msg.method === 'Runtime.exceptionThrown') report.errors.push(msg.params.exceptionDetails.exception?.description || msg.params.exceptionDetails.text); else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') report.errors.push(msg.params.args.map((a) => a.value ?? a.description).join(' ')); else if (msg.method === 'Browser.downloadWillBegin') downloadGuid = msg.params.guid; else if (msg.method === 'Browser.downloadProgress' && msg.params.state === 'completed') done.add(msg.params.guid); }; const ev = async (expression) => { const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); return r.result.value; }; const until = async (expression) => { for (let i = 0; i < 100; i++) { if (await ev(expression)) return; await delay(150); } throw new Error(`Timeout: ${expression}`); }; const click = (expression) => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled target'); e.click(); })()`); const check = (name, condition) => { assert.ok(condition, name); report.checks.push(name); console.log('PASS', name); }; const shot = async (name) => { await delay(250); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(r.data, 'base64')); }; const file = async (selector, name) => { const doc = await send('DOM.getDocument'); const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector }); await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [name] }); }; const navigate = async () => { let timer; const load = new Promise((resolve, reject) => { loaded = resolve; timer = setTimeout(() => reject(new Error('Load timeout')), 20000); }); await send('Page.navigate', { url: 'http://127.0.0.1:5173/' }); try { await load; } finally { clearTimeout(timer); loaded = undefined; } await until(`typeof window.loadBytesToApp === 'function' && !!document.querySelector('[data-card="excel"] button')`); }; const select = (name) => click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes(${JSON.stringify(name)}) && b.textContent.includes('교대'))`); await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable'); await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false }); const downloads = 'C:\\Users\\User\\AppData\\Local\\Temp\\pierzainer-abut-name-20261105\\downloads'; await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true }); await navigate(); await click(`document.querySelector('[data-card="excel"] button')`); await ev(`document.getElementById('project-name').value = '복합 교대명 검증'; document.getElementById('project-name').dispatchEvent(new Event('input'));`); await file('#file', validFile); await until(`document.getElementById('dlg-apply').textContent.includes('교대 2기')`); check('both full names recognized without cascade errors', await ev(`document.getElementById('pv-abuts').textContent.includes('A1-완도') && document.getElementById('pv-abuts').textContent.includes('A2-완도') && !document.querySelector('#sheets .bad')`)); check('full-name checkboxes stay distinct', await ev(`document.getElementById('import-abut-A1-완도').checked && document.getElementById('import-abut-A2-완도').checked`)); await shot('01-full-names-import'); await click(`document.getElementById('dlg-apply')`); await click(`document.getElementById('nav-rebar')`); await select('A1-완도'); check('full name selects the start abutment', await ev(`document.getElementById('rebar-note').textContent.startsWith('A1-완도') && document.querySelectorAll('#rebar-tabs button').length === 6`)); await select('A2-완도'); check('full name selects the end abutment independently', await ev(`document.getElementById('rebar-note').textContent.startsWith('A2-완도')`)); const order = await ev(`[...document.querySelectorAll('#rebar-pier-items button')].map(b => b.textContent)`); check('type-based order is preserved', order[0].includes('A1-완도') && order[1].includes('P1') && order[2].includes('A2-완도')); await shot('02-independent-abutments'); await click(`document.getElementById('save-project')`); await until(`document.getElementById('save-dialog').open`); await click(`document.getElementById('sv-save')`); for (let i = 0; i < 100 && !(downloadGuid && done.has(downloadGuid)); i++) await delay(150); assert.ok(downloadGuid && done.has(downloadGuid), 'actual download completes'); const text = fs.readFileSync(path.join(downloads, downloadGuid), 'utf8'); const project = JSON.parse(text); const expectedNames = ['A1-완도', 'A2-완도']; assert.deepEqual(project.abutments.map((a) => a.name), expectedNames); assert.deepEqual(project.abutDefaults.map((a) => a.name), expectedNames); report.checks.push('actual download preserves both names and defaults'); const saved = path.join(out, 'full-names.pzr.json'); fs.writeFileSync(saved, text); await navigate(); await ev('window.openSampleProject()'); await click(`document.getElementById('open-project')`); await file('#project-file', saved); await until(`document.querySelector('#toast-host').textContent.includes('열었습니다')`); await click(`document.getElementById('nav-rebar')`); await select('A1-완도'); await select('A2-완도'); check('fresh reopen regenerates full-name abutments', await ev(`document.getElementById('save-status').textContent === '저장됨' && document.getElementById('rebar-note').textContent.startsWith('A2-완도')`)); await shot('03-reopened'); await click(`document.getElementById('open-import')`); await file('#file', badFile); await until(`document.getElementById('dlg-apply').textContent.includes('교대 1기') && !!document.querySelector('#sheets details')`); check('one bad definition does not disable another name', await ev(`document.getElementById('import-abut-A2-완도').checked && !document.getElementById('import-abut-A2-완도').disabled`)); check('dependent rows are collapsed under the root', await ev(`document.querySelectorAll('#sheets details').length === 1 && !document.querySelector('#sheets details').open && document.querySelector('#sheets details summary').textContent.includes('8개 행') && document.querySelectorAll('#sheets details p').length === 8`)); await shot('04-root-error-collapsed'); await click(`document.querySelector('#sheets details summary')`); check('dependent rows remain available for diagnosis', await ev(`document.querySelector('#sheets details').open && document.querySelector('#sheets details').textContent.includes('IP') && document.querySelector('#sheets details').textContent.includes('받침')`)); await shot('05-related-rows'); check('no browser runtime errors', report.errors.length === 0); report.result = 'PASS'; } main().catch((error) => { report.result = 'FAIL'; report.failure = error.stack; process.exitCode = 1; }).finally(async () => { if (report.result === 'FAIL' && send) { try { const shot = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64')); } catch { /* Keep the original error. */ } } report.finished = new Date().toISOString(); fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2)); console.log(JSON.stringify({ result: report.result, checks: report.checks.length, errors: report.errors, failure: report.failure })); if (send) { try { await send('Page.close'); } catch { /* The tab may already be closed. */ } } ws?.close(); });