const origin = 'http://localhost:5173' 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 const requestId = id const timer = setTimeout(() => { pending.delete(requestId) reject(new Error(`CDP request timed out: ${method}`)) }, 8000) pending.set(requestId, { resolve: (value) => { clearTimeout(timer); resolve(value) }, reject: (error) => { clearTimeout(timer); reject(error) } }) socket.send(JSON.stringify({ id: requestId, 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) } let navigationId = 0 const openG01 = async (send) => { navigationId += 1 await send('Page.navigate', { url: `${origin}/?g01SwitchAudit=${navigationId}#/pages/genealogy/g01-my-genealogies` }) await waitFor(send, "Boolean(document.querySelector('.current-slip'))", 'G01 did not render after navigation') } 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-dialog')), switcherVisible: Boolean(dialog), borderImageSource: dialog ? getComputedStyle(dialog).borderImageSource : '', borderImageSlice: dialog ? getComputedStyle(dialog).borderImageSlice : '' } })()`) 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 openG01(send) await openSwitcher(send) const sizeResults = [] for (const size of sizes) { await setSize(send, size.width, size.height) await openG01(send) 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 setSize(send, 412, 915) await openG01(send) await openSwitcher(send) 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') 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') assert(await addClonesToTotal(send, 50), 'Could not prepare fifty-item stress state') await sleep(100) const fiftyItemsTop = await getMetrics(send) assert(fiftyItemsTop.itemCount === 50, 'Fifty-item state did not contain fifty items') assert(Math.abs(fiftyItemsTop.dialog.height - expectedMaximumHeight) < 3, 'Fifty-item dialog escaped its safe maximum height') assert(fiftyItemsTop.listScrollHeight > fiftyItemsTop.listClientHeight * 3, 'Fifty-item list does not own its long-data scrolling') await valueOf(send, `(() => { const list = document.querySelector('.genealogy-switcher__list') list.scrollTop = list.scrollHeight return true })()`) await sleep(50) const fiftyItemsBottom = await getMetrics(send) assert(fiftyItemsBottom.listScrollTop > 0, 'Fifty-item list did not reach its bottom data') 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') assert((await valueOf(send, "document.querySelector('.genealogy-card--current')?.textContent"))?.includes('汤氏宗谱'), 'Joined genealogy card did not expose the current visual state') assert( (await valueOf(send, "document.querySelector('.current-meta')?.textContent"))?.includes('成员'), 'Joined genealogy did not display the member role' ) assert( (await valueOf(send, "document.querySelectorAll('.shortcut-item').length")) === 3, 'Joined genealogy did not hide the application-review shortcut' ) assert( !(await valueOf(send, "document.querySelector('.shortcut-grid')?.textContent"))?.includes('申请审核'), 'Joined genealogy still exposed application review' ) 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') assert( (await valueOf(send, "document.querySelector('.current-meta')?.textContent"))?.includes('管理员'), 'Created genealogy did not restore the administrator role' ) assert( (await valueOf(send, "document.querySelectorAll('.shortcut-item').length")) === 4, 'Created genealogy did not restore all four shortcuts' ) await openSwitcher(send) await clearClones(send) await valueOf(send, "document.querySelector('.genealogy-switcher__close').click()") await valueOf(send, `(() => { const cards = document.querySelectorAll('.genealogy-card') if (cards.length < 2) return false cards[0].click() cards[1].click() return true })()`) await waitFor(send, "location.href.includes('/pages/genealogy/g05-genealogy-overview?genealogyId=1001')", 'Rapid genealogy clicks did not keep the first accepted navigation target') await valueOf(send, "document.querySelector('.header-back')?.click()") await waitFor(send, "location.href.includes('/pages/genealogy/g01-my-genealogies')", 'G05 did not return to the existing G01 instance') assert((await valueOf(send, "document.querySelector('.current-slip')?.textContent"))?.includes('河南'), 'Rejected second navigation overwrote the visible current genealogy') assert((await valueOf(send, "document.querySelector('.current-meta')?.textContent"))?.includes('管理员'), 'Rejected second navigation overwrote the persisted genealogy context') 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, fiftyItemsTop, fiftyItemsBottom, 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) })