// Verify A1's six rebar cards, rendered views, edits and project round trip via CDP. // Run with Windows Node when CDP is served by Windows Chrome: // node scripts/verify-abut-workflow.cjs const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict'); const [input, out, downloads, mode] = process.argv.slice(2); if (!input || !out || !downloads) throw new Error('input, output and download directories are required'); const base = process.env.CDP || 'http://127.0.0.1:9444'; const url = process.env.APP_URL || 'http://127.0.0.1:5173/'; const report = mode === '--resume' ? JSON.parse(fs.readFileSync(path.join(out, 'report.json'), 'utf8')) : { input, url, started: new Date().toISOString(), cards: [], errors: [], checks: [] }; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); fs.mkdirSync(out, { recursive: true }); let ws; async function main() { console.log('Connecting to Chrome', base); const targets = await (await fetch(`${base}/json`, { signal: AbortSignal.timeout(10000) })).json(); const target = targets.find((t) => t.type === 'page'); assert.ok(target, 'Chrome page target'); ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); let next = 0; const pending = new Map(); const completed = new Set(); let downloadGuid; let loaded; ws.onmessage = ({ data }) => { const message = JSON.parse(data); if (message.id) { const waiter = pending.get(message.id); if (!waiter) return; clearTimeout(waiter.timer); pending.delete(message.id); if (message.error) waiter.reject(new Error(JSON.stringify(message.error))); else waiter.resolve(message.result); } else if (message.method === 'Page.loadEventFired') loaded?.(); else if (message.method === 'Runtime.exceptionThrown') { report.errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text); } else if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') { report.errors.push(message.params.args.map((v) => v.value ?? v.description ?? '').join(' ')); } else if (message.method === 'Browser.downloadWillBegin') downloadGuid = message.params.guid; else if (message.method === 'Browser.downloadProgress' && message.params.state === 'completed') completed.add(message.params.guid); }; const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++next; 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 })); }); const evaluate = async (expression) => { const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); return result.result.value; }; const navigate = async () => { let timer; const event = new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('Page load timeout')), 20000); loaded = resolve; }); await send('Page.navigate', { url }); try { await event; } finally { clearTimeout(timer); loaded = undefined; } await until(() => evaluate(`typeof window.loadBytesToApp === 'function'`), 'app event handlers loaded'); }; const until = async (test, label) => { const end = Date.now() + 20000; while (Date.now() < end) { if (await test()) return; await delay(150); } report.timeoutPage = await evaluate('document.body.innerText'); const shot = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64')); throw new Error(`Timeout: ${label}`); }; const click = (expression) => evaluate(`(() => { const node = ${expression}; if (!node) throw new Error('Missing click target'); if (node.disabled) throw new Error('Disabled click target'); node.click(); return true; })()`); const selectCard = async (label) => { await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(label)}))`); await until(() => evaluate(`document.querySelector('#rebar-table svg') !== null`), label); await delay(250); }; const screenshot = async (name) => { const shot = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(shot.data, 'base64')); }; const upload = async (selector, file) => { const doc = await send('DOM.getDocument'); const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector }); assert.ok(node.nodeId, selector); await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [file] }); }; const editFirst = async (value) => evaluate(`(() => { const node = document.querySelector('#rebar-table .abut-rebar-inputs input[type=number]'); if (!node) throw new Error('Missing rebar input'); const old = { id: node.id, value: node.value }; node.value = ${JSON.stringify(value)}; node.dispatchEvent(new Event('change', { bubbles: true })); return old; })()`); const rows = () => evaluate(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter(r => r.cells.length > 2).map(r => r.innerText)`); const restoreView = async (card) => { for (const text of card.context) { await click(`[...document.querySelectorAll('#rebar-table .abut-optt')].find(b => b.textContent === ${JSON.stringify(text)})`); } }; await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable'); await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false }); await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true }); await send('Page.addScriptToEvaluateOnNewDocument', { source: ` window.__drawCalls = 0; for (const type of [window.WebGLRenderingContext, window.WebGL2RenderingContext]) { if (!type) continue; for (const method of ['drawElements', 'drawArrays', 'drawElementsInstanced', 'drawArraysInstanced']) { const original = type.prototype[method]; if (original) type.prototype[method] = function(...args) { window.__drawCalls++; return original.apply(this, args); }; } } ` }); if (mode !== '--resume') { await navigate(); await until(() => evaluate(`document.querySelector('[data-card="excel"] button') !== null && typeof window.loadBytesToApp === 'function'`), 'app loaded'); await click(`document.querySelector('[data-card="excel"] button')`); await evaluate(`const name = document.getElementById('project-name'); name.value = 'A1 화면 검증'; name.dispatchEvent(new Event('input', { bubbles: true }));`); await upload('#file', input); await until(() => evaluate(`!document.getElementById('dlg-apply').disabled`), 'Excel import'); report.import = await evaluate(`({ sheets: document.getElementById('sheets').innerText, selection: document.getElementById('dlg-selected').textContent })`); await screenshot('01-import'); await click(`document.getElementById('dlg-apply')`); await click(`document.getElementById('nav-rebar')`); await until(() => evaluate(`[...document.querySelectorAll('#rebar-pier-items button')].some(b => b.textContent.includes('교대'))`), 'abutment list'); await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('교대') && b.textContent.includes('A1'))`); await until(() => evaluate(`document.querySelectorAll('#rebar-tabs button').length === 6`), 'six member cards'); for (const [index, label] of ['받침블럭', '벽체', '흉벽', '확대기초', '날개벽', '접속슬래브'].entries()) { await selectCard(label); const before = await rows(); assert.ok(before.length, `${label}: non-empty rebar table`); const card = { label, rows: before, views: [] }; const views = await evaluate(`[...document.querySelectorAll('#rebar-table .abut-optt')].map(b => b.textContent)`); assert.ok(views.length >= 2, `${label}: view tabs`); for (const [viewIndex, view] of views.entries()) { const drawBefore = await evaluate('window.__drawCalls'); await click(`[...document.querySelectorAll('#rebar-table .abut-optt')].find(b => b.textContent === ${JSON.stringify(view)})`); await delay(350); const facts = await evaluate(`({ svg: !!document.querySelector('#rebar-table svg'), badSvg: /(?:NaN|Infinity)/.test(document.querySelector('#rebar-table svg')?.outerHTML || ''), drawCalls: window.__drawCalls, canvases: [...document.querySelectorAll('canvas')].map(c => ({ width: c.width, height: c.height })) })`); assert.ok(facts.svg && !facts.badSvg, `${label}/${view}: finite SVG`); assert.ok(facts.drawCalls > drawBefore, `${label}/${view}: WebGL draw calls`); await screenshot(`${String(index + 2).padStart(2, '0')}-${index}-${viewIndex}`); fs.writeFileSync(path.join(out, `${index}-${viewIndex}.svg`), await evaluate(`document.querySelector('#rebar-table svg').outerHTML`)); card.views.push({ view, ...facts }); } const originalValue = await evaluate(`document.querySelector('#rebar-table .abut-rebar-inputs input[type=number]').value`); const changedValue = originalValue === '150' ? '200' : '150'; const old = await editFirst(changedValue); const changed = await rows(); assert.notDeepEqual(changed, before, `${label}: input recomputes rows`); assert.equal(await evaluate(`document.getElementById(${JSON.stringify(old.id)}).value`), changedValue); card.edit = { ...old, changedValue, changedRows: changed }; { const marks = await evaluate(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter(r => r.cells.length > 2).map(r => r.cells[2].textContent)`); assert.ok(marks[0], `${label}: selectable mark`); await click(`document.querySelector('#rebar-table table.rebar-table tbody tr')`); await delay(150); if (!await evaluate(`document.querySelector('#rebar-table .rebar-select button:last-child').getAttribute('aria-pressed') === 'true'`)) { await click(`[...document.querySelectorAll('#rebar-table .rebar-select button')].find(b => b.textContent === '선택만 보기')`); } await delay(250); card.selection = await evaluate(`document.querySelector('#rebar-table .rebar-select').innerText`); assert.ok(card.selection.includes('선택 1 /'), `${label}: one selected bar`); await screenshot(`${String(index + 2).padStart(2, '0')}-${index}-selected`); } card.context = await evaluate(`[...document.querySelectorAll('#rebar-table .abut-optt[aria-pressed="true"]')].map(b => b.textContent)`); report.cards.push(card); console.log('Verified card', label); fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2)); } // Snapshot all tables after every edit, including dependent members and the wing side. for (const card of report.cards) { await selectCard(card.label); await restoreView(card); card.edit.changedRows = await rows(); } fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2)); await click(`document.getElementById('save-project')`); await until(() => evaluate(`document.getElementById('save-dialog').open`), 'save dialog'); await evaluate(`document.getElementById('sv-name').value = 'A1 화면 검증'; document.getElementById('sv-bridge').value = 'A1-verification'`); await click(`document.getElementById('sv-save')`); await until(async () => downloadGuid && completed.has(downloadGuid), 'download completes'); const downloaded = fs.readFileSync(path.join(downloads, downloadGuid), 'utf8'); const project = JSON.parse(downloaded); report.savedAbutments = project.abutments; assert.ok(project.abutments?.length, 'project contains abutments'); const savedFile = path.join(out, 'A1-roundtrip.pzr.json'); fs.writeFileSync(savedFile, downloaded); report.checks.push('actual project download'); fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2)); } await navigate(); // Enter the app shell through its existing sample entry, then open the saved file. // This uses no original Excel file for reconstruction. await evaluate('window.openSampleProject()'); await click(`document.getElementById('open-project')`); await upload('#project-file', path.join(out, 'A1-roundtrip.pzr.json')); await until(() => evaluate(`document.querySelector('#toast-host')?.textContent.includes('열었습니다') && document.getElementById('save-status').textContent === '저장됨'`), 'open saved project'); await click(`document.getElementById('nav-rebar')`); await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('교대') && b.textContent.includes('A1'))`); for (const card of report.cards) { await selectCard(card.label); await restoreView(card); const restored = await evaluate(`document.getElementById(${JSON.stringify(card.edit.id)})?.value`); assert.equal(restored, card.edit.changedValue, `${card.label}: restored input`); assert.deepEqual(await rows(), card.edit.changedRows, `${card.label}: restored computed rows`); card.roundtrip = 'PASS'; } await screenshot('09-restored'); report.checks.push('6 member cards', 'all SVG view tabs', 'WebGL drawing', '6 input edits', '6 bar selections', 'actual project download', 'fresh page project reopen', '6 restored inputs and computed tables'); assert.equal(report.errors.length, 0, 'browser runtime errors'); delete report.failure; delete report.timeoutPage; report.checks = [...new Set(report.checks)]; report.result = 'PASS'; } main().catch((error) => { report.result = 'FAIL'; report.failure = error.stack; process.exitCode = 1; }).finally(() => { report.finished = new Date().toISOString(); fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2)); console.log(JSON.stringify({ result: report.result, cards: report.cards.length, errors: report.errors, failure: report.failure })); ws?.close(); });