Files
jiapuapp/tests/g01-switch-dialog-runtime-smoke.js
T
2026-07-20 06:52:26 +08:00

257 lines
12 KiB
JavaScript

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)
})