Files
BoosAPI/desktop/libs/markstream-vue/scripts/e2e-math-streaming-stability.mjs
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

315 lines
9.4 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-vue2-cli')
const host = '127.0.0.1'
function isPortOpen(port, listenHost = host) {
return new Promise((resolve) => {
const socket = net.createConnection({ host: listenHost, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 3338, end = 3368) {
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, timeout = 60000) {
const start = Date.now()
while (Date.now() - start < timeout) {
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 logBuffer = []
const child = spawn(
'pnpm',
['exec', 'vue-cli-service', 'serve', '--port', String(port), '--host', host],
{
cwd: playgroundDir,
env: {
...process.env,
BROWSER: 'none',
CI: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
},
)
const appendLogs = (chunk) => {
const text = String(chunk)
logBuffer.push(text)
if (logBuffer.length > 120)
logBuffer.splice(0, logBuffer.length - 120)
}
child.stdout.on('data', appendLogs)
child.stderr.on('data', appendLogs)
return {
child,
getLogs() {
return logBuffer.join('')
},
}
}
async function readProgress(page) {
return page.evaluate(() => {
const metaText = document.querySelector('.meta')?.textContent || ''
const match = metaText.match(/\((\d+)%\)/)
return match ? Number(match[1]) : null
})
}
async function scrollFirstIntoView(page, selector, timeout = 20000) {
await page.waitForFunction((query) => {
const el = document.querySelector(query)
if (!el)
return false
el.scrollIntoView({ block: 'center', inline: 'nearest' })
return true
}, selector, { timeout })
}
async function waitForRenderedMath(page) {
await scrollFirstIntoView(page, '.math-block')
await page.waitForFunction(() => Boolean(document.querySelector('.math-block .katex')), { timeout: 20000 })
await scrollFirstIntoView(page, '.math-inline-wrapper')
await page.waitForFunction(() => Boolean(document.querySelector('.math-inline-wrapper .katex')), { timeout: 20000 })
}
async function collectRegression(page) {
await page.locator('#delay').fill('4')
await page.locator('#chunk').fill('16')
await page.getByRole('button', { name: 'Reset' }).click()
await waitForRenderedMath(page)
const progressAtMonitorStart = await readProgress(page)
const seenRenderedBlocks = new Set()
const seenRenderedInline = new Set()
const regressedBlocks = new Set()
const regressedInline = new Set()
let maxVisibleBlockLoaders = 0
let maxVisibleInlineLoaders = 0
const uniqueProgress = new Set(progressAtMonitorStart == null ? [] : [progressAtMonitorStart])
const start = Date.now()
while (Date.now() - start < 25000) {
const snapshot = await page.evaluate(() => {
const isVisible = (el) => {
if (!el)
return false
const style = window.getComputedStyle(el)
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity || '1') > 0.05
}
const blocks = Array.from(document.querySelectorAll('.math-block')).map((block, index) => ({
index,
hasKatex: Boolean(block.querySelector('.katex')),
loadingVisible: isVisible(block.querySelector('.math-loading-overlay')),
fallbackVisible: isVisible(block.querySelector('.math-block__fallback')),
}))
const inline = Array.from(document.querySelectorAll('.math-inline-wrapper')).map((item, index) => ({
index,
hasKatex: Boolean(item.querySelector('.katex')),
loadingVisible: isVisible(item.querySelector('.math-inline__loading')),
fallbackVisible: isVisible(item.querySelector('.math-inline--fallback')),
}))
const metaText = document.querySelector('.meta')?.textContent || ''
const match = metaText.match(/\((\d+)%\)/)
return {
blocks,
inline,
progress: match ? Number(match[1]) : null,
}
})
if (snapshot.progress != null)
uniqueProgress.add(snapshot.progress)
let visibleBlockLoaders = 0
for (const block of snapshot.blocks) {
if (block.hasKatex)
seenRenderedBlocks.add(block.index)
if (block.loadingVisible)
visibleBlockLoaders += 1
if (seenRenderedBlocks.has(block.index) && (block.loadingVisible || block.fallbackVisible))
regressedBlocks.add(block.index)
}
maxVisibleBlockLoaders = Math.max(maxVisibleBlockLoaders, visibleBlockLoaders)
let visibleInlineLoaders = 0
for (const item of snapshot.inline) {
if (item.hasKatex)
seenRenderedInline.add(item.index)
if (item.loadingVisible)
visibleInlineLoaders += 1
if (seenRenderedInline.has(item.index) && (item.loadingVisible || item.fallbackVisible))
regressedInline.add(item.index)
}
maxVisibleInlineLoaders = Math.max(maxVisibleInlineLoaders, visibleInlineLoaders)
if (snapshot.progress === 100)
break
await page.waitForTimeout(120)
}
const finalProgress = Math.max(...Array.from(uniqueProgress.values()), 0)
return {
progressAtMonitorStart,
finalProgress,
seenRenderedBlocks: Array.from(seenRenderedBlocks),
seenRenderedInline: Array.from(seenRenderedInline),
regressedBlocks: Array.from(regressedBlocks),
regressedInline: Array.from(regressedInline),
uniqueProgress: Array.from(uniqueProgress).sort((a, b) => a - b),
maxVisibleBlockLoaders,
maxVisibleInlineLoaders,
}
}
async function main() {
const port = await findFreePort()
const server = startDevServer(port)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage({ viewport: { width: 1440, height: 1600 } })
const consoleErrors = []
const pageErrors = []
page.on('console', (msg) => {
if (msg.type() === 'error')
consoleErrors.push(msg.text())
})
page.on('pageerror', error => pageErrors.push(String(error)))
const url = `http://${host}:${port}/?probe=1`
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('#delay', { timeout: 30000 })
await page.waitForSelector('.meta', { timeout: 30000 })
const regression = await collectRegression(page)
const screenshot = '/tmp/vue2-cli-math-streaming-stability.png'
await page.screenshot({ path: screenshot, fullPage: true })
await browser.close()
const result = {
url,
...regression,
consoleErrorCount: consoleErrors.length,
pageErrorCount: pageErrors.length,
screenshot,
}
console.log(JSON.stringify(result, null, 2))
if (result.regressedBlocks.length > 0)
throw new Error(`Math block regressed back to loading/fallback: ${result.regressedBlocks.join(', ')}`)
if (result.regressedInline.length > 0)
throw new Error(`Math inline regressed back to loading/fallback: ${result.regressedInline.join(', ')}`)
if (result.seenRenderedBlocks.length === 0)
throw new Error('No rendered math block was observed during the run')
if (result.seenRenderedInline.length === 0)
throw new Error('No rendered math inline node was observed during the run')
if (result.finalProgress !== 100)
throw new Error(`Streaming did not finish while monitoring math nodes (ended at ${result.finalProgress}%)`)
if (result.consoleErrorCount > 0 || result.pageErrorCount > 0)
throw new Error(`Unexpected browser errors: console=${result.consoleErrorCount}, page=${result.pageErrorCount}`)
}
catch (error) {
const recentLogs = server.getLogs().trim()
if (recentLogs) {
console.error('--- vue-cli recent logs ---')
console.error(recentLogs)
console.error('--- end vue-cli recent logs ---')
}
throw error
}
finally {
killProcessTree(server.child)
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})