e0a5f57793
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.
447 lines
17 KiB
JavaScript
447 lines
17 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 = 4250, end = 4290) {
|
|
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 => Boolean(element.querySelector('svg')))
|
|
&& visibleInfographics.every(element => Boolean(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 => Boolean(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 runScenario(browser, port, mode) {
|
|
const rootSelector = '.preview-surface'
|
|
const sample = process.env.PLAYGROUND_SAMPLE || 'baseline'
|
|
const warmupContext = await browser.newContext({
|
|
viewport: { width: 1600, height: 1200 },
|
|
storageState: {
|
|
cookies: [],
|
|
origins: [
|
|
{
|
|
origin: `http://${host}:${port}`,
|
|
localStorage: [
|
|
{ name: 'vmr-test-sample', value: sample },
|
|
{ name: 'vmr-test-render-mode', value: mode },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
const warmupPage = await warmupContext.newPage()
|
|
await warmupPage.goto(`http://${host}:${port}/test`, { waitUntil: 'load' })
|
|
await warmupPage.locator('.workspace-card--preview').waitFor({ state: 'visible', timeout: 15000 })
|
|
await warmupContext.close()
|
|
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1600, height: 1200 },
|
|
storageState: {
|
|
cookies: [],
|
|
origins: [
|
|
{
|
|
origin: `http://${host}:${port}`,
|
|
localStorage: [
|
|
{ name: 'vmr-test-sample', value: sample },
|
|
{ name: 'vmr-test-render-mode', value: mode },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
|
|
await context.addInitScript(() => {
|
|
const state = {
|
|
startedAt: performance.now(),
|
|
cls: 0,
|
|
lcpMs: 0,
|
|
lcpElement: null,
|
|
longTasks: [],
|
|
paints: {},
|
|
layoutShifts: [],
|
|
}
|
|
|
|
window.__playgroundPerfState = 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()
|
|
await page.goto(`http://${host}:${port}/test`, { waitUntil: 'load' })
|
|
|
|
await page.locator('.workspace-card--preview').waitFor({ state: 'visible', timeout: 15000 })
|
|
await waitForVisibleBlocksReady(page, rootSelector)
|
|
await page.waitForTimeout(200)
|
|
|
|
const result = await page.evaluate(() => {
|
|
const state = window.__playgroundPerfState ?? {}
|
|
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('.preview-surface')
|
|
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 allCodeBlocks = Array.from(document.querySelectorAll('.code-block-container'))
|
|
const visibleCodeBlocks = allCodeBlocks.filter(isVisible)
|
|
const diffCodeBlocks = allCodeBlocks.filter(element =>
|
|
element.classList.contains('is-diff') || Boolean(element.querySelector('.monaco-diff-editor')),
|
|
)
|
|
const visibleDiffCodeBlocks = diffCodeBlocks.filter(isVisible)
|
|
const renderedMermaidCount = mermaids.filter(element => element.querySelector('svg')).length
|
|
return {
|
|
sample: localStorage.getItem('vmr-test-sample') ?? 'unknown',
|
|
mode: localStorage.getItem('vmr-test-render-mode') ?? 'unknown',
|
|
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: allCodeBlocks.length,
|
|
diffCodeBlockCount: diffCodeBlocks.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,
|
|
visibleDiffCodeBlockCount: visibleDiffCodeBlocks.length,
|
|
visibleDiffFallbackCount: visibleDiffCodeBlocks.filter(element => element.querySelector('.code-fallback-plain, .code-pre-fallback')).length,
|
|
mermaidCount: mermaids.length,
|
|
renderedMermaidCount,
|
|
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,
|
|
sandboxFrameMounted: Boolean(document.querySelector('.sandbox-frame')),
|
|
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(200)
|
|
|
|
result.fullScroll = await page.evaluate(() => {
|
|
const state = window.__playgroundPerfState ?? {}
|
|
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),
|
|
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),
|
|
}
|
|
})
|
|
|
|
await context.close()
|
|
return result
|
|
}
|
|
|
|
function assertScenario(result) {
|
|
if (!(result.codeBlockCount > 0))
|
|
throw new Error(`[${result.mode}] Expected at least one rendered code block.`)
|
|
if (result.visibleFallbackCount !== 0)
|
|
throw new Error(`[${result.mode}] Visible code fallback should be gone after initial settle.`)
|
|
if (result.sample === 'diff' && result.mode === 'monaco' && !(result.diffCodeBlockCount > 0))
|
|
throw new Error('[monaco] Diff sample should render at least one Monaco diff block.')
|
|
if (result.sample === 'diff' && result.mode === 'monaco' && result.visibleDiffFallbackCount !== 0)
|
|
throw new Error('[monaco] Visible diff fallback should be gone after initial settle.')
|
|
if (result.visibleRenderedMermaidCount !== result.visibleMermaidCount)
|
|
throw new Error(`[${result.mode}] Visible mermaid blocks should finish in preview mode after initial settle.`)
|
|
if (result.visibleRenderedInfographicCount !== result.visibleInfographicCount)
|
|
throw new Error(`[${result.mode}] Visible infographic blocks should finish after initial settle.`)
|
|
if (!(result.lcpMs > 0 && result.lcpMs <= 5000))
|
|
throw new Error(`[${result.mode}] LCP should stay within 5000ms. Got ${result.lcpMs}.`)
|
|
if (!(result.cls <= 0.05))
|
|
throw new Error(`[${result.mode}] CLS should stay within 0.05. Got ${result.cls}.`)
|
|
if (!(result.settleTimeMs <= 6000))
|
|
throw new Error(`[${result.mode}] Settle time should stay within 6000ms. Got ${result.settleTimeMs}.`)
|
|
if (!(result.longTaskMaxMs <= 700))
|
|
throw new Error(`[${result.mode}] Max long task should stay within 700ms. Got ${result.longTaskMaxMs}.`)
|
|
if (!(result.longTaskTotalMs <= 1800))
|
|
throw new Error(`[${result.mode}] Total long task time should stay within 1800ms. Got ${result.longTaskTotalMs}.`)
|
|
if (result.fullScroll.fallbackCount !== 0)
|
|
throw new Error(`[${result.mode}] Code fallback should be gone after full scroll settle.`)
|
|
if (result.fullScroll.renderedMermaidCount !== result.fullScroll.mermaidCount)
|
|
throw new Error(`[${result.mode}] Mermaid blocks should all finish after full scroll settle.`)
|
|
if (result.fullScroll.renderedInfographicCount !== result.fullScroll.infographicCount)
|
|
throw new Error(`[${result.mode}] Infographic blocks should all finish after full scroll settle.`)
|
|
if (result.fullScroll.renderedD2Count !== result.fullScroll.d2Count)
|
|
throw new Error(`[${result.mode}] D2 blocks should all finish after full scroll settle.`)
|
|
}
|
|
|
|
async function run() {
|
|
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
|
|
const server = startDevServer(port)
|
|
let results = 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 markdownResult = await runScenario(browser, port, 'markdown')
|
|
const monacoResult = await runScenario(browser, port, 'monaco')
|
|
results = {
|
|
markdown: markdownResult,
|
|
monaco: monacoResult,
|
|
}
|
|
|
|
assertScenario(markdownResult)
|
|
assertScenario(monacoResult)
|
|
|
|
console.log(JSON.stringify(results, null, 2))
|
|
|
|
await browser.close()
|
|
}
|
|
catch (error) {
|
|
console.error('[e2e-playground-performance] failed')
|
|
console.error(error)
|
|
if (results)
|
|
console.error(JSON.stringify(results, null, 2))
|
|
console.error(server.getLogs())
|
|
process.exitCode = 1
|
|
}
|
|
finally {
|
|
cleanup()
|
|
}
|
|
}
|
|
|
|
run()
|