Files
BoosAPI/desktop/libs/markstream-vue/scripts/e2e-main-playground-performance.mjs
T
boos e0a5f57793 feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
2026-06-22 11:32:57 +08:00

432 lines
16 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground')
const host = '127.0.0.1'
function isPortOpen(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4321, end = 4360) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeoutMs = 60000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function startDevServer(port) {
const logs = []
const child = spawn(
'pnpm',
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
{
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CI: '1',
},
},
)
child.stdout.on('data', chunk => logs.push(String(chunk)))
child.stderr.on('data', chunk => logs.push(String(chunk)))
return {
child,
getLogs() {
return logs.join('')
},
}
}
async function waitForVisibleBlocksReady(page, rootSelector) {
await page.waitForFunction((selector) => {
const root = document.querySelector(selector)
if (!root)
return false
const rootRect = root.getBoundingClientRect()
const isVisible = (element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > rootRect.top && rect.top < rootRect.bottom
}
const visibleCodeBlocks = Array.from(document.querySelectorAll('.code-block-container')).filter(isVisible)
const visibleMermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]')).filter(isVisible)
const visibleInfographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]')).filter(isVisible)
return visibleCodeBlocks.every(element => !element.querySelector('.code-fallback-plain, .code-pre-fallback'))
&& visibleMermaids.every(element => element.querySelector('svg'))
&& visibleInfographics.every(element => element.querySelector('svg'))
}, rootSelector, { timeout: 30000 })
}
async function waitForAllD2Ready(page, timeout = 15000) {
await page.waitForFunction(() => {
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
return d2Blocks.every(element => element.querySelector('.d2-svg svg'))
}, null, { timeout })
}
async function scrollThroughRoot(page, rootSelector) {
while (true) {
await waitForVisibleBlocksReady(page, rootSelector)
const state = await page.evaluate((selector) => {
const root = document.querySelector(selector)
if (!root)
return { done: true }
root.style.scrollBehavior = 'auto'
const maxScrollTop = Math.max(0, root.scrollHeight - root.clientHeight)
const step = Math.max(320, Math.round(root.clientHeight * 0.85))
const nextScrollTop = Math.min(maxScrollTop, root.scrollTop + step)
const done = nextScrollTop <= root.scrollTop + 1
if (!done)
root.scrollTop = nextScrollTop
return { done }
}, rootSelector)
if (state.done)
break
await page.waitForTimeout(180)
}
await waitForVisibleBlocksReady(page, rootSelector)
}
async function collectMetrics(page) {
const rootSelector = '.chatbot-messages'
await page.goto('/', { waitUntil: 'load' })
await page.locator('.chatbot-container').waitFor({ state: 'visible', timeout: 15000 })
await waitForVisibleBlocksReady(page, rootSelector)
await page.waitForTimeout(250)
const initial = await page.evaluate(() => {
const state = window.__mainPlaygroundPerf ?? {}
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
const mermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]'))
const infographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]'))
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
const root = document.querySelector('.chatbot-messages')
const rootRect = root?.getBoundingClientRect()
const isVisible = (element) => {
if (!rootRect)
return false
const rect = element.getBoundingClientRect()
return rect.bottom > rootRect.top && rect.top < rootRect.bottom
}
const visibleMermaids = mermaids.filter(isVisible)
const visibleInfographics = infographics.filter(isVisible)
const visibleD2Blocks = d2Blocks.filter(isVisible)
const visibleCodeBlocks = Array.from(document.querySelectorAll('.code-block-container')).filter(isVisible)
return {
lcpMs: Number(state.lcpMs ?? 0),
lcpElement: state.lcpElement ?? null,
cls: Number(state.cls ?? 0),
firstPaintMs: Number(state.paints?.paint ?? 0),
firstContentfulPaintMs: Number(state.paints?.['first-contentful-paint'] ?? 0),
longTaskCount: longTasks.length,
longTaskTotalMs: longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0),
longTaskMaxMs: longTasks.length ? Math.max(...longTasks) : 0,
settleTimeMs: performance.now() - Number(state.startedAt ?? 0),
codeBlockCount: document.querySelectorAll('.code-block-container').length,
fallbackCount: document.querySelectorAll('.code-fallback-plain, .code-pre-fallback').length,
visibleCodeBlockCount: visibleCodeBlocks.length,
visibleFallbackCount: visibleCodeBlocks.filter(element => element.querySelector('.code-fallback-plain, .code-pre-fallback')).length,
mermaidCount: mermaids.length,
renderedMermaidCount: mermaids.filter(element => element.querySelector('svg')).length,
visibleMermaidCount: visibleMermaids.length,
visibleRenderedMermaidCount: visibleMermaids.filter(element => element.querySelector('svg')).length,
infographicCount: infographics.length,
renderedInfographicCount: infographics.filter(element => element.querySelector('svg')).length,
visibleInfographicCount: visibleInfographics.length,
visibleRenderedInfographicCount: visibleInfographics.filter(element => element.querySelector('svg')).length,
d2Count: d2Blocks.length,
renderedD2Count: d2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
visibleD2Count: visibleD2Blocks.length,
visibleRenderedD2Count: visibleD2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
topLayoutShifts: Array.isArray(state.layoutShifts)
? [...state.layoutShifts]
.sort((a, b) => Number(b?.value || 0) - Number(a?.value || 0))
.slice(0, 5)
: [],
}
})
await scrollThroughRoot(page, rootSelector)
await waitForAllD2Ready(page)
await page.waitForTimeout(250)
const fullScroll = await page.evaluate(() => {
const state = window.__mainPlaygroundPerf ?? {}
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
const mermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]'))
const infographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]'))
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
return {
settleTimeMs: performance.now() - Number(state.startedAt ?? 0),
codeBlockCount: document.querySelectorAll('.code-block-container').length,
fallbackCount: document.querySelectorAll('.code-fallback-plain, .code-pre-fallback').length,
mermaidCount: mermaids.length,
renderedMermaidCount: mermaids.filter(element => element.querySelector('svg')).length,
infographicCount: infographics.length,
renderedInfographicCount: infographics.filter(element => element.querySelector('svg')).length,
d2Count: d2Blocks.length,
renderedD2Count: d2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
longTaskTotalMs: longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0),
}
})
const replayButton = page.locator('button[title="Pause streaming"], button[title="Resume streaming"]')
await replayButton.waitFor({ state: 'visible', timeout: 5000 })
if ((await replayButton.getAttribute('title')) === 'Pause streaming') {
await replayButton.click()
await page.waitForFunction(() => {
const button = document.querySelector('button[title="Resume streaming"]')
return Boolean(button)
}, null, { timeout: 5000 })
}
await page.evaluate(() => {
const state = window.__mainPlaygroundPerf
if (state) {
state.replayStartedAt = performance.now()
state.replayLongTaskCountBaseline = state.longTasks.length
state.replayLongTaskTotalBaseline = state.longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0)
}
})
await page.locator('button[title="Resume streaming"]').click()
await waitForVisibleBlocksReady(page, rootSelector)
await page.waitForTimeout(250)
const replay = await page.evaluate(() => {
const state = window.__mainPlaygroundPerf ?? {}
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
const totalLongTaskMs = longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0)
return {
settleTimeMs: performance.now() - Number(state.replayStartedAt ?? performance.now()),
longTaskCount: longTasks.length - Number(state.replayLongTaskCountBaseline ?? 0),
longTaskTotalMs: totalLongTaskMs - Number(state.replayLongTaskTotalBaseline ?? 0),
}
})
return { initial, fullScroll, replay }
}
function assertScenario(result) {
if (!(result.initial.visibleFallbackCount === 0))
throw new Error(`Visible code block fallbacks should be gone after initial settle. Got ${result.initial.visibleFallbackCount}.`)
if (result.initial.visibleRenderedMermaidCount !== result.initial.visibleMermaidCount)
throw new Error('Visible mermaid blocks should finish in preview mode after initial settle.')
if (result.initial.visibleRenderedInfographicCount !== result.initial.visibleInfographicCount)
throw new Error('Visible infographic blocks should finish after initial settle.')
if (!(result.initial.lcpMs > 0))
throw new Error('Expected a positive LCP measurement.')
if (!(result.initial.lcpMs <= 4000))
throw new Error(`LCP should stay within 4000ms. Got ${result.initial.lcpMs}.`)
if (!(result.initial.cls <= 0.05))
throw new Error(`CLS should stay within 0.05. Got ${result.initial.cls}.`)
if (!(result.initial.longTaskMaxMs <= 500))
throw new Error(`Max long task should stay within 500ms. Got ${result.initial.longTaskMaxMs}.`)
if (!(result.initial.longTaskTotalMs <= 1400))
throw new Error(`Total long task time should stay within 1400ms. Got ${result.initial.longTaskTotalMs}.`)
if (!(result.initial.settleTimeMs <= 7000))
throw new Error(`Initial settle should stay within 7000ms. Got ${result.initial.settleTimeMs}.`)
if (result.fullScroll.fallbackCount !== 0)
throw new Error('Code block fallbacks should be gone after full scroll settle.')
if (result.fullScroll.renderedMermaidCount !== result.fullScroll.mermaidCount)
throw new Error('All mermaid blocks should finish after full scroll settle.')
if (result.fullScroll.renderedInfographicCount !== result.fullScroll.infographicCount)
throw new Error('All infographic blocks should finish after full scroll settle.')
if (result.fullScroll.d2Count > 0 && result.fullScroll.renderedD2Count !== result.fullScroll.d2Count)
throw new Error('All d2 blocks should finish after full scroll settle.')
if (!(result.replay.settleTimeMs <= 5000))
throw new Error(`Replay settle should stay within 5000ms. Got ${result.replay.settleTimeMs}.`)
}
async function run() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
const server = startDevServer(port)
let result = null
const cleanup = () => killProcessTree(server.child)
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('exit', cleanup)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const warmupContext = await browser.newContext({
viewport: { width: 1600, height: 1200 },
baseURL: `http://${host}:${port}`,
})
const warmupPage = await warmupContext.newPage()
await warmupPage.goto('/', { waitUntil: 'load' })
await warmupPage.locator('.chatbot-container').waitFor({ state: 'visible', timeout: 15000 })
await warmupContext.close()
const context = await browser.newContext({
viewport: { width: 1600, height: 1200 },
baseURL: `http://${host}:${port}`,
})
await context.addInitScript(() => {
const state = {
startedAt: performance.now(),
cls: 0,
lcpMs: 0,
lcpElement: null,
longTasks: [],
paints: {},
layoutShifts: [],
pauseEnabledSeen: false,
}
window.__mainPlaygroundPerf = state
const describeElement = (element) => {
if (!element)
return null
const tag = element.tagName ? element.tagName.toLowerCase() : 'unknown'
const id = element.id ? `#${element.id}` : ''
const className = typeof element.className === 'string'
? element.className.trim().split(/\s+/).filter(Boolean).join('.')
: ''
return `${tag}${id}${className ? `.${className}` : ''}`
}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.startTime > state.lcpMs) {
state.lcpMs = entry.startTime
state.lcpElement = describeElement(entry.element)
}
}
}).observe({ type: 'largest-contentful-paint', buffered: true })
}
catch {}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
state.cls += entry.value
state.layoutShifts.push({
value: entry.value,
sources: Array.isArray(entry.sources)
? entry.sources
.map(source => describeElement(source?.node))
.filter(Boolean)
.slice(0, 4)
: [],
})
}
}
}).observe({ type: 'layout-shift', buffered: true })
}
catch {}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
state.longTasks.push(entry.duration)
}).observe({ type: 'longtask', buffered: true })
}
catch {}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
state.paints[entry.name] = entry.startTime
}).observe({ type: 'paint', buffered: true })
}
catch {}
})
const page = await context.newPage()
result = await collectMetrics(page)
assertScenario(result)
console.log(JSON.stringify(result, null, 2))
await context.close()
await browser.close()
}
catch (error) {
console.error('[e2e-main-playground-performance] failed')
console.error(error)
if (result)
console.error(JSON.stringify(result, null, 2))
console.error(server.getLogs())
process.exitCode = 1
}
finally {
cleanup()
}
}
run()