const fs = require('fs') const path = require('path') const origin = 'http://localhost:5173' const outputDirectory = path.resolve('docs/design/screens/runtime/2026-07-19/g01-approval') const sizes = [ { width: 320, height: 568 }, { width: 360, height: 640 }, { width: 360, height: 800 }, { width: 412, height: 915 } ] const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) const connect = async () => { const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json() const projectPages = pages.filter((page) => page.type === 'page' && page.url.startsWith(origin)) if (projectPages.length !== 1) throw new Error(`Expected one project page, found ${projectPages.length}`) const socket = new WebSocket(projectPages[0].webSocketDebuggerUrl) await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }) socket.addEventListener('error', reject, { once: true }) }) let id = 0 const pending = new Map() socket.addEventListener('message', (event) => { const message = JSON.parse(event.data) const request = pending.get(message.id) if (!request) return pending.delete(message.id) if (message.error) request.reject(new Error(message.error.message)) else request.resolve(message.result) }) const send = (method, params = {}) => new Promise((resolve, reject) => { id += 1 pending.set(id, { resolve, reject }) socket.send(JSON.stringify({ id, method, params })) }) return { projectPageCount: projectPages.length, socket, send } } const valueOf = async (send, expression) => { const result = await send('Runtime.evaluate', { expression, returnByValue: true }) if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Runtime evaluation failed') return result.result?.value } const waitFor = async (send, expression, message) => { for (let attempt = 0; attempt < 50; attempt += 1) { if (await valueOf(send, expression)) return await sleep(100) } throw new Error(message) } const setSize = async (send, width, height) => { await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: true, screenWidth: width, screenHeight: height }) await sleep(150) } const openSwitcher = async (send) => { if (!await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))")) { const clicked = await valueOf(send, `(() => { const trigger = document.querySelector('.current-slip') if (!trigger) return false trigger.click() return true })()`) if (!clicked) throw new Error('Could not find the switch genealogy trigger') } await waitFor(send, "Boolean(document.querySelector('.genealogy-switcher'))", 'Switcher did not open') } const clearClones = (send) => valueOf(send, `(() => { document.querySelectorAll('[data-cdp-clone="1"]').forEach((node) => node.remove()) return true })()`) const addClonesToTotal = (send, total) => valueOf(send, `(() => { document.querySelectorAll('[data-cdp-clone="1"]').forEach((node) => node.remove()) const list = document.querySelector('.genealogy-switcher__list') const originals = Array.from(list?.querySelectorAll('.switcher-item') || []) if (!list || originals.length !== 2) return false for (let index = originals.length; index < ${total}; index += 1) { const clone = originals[index % originals.length].cloneNode(true) clone.dataset.cdpClone = '1' clone.querySelector('.switcher-item__name').textContent = '压力测试家谱 ' + (index + 1) clone.querySelector('.switcher-item__state').textContent = '选择' clone.classList.remove('switcher-item--active') list.appendChild(clone) } return true })()`) const getMetrics = (send) => valueOf(send, `(() => { const dialog = document.querySelector('.genealogy-switcher') const content = document.querySelector('.genealogy-switcher__content') const title = document.querySelector('.dialog-title') const close = document.querySelector('.genealogy-switcher__close') const list = document.querySelector('.genealogy-switcher__list') const items = Array.from(document.querySelectorAll('.genealogy-switcher__list .switcher-item')) const rect = (node) => node ? { top: node.getBoundingClientRect().top, right: node.getBoundingClientRect().right, bottom: node.getBoundingClientRect().bottom, left: node.getBoundingClientRect().left, width: node.getBoundingClientRect().width, height: node.getBoundingClientRect().height } : null const last = items.at(-1) return { viewport: { width: innerWidth, height: innerHeight }, dialog: rect(dialog), content: rect(content), title: rect(title), close: rect(close), list: rect(list), itemCount: items.length, cloneCount: document.querySelectorAll('[data-cdp-clone="1"]').length, listClientHeight: list?.clientHeight || 0, listScrollHeight: list?.scrollHeight || 0, listScrollTop: list?.scrollTop || 0, blankBelowLastItem: list && last ? list.getBoundingClientRect().bottom - last.getBoundingClientRect().bottom : null, horizontalOverflow: document.documentElement.scrollWidth > innerWidth || document.body.scrollWidth > innerWidth, addDialogVisible: Boolean(document.querySelector('.add-genealogy-sheet')), switcherVisible: Boolean(dialog), borderImageSource: dialog ? getComputedStyle(dialog).borderImageSource : '', borderImageSlice: dialog ? getComputedStyle(dialog).borderImageSlice : '' } })()`) const capture = async (send, filename) => { const screenshot = await send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false }) fs.mkdirSync(outputDirectory, { recursive: true }) fs.writeFileSync(path.join(outputDirectory, filename), Buffer.from(screenshot.data, 'base64')) } const assert = (condition, message) => { if (!condition) throw new Error(message) } const run = async () => { const { projectPageCount, socket, send } = await connect() const runtimeErrors = [] const resourceErrors = [] socket.addEventListener('message', (event) => { const message = JSON.parse(event.data) if (message.method === 'Runtime.exceptionThrown') runtimeErrors.push(message.params.exceptionDetails?.text || 'runtime exception') if (message.method === 'Network.responseReceived' && message.params.response.status >= 400) { resourceErrors.push(`${message.params.response.status} ${message.params.response.url}`) } }) try { await send('Page.enable') await send('Runtime.enable') await send('Network.enable') await setSize(send, 412, 915) await send('Page.reload') await waitFor(send, "Boolean(document.querySelector('.current-slip'))", 'G01 did not render after reload') await openSwitcher(send) const sizeResults = [] for (const size of sizes) { await setSize(send, size.width, size.height) await clearClones(send) await openSwitcher(send) const metrics = await getMetrics(send) const rpx = size.width / 750 assert(metrics.itemCount === 2, `${size.width}x${size.height}: expected two items`) const expectedDialogHeight = (600 * rpx) + 2 assert(Math.abs(metrics.dialog.height - expectedDialogHeight) < 1, `${size.width}x${size.height}: dialog height ${metrics.dialog.height}px is not the expected ${expectedDialogHeight}px including its 1px border`) assert(metrics.title.top > metrics.dialog.top + (78 * rpx), `${size.width}x${size.height}: title overlaps top decoration`) assert(metrics.close.top >= metrics.dialog.top && metrics.close.right <= metrics.dialog.right + 1, `${size.width}x${size.height}: close control is clipped`) assert(metrics.listScrollHeight <= metrics.listClientHeight + 1, `${size.width}x${size.height}: two-item list unexpectedly scrolls`) assert(metrics.blankBelowLastItem <= (72 * rpx) + 2, `${size.width}x${size.height}: too much blank space below last item`) assert(!metrics.horizontalOverflow, `${size.width}x${size.height}: horizontal overflow`) sizeResults.push(metrics) await capture(send, `06-switch-dialog-stretchable-${size.width}x${size.height}.png`) } await setSize(send, 412, 915) assert(await addClonesToTotal(send, 6), 'Could not prepare six-item stress state') await sleep(100) const sixItems = await getMetrics(send) assert(sixItems.itemCount === 6, 'Six-item state did not contain six items') assert(sixItems.dialog.height > sizeResults.at(-1).dialog.height + 1, 'Six-item dialog did not grow') assert(sixItems.dialog.height < 915 - ((120 * 412) / 750) - 1, 'Six-item dialog reached the maximum height too early') assert(sixItems.listScrollHeight <= sixItems.listClientHeight + 1, 'Six-item list unexpectedly scrolls') await capture(send, '06-switch-dialog-stretchable-six-items-412x915.png') assert(await addClonesToTotal(send, 12), 'Could not prepare twelve-item stress state') await sleep(100) const twelveItemsTop = await getMetrics(send) const expectedMaximumHeight = 915 - ((120 * 412) / 750) assert(Math.abs(twelveItemsTop.dialog.height - expectedMaximumHeight) < 3, `Twelve-item dialog height ${twelveItemsTop.dialog.height}px did not stop at the expected ${expectedMaximumHeight}px safe maximum: ${JSON.stringify(twelveItemsTop)}`) assert(twelveItemsTop.listScrollHeight > twelveItemsTop.listClientHeight + 1, 'Twelve-item list does not scroll') assert(twelveItemsTop.listScrollTop === 0, 'Twelve-item list did not start at the top') await valueOf(send, `(() => { const list = document.querySelector('.genealogy-switcher__list') list.scrollTop = list.scrollHeight return true })()`) await sleep(50) const twelveItemsBottom = await getMetrics(send) assert(twelveItemsBottom.listScrollTop > 0, 'Twelve-item list did not scroll to the bottom') await clearClones(send) await valueOf(send, "document.querySelector('.genealogy-switcher__content').click()") assert(await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))"), 'Inner click closed the switcher') await valueOf(send, "document.querySelector('.genealogy-switcher__close').click()") assert(!await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))"), 'Close icon did not close the switcher') await openSwitcher(send) await valueOf(send, "document.querySelector('.genealogy-switcher-layer').click()") assert(!await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))"), 'Mask did not close the switcher') await openSwitcher(send) await valueOf(send, "document.querySelectorAll('.switcher-item')[1].click()") await waitFor(send, "!document.querySelector('.genealogy-switcher')", 'Selecting the second genealogy did not close the switcher') assert((await valueOf(send, "document.querySelector('.current-slip')?.textContent"))?.includes('山东'), 'Selecting the second genealogy did not update the current genealogy') await openSwitcher(send) await valueOf(send, "document.querySelectorAll('.switcher-item')[0].click()") await waitFor(send, "!document.querySelector('.genealogy-switcher')", 'Restoring the first genealogy did not close the switcher') await openSwitcher(send) await clearClones(send) const final = await getMetrics(send) assert(final.viewport.width === 412 && final.viewport.height === 915, 'Final viewport is not 412x915') assert(final.itemCount === 2 && final.cloneCount === 0, 'Final state contains temporary items') assert(final.switcherVisible && !final.addDialogVisible, 'Final state does not show only the switcher') assert(!final.horizontalOverflow, 'Final state has horizontal overflow') assert(runtimeErrors.length === 0, `Runtime exceptions: ${runtimeErrors.join(' | ')}`) assert(resourceErrors.length === 0, `Resource errors: ${resourceErrors.join(' | ')}`) process.stdout.write(`${JSON.stringify({ projectPageCount, sizeResults, sixItems, twelveItemsTop, twelveItemsBottom, final, runtimeErrors, resourceErrors }, null, 2)}\n`) process.stdout.write('PASS G-01 switch dialog runtime smoke\n') } finally { socket.close() } } run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`) process.exit(1) })