Files
workspace/code/g3soft-libs/packages/ui/probe-table-prod.mjs
T
2026-10-16 17:30:17 +08:00

261 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { chromium } from '@playwright/test'
/**
* /table-test 探针 v13 —— 生产构建对照(只读诊断,不改仓库代码)。
*
* 目的:排除「Vite dev 模式 + 未压缩 + sourcemap/HMR 开销」这一解释。
* 应用从**生产构建**(vite build 输出,served by vite preview :5190)加载;
* 由于 preview 不带 API 代理,这里把 /api/** 转发到 dev server(:5090)拿真实数据,
* 并在响应里补一条 menu.table_test 授权(与前面几个探针同理,仅测试进程内生效)。
*/
const PROD = process.env.PROD_URL || 'http://127.0.0.1:5190'
const API_ORIGIN = process.env.API_ORIGIN || 'http://127.0.0.1:5090'
const MENU_CODE = 'menu.table_test'
const median = (a) => {
const s = [...a].filter((x) => x != null).sort((x, y) => x - y)
return s.length ? s[s.length >> 1] : null
}
const r1 = (n) => (n == null ? null : +Number(n).toFixed(1))
const browser = await chromium.launch({ headless: false })
const context = await browser.newContext({ viewport: { width: 1600, height: 900 } })
/**
* 生产构建里 `import.meta.env.DEV === false`,dev 自动登录整段被 tree-shake 掉了,
* 直奔 /table-test 会停在 /login。所以这里自己换一个 token,再用 addInitScript
* 在应用脚本执行前种进 sessionStorage(与 web/e2e/support/fixtures.ts 同一手法)。
*
* 账号取自 web/.env.development(VITE_DEV_*)。
*/
async function freshToken() {
const res = await fetch(`${API_ORIGIN}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
orgid: process.env.E2E_ORG || 'G3HD',
userid: process.env.E2E_ACCOUNT || 'demo_zhangwei',
password: process.env.E2E_PASSWORD ?? 'demo_zhangwei',
}),
})
const body = await res.json()
if (body.code !== 0 || !body.data?.token) {
throw new Error(`登录失败 code=${body.code} ${body.message || ''}`)
}
return body.data
}
const session = await freshToken()
console.log('登录成功:', session.user?.account, session.orgId)
await context.addInitScript(
(payload) => {
window.sessionStorage.setItem(payload.key, JSON.stringify(payload.value))
},
{
key: 'g3erp:login',
value: { loginInfo: { token: session.token, user: session.user } },
},
)
// /api/** → 转发到 dev server(拿真实后端数据),并按需注入菜单授权
await context.route('**/api/**', async (route) => {
const req = route.request()
const url = new URL(req.url())
const target = `${API_ORIGIN}${url.pathname}${url.search}`
let viewName = ''
try {
viewName = JSON.parse(req.postData() || '').view_name || ''
} catch {
/* ignore */
}
const res = await route.fetch({ url: target })
if (viewName !== 's_power' && viewName !== 's_user_power') {
return route.fulfill({ response: res })
}
let json
try {
json = await res.json()
} catch {
return route.fulfill({ response: res })
}
const rows = Array.isArray(json?.data) ? json.data : []
if (viewName === 's_power') {
if (!rows.some((r) => r.b_id === MENU_CODE)) rows.push({ b_id: MENU_CODE, b_canuse: 1 })
} else if (!rows.some((r) => r.b_power_id === MENU_CODE)) {
rows.push({ b_user_id: 'demo_zhangwei', b_power_id: MENU_CODE, b_canuse: 1 })
}
json.data = rows
await route.fulfill({
response: res,
body: JSON.stringify(json),
headers: { ...res.headers(), 'content-type': 'application/json' },
})
})
const page = await context.newPage()
page.on('pageerror', (e) => console.log('[pageerror]', String(e).slice(0, 200)))
console.log('打开生产构建 /table-test ...')
await page.goto(`${PROD}/table-test`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 90000 })
await page.waitForTimeout(3500)
const env = await page.evaluate(() => ({
url: location.href,
scripts: [...document.querySelectorAll('script[src]')]
.map((s) => s.getAttribute('src'))
.filter((s) => s.includes('/assets/'))
.slice(0, 3),
tds: document.querySelectorAll('.stk-tbody-main td').length,
trs: document.querySelectorAll('.stk-tbody-main tr').length,
editorRoots: document.querySelectorAll('.g3-table__cell-editor-root').length,
}))
console.log('环境:', JSON.stringify(env, null, 2))
async function scrollFps({ axis = 'y', steps = 60, px = 60 } = {}) {
const ok = await page.evaluate(() => !!document.querySelector('.stk-table'))
if (!ok) return { fps: null }
return page.evaluate(
async ({ axis, steps, px }) => {
const cont = document.querySelector('.stk-table')
cont.scrollTop = 0
cont.scrollLeft = 0
await new Promise((r) => requestAnimationFrame(r))
const frames = []
let last = performance.now()
let stop = false
const tick = () => {
const now = performance.now()
frames.push(now - last)
last = now
if (!stop) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
for (let i = 0; i < steps; i += 1) {
await new Promise((r) => requestAnimationFrame(r))
if (axis === 'y') {
const max = cont.scrollHeight - cont.clientHeight
cont.scrollTop = cont.scrollTop >= max - 1 ? 0 : Math.min(max, cont.scrollTop + px)
} else {
const max = cont.scrollWidth - cont.clientWidth
cont.scrollLeft = cont.scrollLeft >= max - 1 ? 0 : Math.min(max, cont.scrollLeft + px)
}
}
stop = true
const sorted = [...frames].sort((a, b) => a - b)
const avg = frames.reduce((a, b) => a + b, 0) / frames.length
const q = (p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
return { fps: +(1000 / avg).toFixed(1), avg: +avg.toFixed(1), p95: +q(0.95).toFixed(1) }
},
{ axis, steps, px },
)
}
console.log('\n--- 生产构建:纵向滚动 ×3 ---')
const yRuns = []
for (let i = 0; i < 3; i += 1) yRuns.push(await scrollFps({ axis: 'y', steps: 60, px: 60 }))
console.log(JSON.stringify(yRuns), `中位 ${r1(median(yRuns.map((r) => r.fps)))}fps`)
console.log('--- 生产构建:横向滚动 ×3 ---')
const xRuns = []
for (let i = 0; i < 3; i += 1) xRuns.push(await scrollFps({ axis: 'x', steps: 50, px: 80 }))
console.log(JSON.stringify(xRuns), `中位 ${r1(median(xRuns.map((r) => r.fps)))}fps`)
console.log('--- 生产构建:静止基线 ---')
const idle = []
for (let i = 0; i < 2; i += 1) idle.push(await scrollFps({ axis: 'y', steps: 45, px: 0 }))
console.log(JSON.stringify(idle), `中位 ${r1(median(idle.map((r) => r.fps)))}fps`)
/* 点击延迟(生产) */
console.log('\n--- 生产构建:点击进入编辑 ---')
async function clickProbe(rowIdx, colIdx, settle = 350) {
await page.waitForTimeout(settle)
return page.evaluate(
async ({ rowIdx, colIdx }) => {
const tr = [...document.querySelectorAll('.stk-tbody-main tr')][rowIdx]
if (!tr) return { error: 'no row' }
const td = [...tr.querySelectorAll('td')][colIdx]
if (!td) return { error: 'no td' }
const r = td.getBoundingClientRect()
if (r.top < 0 || r.bottom > window.innerHeight) return { error: 'offscreen' }
const t0 = performance.now()
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent
td.dispatchEvent(
new Ctor(type, {
bubbles: true,
cancelable: true,
clientX: r.left + r.width / 2,
clientY: r.top + r.height / 2,
button: 0,
}),
)
}
let mounted = -1
const deadline = performance.now() + 4000
while (performance.now() < deadline) {
if (td.querySelector('.g3-table__cell-editor-root')) {
mounted = performance.now() - t0
break
}
await new Promise((r2) => requestAnimationFrame(r2))
}
await new Promise((r2) => requestAnimationFrame(r2))
return +mounted.toFixed(1)
},
{ rowIdx, colIdx },
)
}
await page.reload({ waitUntil: 'domcontentloaded' })
await page.waitForSelector('.stk-table', { timeout: 90000 })
await page.waitForTimeout(3000)
console.log('首次点击:', JSON.stringify(await clickProbe(3, 6, 500)))
const clicks = []
for (const r of [5, 7, 9, 11]) clicks.push(await clickProbe(r, 6, 350))
console.log('换行点击:', JSON.stringify(clicks), `中位 ${r1(median(clicks))}ms`)
const same = []
for (let i = 0; i < 3; i += 1) same.push(await clickProbe(11, 6, 250))
console.log('同格连点:', JSON.stringify(same))
/* 编辑态滚动 */
await clickProbe(3, 6, 300)
console.log('\n--- 生产构建:编辑态纵向滚动 ×2 ---')
const edRuns = []
for (let i = 0; i < 2; i += 1) edRuns.push(await scrollFps({ axis: 'y', steps: 55, px: 60 }))
console.log(JSON.stringify(edRuns), `中位 ${r1(median(edRuns.map((r) => r.fps)))}fps`)
/* errorCount / 保存 */
console.log('\n--- 生产构建:保存明细 + errorCount(1000 行)---')
const save = await page.evaluate(async () => {
let i = document.querySelector('.booking')?.__vueParentComponent
while (i && !i.setupState?.rows) i = i.parent
if (!i) return { error: 'no page instance' }
const ss = i.setupState
const rows = ss.rows?.value ?? ss.rows
void ss.errorCount?.value
rows[0].freight = Number(rows[0].freight) + 0.001
const t0 = performance.now()
void ss.errorCount?.value
const ecMs = performance.now() - t0
rows[0].freight = Number(rows[0].freight) - 0.001
const btn = [...document.querySelectorAll('.booking__btn')].find((b) =>
b.textContent.includes('保存明细'),
)
const t1 = performance.now()
btn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await new Promise((r) => requestAnimationFrame(r))
await new Promise((r) => setTimeout(r, 0))
return {
rows: rows.length,
errorCountRecomputeMs: +ecMs.toFixed(1),
saveClickToNextFrameMs: +(performance.now() - t1).toFixed(1),
}
})
console.log(JSON.stringify(save))
await page.screenshot({ path: 'probe-table-prod.png' })
await browser.close()
console.log('\n完成。截图 -> probe-table-prod.png')