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.
This commit is contained in:
@@ -0,0 +1,820 @@
|
||||
#!/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-angular')
|
||||
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 = 4175, end = 4205) {
|
||||
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 startedAt = Date.now()
|
||||
while (Date.now() - startedAt < 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',
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
].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', 'vite', 'dev', '--host', host, '--port', String(port)],
|
||||
{
|
||||
cwd: playgroundDir,
|
||||
env: {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
|
||||
const appendLogs = (chunk) => {
|
||||
const text = String(chunk)
|
||||
logBuffer.push(text)
|
||||
if (logBuffer.length > 160)
|
||||
logBuffer.splice(0, logBuffer.length - 160)
|
||||
}
|
||||
|
||||
child.stdout.on('data', appendLogs)
|
||||
child.stderr.on('data', appendLogs)
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logBuffer.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeErrors(errors) {
|
||||
return errors
|
||||
.map(error => error.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function isExecutionContextDestroyed(error) {
|
||||
const message = String(error?.message ?? error ?? '')
|
||||
return message.includes('Execution context was destroyed')
|
||||
|| message.includes('most likely because of a navigation')
|
||||
}
|
||||
|
||||
async function installHoverProbe(page) {
|
||||
await page.addInitScript(() => {
|
||||
const globalKey = '__MARKSTREAM_HOVER_PROBE__'
|
||||
if (globalThis[globalKey])
|
||||
return
|
||||
|
||||
const state = {
|
||||
mouseoverCount: 0,
|
||||
mouseoutCount: 0,
|
||||
lastTitle: '',
|
||||
frameCount: 0,
|
||||
maxFrameGap: 0,
|
||||
maxTimerDrift: 0,
|
||||
}
|
||||
|
||||
let lastFrameAt = performance.now()
|
||||
const tick = (now) => {
|
||||
state.frameCount += 1
|
||||
state.maxFrameGap = Math.max(state.maxFrameGap, now - lastFrameAt)
|
||||
lastFrameAt = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
let expectedTimerAt = performance.now() + 100
|
||||
setInterval(() => {
|
||||
const now = performance.now()
|
||||
state.maxTimerDrift = Math.max(state.maxTimerDrift, Math.max(0, now - expectedTimerAt))
|
||||
expectedTimerAt = now + 100
|
||||
}, 100)
|
||||
|
||||
const resolveTarget = (value) => {
|
||||
if (!(value instanceof Element))
|
||||
return null
|
||||
return value.closest('[title], a[href]')
|
||||
}
|
||||
|
||||
document.addEventListener('mouseover', (event) => {
|
||||
const target = resolveTarget(event.target)
|
||||
if (!target)
|
||||
return
|
||||
state.mouseoverCount += 1
|
||||
state.lastTitle = target.getAttribute('title') || target.getAttribute('href') || target.textContent || ''
|
||||
}, true)
|
||||
|
||||
document.addEventListener('mouseout', (event) => {
|
||||
const target = resolveTarget(event.target)
|
||||
if (!target)
|
||||
return
|
||||
state.mouseoutCount += 1
|
||||
}, true)
|
||||
|
||||
globalThis[globalKey] = state
|
||||
})
|
||||
}
|
||||
|
||||
async function readHoverProbe(page) {
|
||||
return page.evaluate(() => {
|
||||
const state = globalThis.__MARKSTREAM_HOVER_PROBE__ || {}
|
||||
return {
|
||||
mouseoverCount: Number(state.mouseoverCount || 0),
|
||||
mouseoutCount: Number(state.mouseoutCount || 0),
|
||||
lastTitle: String(state.lastTitle || ''),
|
||||
frameCount: Number(state.frameCount || 0),
|
||||
maxFrameGap: Number(state.maxFrameGap || 0),
|
||||
maxTimerDrift: Number(state.maxTimerDrift || 0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function readHomeProgress(page) {
|
||||
return page.evaluate(() => {
|
||||
const text = document.querySelector('.meta')?.textContent?.trim() || ''
|
||||
const match = text.match(/^(\d+)\s*\/\s*(\d+)\s*\((\d+)%\)$/)
|
||||
const current = match ? Number.parseInt(match[1], 10) : 0
|
||||
const total = match ? Number.parseInt(match[2], 10) : 0
|
||||
const percent = match ? Number.parseInt(match[3], 10) : 0
|
||||
return {
|
||||
text,
|
||||
current,
|
||||
total,
|
||||
percent,
|
||||
done: total > 0 && current >= total && percent === 100,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function readHomePlaceholderCount(page) {
|
||||
return page.evaluate(() => {
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0
|
||||
return Array.from(document.querySelectorAll('.panel.preview .markstream-angular .node-placeholder'))
|
||||
.filter((node) => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
return rect.width > 0
|
||||
&& rect.height > 0
|
||||
&& rect.bottom > 0
|
||||
&& rect.right > 0
|
||||
&& rect.top < viewportHeight
|
||||
&& rect.left < viewportWidth
|
||||
})
|
||||
.length
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForProbeIncrement(page, key, previousValue, timeout = 1500) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
const current = await readHoverProbe(page)
|
||||
if (current[key] > previousValue)
|
||||
return Date.now() - startedAt
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
throw new Error(`Timed out waiting for hover probe increment: ${key}`)
|
||||
}
|
||||
|
||||
async function waitForHomeHoverTarget(page, timeout = 90000) {
|
||||
const selector = '.panel.preview .markstream-angular [title], .panel.preview .markstream-angular a[href]'
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
const locator = page.locator(selector).first()
|
||||
if (await locator.count()) {
|
||||
try {
|
||||
if (await locator.isVisible())
|
||||
return selector
|
||||
}
|
||||
catch {
|
||||
// The preview re-renders while streaming; retry with a fresh locator.
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for a hoverable home preview target.')
|
||||
}
|
||||
|
||||
async function exerciseHomeHoverDuringStreaming(page, selector) {
|
||||
const samples = []
|
||||
const startedAt = Date.now()
|
||||
let maxPlaceholderCount = 0
|
||||
|
||||
while (Date.now() - startedAt < 180000) {
|
||||
const progress = await readHomeProgress(page)
|
||||
if (progress.done)
|
||||
break
|
||||
|
||||
const before = await readHoverProbe(page)
|
||||
const target = page.locator(selector).first()
|
||||
await target.hover({ timeout: 3000 })
|
||||
const overLatencyMs = await waitForProbeIncrement(page, 'mouseoverCount', before.mouseoverCount, 1500)
|
||||
|
||||
await page.mouse.move(8, 8)
|
||||
const outLatencyMs = await waitForProbeIncrement(page, 'mouseoutCount', before.mouseoutCount, 1500)
|
||||
const placeholderCount = await readHomePlaceholderCount(page)
|
||||
maxPlaceholderCount = Math.max(maxPlaceholderCount, placeholderCount)
|
||||
|
||||
samples.push({
|
||||
current: progress.current,
|
||||
percent: progress.percent,
|
||||
overLatencyMs,
|
||||
outLatencyMs,
|
||||
placeholderCount,
|
||||
})
|
||||
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
|
||||
const finalProbe = await readHoverProbe(page)
|
||||
return {
|
||||
interactionCount: samples.length,
|
||||
maxOverLatencyMs: Math.max(0, ...samples.map(sample => sample.overLatencyMs)),
|
||||
maxOutLatencyMs: Math.max(0, ...samples.map(sample => sample.outLatencyMs)),
|
||||
maxPlaceholderCount,
|
||||
finalProbe,
|
||||
samples,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHomeCompletion(page, timeout = 180000) {
|
||||
await page.waitForFunction(() => {
|
||||
const text = document.querySelector('.meta')?.textContent?.trim() || ''
|
||||
const match = text.match(/^(\d+)\s*\/\s*(\d+)\s*\((\d+)%\)$/)
|
||||
if (!match)
|
||||
return false
|
||||
const current = Number.parseInt(match[1], 10)
|
||||
const total = Number.parseInt(match[2], 10)
|
||||
const percent = Number.parseInt(match[3], 10)
|
||||
return total > 0 && current >= total && percent === 100
|
||||
}, { timeout })
|
||||
}
|
||||
|
||||
async function collectHomeCompletionMetrics(page) {
|
||||
return page.evaluate(() => {
|
||||
const preview = document.querySelector('.panel.preview .markstream-angular')
|
||||
const text = preview?.textContent || ''
|
||||
const metaText = document.querySelector('.meta')?.textContent?.trim() || ''
|
||||
return {
|
||||
metaText,
|
||||
textLength: text.length,
|
||||
containsTaylor: text.includes('泰勒公式'),
|
||||
containsOrthogonalComplement: text.includes('正交补空间'),
|
||||
containsHelloWorldTail: text.includes('hello world'),
|
||||
linkCount: document.querySelectorAll('.panel.preview .markstream-angular a').length,
|
||||
katexCount: document.querySelectorAll('.panel.preview .markstream-angular .katex').length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForHomeStreamingCodeHighlight(page, timeout = 180000) {
|
||||
const startedAt = Date.now()
|
||||
let lastSnapshot = null
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
lastSnapshot = await page.evaluate(() => {
|
||||
const text = document.querySelector('.meta')?.textContent?.trim() || ''
|
||||
const match = text.match(/^(\d+)\s*\/\s*(\d+)\s*\((\d+)%\)$/)
|
||||
const current = match ? Number.parseInt(match[1], 10) : 0
|
||||
const total = match ? Number.parseInt(match[2], 10) : 0
|
||||
const percent = match ? Number.parseInt(match[3], 10) : 0
|
||||
return {
|
||||
metaText: text,
|
||||
current,
|
||||
total,
|
||||
percent,
|
||||
done: total > 0 && current >= total && percent === 100,
|
||||
monacoCount: document.querySelectorAll('.panel.preview .markstream-angular [data-markstream-monaco="1"]').length,
|
||||
fallbackPreCount: document.querySelectorAll('.panel.preview .markstream-angular .code-fallback-plain').length,
|
||||
}
|
||||
})
|
||||
|
||||
if (lastSnapshot.monacoCount > 0)
|
||||
return lastSnapshot
|
||||
|
||||
if (lastSnapshot.done)
|
||||
return lastSnapshot
|
||||
|
||||
await page.waitForTimeout(400)
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for a highlighted home code block: ${JSON.stringify(lastSnapshot)}`)
|
||||
}
|
||||
|
||||
async function collectHomeThinkingMetrics(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const text = document.querySelector('.meta')?.textContent?.trim() || ''
|
||||
const match = text.match(/(\d+)\s*\/\s*(\d+)/)
|
||||
return !!match && Number.parseInt(match[1], 10) >= 1000
|
||||
}, { timeout: 30000 })
|
||||
|
||||
return page.evaluate(() => {
|
||||
const content = document.querySelector('.thinking-node__content')
|
||||
const text = content?.textContent || ''
|
||||
return {
|
||||
textLength: text.trim().length,
|
||||
containsComprehensiveAnswer: text.includes('全面的回答'),
|
||||
paragraphCount: content?.querySelectorAll('p').length || 0,
|
||||
listCount: content?.querySelectorAll('li').length || 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function collectHomeLinkTooltip(page) {
|
||||
const link = page
|
||||
.locator('.panel.preview .markstream-angular a.link-node[href="https://github.com/Simon-He95/markstream-vue"]')
|
||||
.filter({ hasText: 'Star on GitHub' })
|
||||
.first()
|
||||
|
||||
await link.waitFor({ state: 'visible', timeout: 30000 })
|
||||
await link.hover()
|
||||
await page.waitForFunction(() => {
|
||||
const tooltip = document.querySelector('.ms-tooltip')
|
||||
if (!tooltip)
|
||||
return false
|
||||
const style = window.getComputedStyle(tooltip)
|
||||
const text = tooltip.textContent?.trim() || ''
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity || '1') > 0.05
|
||||
&& text.includes('https://github.com/Simon-He95/markstream-vue')
|
||||
}, { timeout: 10000 })
|
||||
|
||||
return page.evaluate(() => {
|
||||
const tooltip = document.querySelector('.ms-tooltip')
|
||||
const style = tooltip ? window.getComputedStyle(tooltip) : null
|
||||
return {
|
||||
text: tooltip?.textContent?.trim() || '',
|
||||
visible: Boolean(
|
||||
tooltip
|
||||
&& style
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity || '1') > 0.05,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForStableEnhancements(page) {
|
||||
await page.waitForSelector('.preview-surface .katex', { timeout: 30000 })
|
||||
await page.waitForSelector('.preview-surface .markstream-angular-mermaid svg', { timeout: 30000 })
|
||||
await page.waitForSelector('.preview-surface [data-markstream-infographic="1"] svg', { timeout: 30000 })
|
||||
await page.waitForSelector('.preview-surface [data-markstream-d2="1"] svg', { timeout: 30000 })
|
||||
}
|
||||
|
||||
async function collectBaselineMetrics(page) {
|
||||
return page.evaluate(() => ({
|
||||
katexCount: document.querySelectorAll('.preview-surface .katex').length,
|
||||
mermaidSvgCount: document.querySelectorAll('.preview-surface .markstream-angular-mermaid svg').length,
|
||||
infographicSvgCount: document.querySelectorAll('.preview-surface [data-markstream-infographic="1"] svg').length,
|
||||
d2SvgCount: document.querySelectorAll('.preview-surface [data-markstream-d2="1"] svg').length,
|
||||
}))
|
||||
}
|
||||
|
||||
async function verifyBaselineInteractions(page) {
|
||||
const mermaidBlock = page.locator('.preview-surface .mermaid-block').first()
|
||||
await mermaidBlock.getByRole('button', { name: 'Source' }).click()
|
||||
await page.waitForSelector('.preview-surface .mermaid-block .mermaid-source', { timeout: 10000 })
|
||||
await mermaidBlock.getByRole('button', { name: 'Preview' }).click()
|
||||
await page.waitForSelector('.preview-surface .mermaid-block .markstream-angular-mermaid svg', { timeout: 10000 })
|
||||
await mermaidBlock.getByRole('button', { name: 'Fullscreen' }).click()
|
||||
await page.waitForSelector('.mermaid-modal-overlay', { timeout: 10000 })
|
||||
await page.getByRole('button', { name: 'Close' }).click()
|
||||
await page.waitForSelector('.mermaid-modal-overlay', { state: 'hidden', timeout: 10000 })
|
||||
|
||||
const d2Block = page.locator('.preview-surface [data-markstream-d2="1"]').first()
|
||||
await d2Block.getByRole('button', { name: 'Source' }).click()
|
||||
await page.waitForFunction(() => {
|
||||
const block = document.querySelector('.preview-surface [data-markstream-d2="1"]')
|
||||
return block?.getAttribute('data-mode') === 'source'
|
||||
}, { timeout: 10000 })
|
||||
await d2Block.getByRole('button', { name: 'Preview' }).click()
|
||||
await page.waitForFunction(() => {
|
||||
const block = document.querySelector('.preview-surface [data-markstream-d2="1"]')
|
||||
return block?.getAttribute('data-mode') === 'preview'
|
||||
}, { timeout: 10000 })
|
||||
|
||||
const infographicBlock = page.locator('.preview-surface [data-markstream-infographic="1"]').first()
|
||||
await infographicBlock.getByRole('button', { name: 'Source' }).click()
|
||||
await page.waitForFunction(() => {
|
||||
const block = document.querySelector('.preview-surface [data-markstream-infographic="1"]')
|
||||
return block?.getAttribute('data-mode') === 'source'
|
||||
}, { timeout: 10000 })
|
||||
await infographicBlock.getByRole('button', { name: 'Preview' }).click()
|
||||
await page.waitForFunction(() => {
|
||||
const block = document.querySelector('.preview-surface [data-markstream-infographic="1"]')
|
||||
return block?.getAttribute('data-mode') === 'preview'
|
||||
}, { timeout: 10000 })
|
||||
}
|
||||
|
||||
async function collectDiffMetrics(page) {
|
||||
return page.evaluate(() => ({
|
||||
wrapperCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"]').length,
|
||||
monacoCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"] .monaco-editor').length,
|
||||
monacoDiffCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"] .monaco-diff-editor').length,
|
||||
badgeTexts: Array.from(document.querySelectorAll('.preview-surface .markstream-angular-enhanced-block__badge'))
|
||||
.map(node => node.textContent?.trim())
|
||||
.filter(Boolean),
|
||||
}))
|
||||
}
|
||||
|
||||
async function verifyDiffInteractions(page) {
|
||||
const diffBlock = page.locator('.preview-surface [data-markstream-monaco-diff="1"]').first()
|
||||
await diffBlock.locator('button[title="Collapse"]').first().click()
|
||||
await page.waitForFunction(() => {
|
||||
const body = document.querySelector('.preview-surface [data-markstream-monaco-diff="1"] .code-block-body')
|
||||
return body?.classList.contains('code-block-body--collapsed') === true
|
||||
}, { timeout: 10000 })
|
||||
await diffBlock.locator('button[title="Expand"]').first().click()
|
||||
await page.waitForFunction(() => {
|
||||
const body = document.querySelector('.preview-surface [data-markstream-monaco-diff="1"] .code-block-body')
|
||||
return body?.classList.contains('code-block-body--collapsed') === false
|
||||
}, { timeout: 10000 })
|
||||
await diffBlock.locator('button[title="Copy"]').first().click()
|
||||
await page.waitForFunction(() => {
|
||||
const live = document.querySelector('.preview-surface [data-markstream-monaco-diff="1"] [role="status"]')
|
||||
return live?.textContent?.includes('Copied')
|
||||
}, { timeout: 10000 })
|
||||
}
|
||||
|
||||
async function collectThinkingMetrics(page) {
|
||||
return page.evaluate(() => ({
|
||||
thinkingCount: document.querySelectorAll('.preview-surface .thinking-node').length,
|
||||
mermaidSvgCount: document.querySelectorAll('.preview-surface .thinking-node .markstream-angular-mermaid svg').length,
|
||||
monacoCount: document.querySelectorAll('.preview-surface .thinking-node [data-markstream-monaco="1"]').length,
|
||||
listCount: document.querySelectorAll('.preview-surface .thinking-node li').length,
|
||||
previewExcerpt: (document.querySelector('.preview-surface')?.textContent || '').slice(0, 500),
|
||||
}))
|
||||
}
|
||||
|
||||
async function collectThinkingDebug(page) {
|
||||
return page.evaluate(() => ({
|
||||
thinkingHtml: (document.querySelector('.preview-surface .thinking-node')?.innerHTML || '').slice(0, 8000),
|
||||
blockCount: document.querySelectorAll('.preview-surface .thinking-node .mermaid-block').length,
|
||||
blockHtml: (document.querySelector('.preview-surface .thinking-node .mermaid-block')?.outerHTML || '').slice(0, 4000),
|
||||
sourceCount: document.querySelectorAll('.preview-surface .thinking-node .mermaid-source').length,
|
||||
previewCount: document.querySelectorAll('.preview-surface .thinking-node .mermaid-preview').length,
|
||||
hostCount: document.querySelectorAll('.preview-surface .thinking-node .markstream-angular-mermaid').length,
|
||||
stage: document.querySelector('.preview-surface .thinking-node .mermaid-block')?.getAttribute('data-stage'),
|
||||
errorTexts: Array.from(document.querySelectorAll('.preview-surface .thinking-node .mermaid-error'))
|
||||
.map(node => node.textContent?.trim())
|
||||
.filter(Boolean),
|
||||
}))
|
||||
}
|
||||
|
||||
async function waitForDiffEditors(page, timeout = 60000) {
|
||||
const startedAt = Date.now()
|
||||
let lastSnapshot = null
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
lastSnapshot = await page.evaluate(() => ({
|
||||
wrapperCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"]').length,
|
||||
monacoCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"] .monaco-editor').length,
|
||||
badgeTexts: Array.from(document.querySelectorAll('.preview-surface .markstream-angular-enhanced-block__badge'))
|
||||
.map(node => node.textContent?.trim())
|
||||
.filter(Boolean),
|
||||
previewExcerpt: (document.querySelector('.preview-surface')?.textContent || '').slice(0, 500),
|
||||
textareaExcerpt: (((document.querySelector('textarea') && 'value' in document.querySelector('textarea'))
|
||||
? document.querySelector('textarea').value
|
||||
: '') || '').slice(0, 160),
|
||||
}))
|
||||
|
||||
if (lastSnapshot.wrapperCount >= 2 && lastSnapshot.monacoCount >= 2)
|
||||
return lastSnapshot
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for diff Monaco blocks: ${JSON.stringify(lastSnapshot)}`)
|
||||
}
|
||||
|
||||
async function collectStressMetrics(page) {
|
||||
return page.evaluate(() => ({
|
||||
tableCount: document.querySelectorAll('.preview-surface table').length,
|
||||
detailsCount: document.querySelectorAll('.preview-surface details').length,
|
||||
summaryCount: document.querySelectorAll('.preview-surface summary').length,
|
||||
blockquoteCount: document.querySelectorAll('.preview-surface blockquote').length,
|
||||
previewExcerpt: (document.querySelector('.preview-surface')?.textContent || '').slice(0, 500),
|
||||
}))
|
||||
}
|
||||
|
||||
async function collectStreamingMetrics(page) {
|
||||
const progressNode = page.locator('.hero-panel__metrics .metric-card').nth(3).locator('strong')
|
||||
const readProgress = async () => {
|
||||
const text = (await progressNode.textContent()).trim()
|
||||
return Number.parseInt(text.replace(/\D/g, ''), 10)
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: '开始流式渲染' }).click()
|
||||
await page.waitForFunction(() => {
|
||||
const pill = document.querySelectorAll('.workspace-card .mini-pill')[1]
|
||||
const foot = document.querySelectorAll('.workspace-card__foot span')[3]
|
||||
return pill?.textContent?.trim() === 'Streaming'
|
||||
&& foot?.textContent?.includes('正在逐步追加中')
|
||||
}, { timeout: 10000 })
|
||||
|
||||
const earlyProgress = await readProgress()
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const progress = document.querySelectorAll('.hero-panel__metrics .metric-card strong')[3]
|
||||
const pill = document.querySelectorAll('.workspace-card .mini-pill')[1]
|
||||
const foot = document.querySelectorAll('.workspace-card__foot span')[3]
|
||||
return progress?.textContent?.trim() === '100%'
|
||||
&& pill?.textContent?.trim() === 'Ready'
|
||||
&& foot?.textContent?.includes('已显示完整输入')
|
||||
}, { timeout: 30000 })
|
||||
|
||||
return {
|
||||
earlyProgress,
|
||||
finalProgress: await readProgress(),
|
||||
}
|
||||
}
|
||||
|
||||
async function collectHomeStreamingHealth(page, initialMetaText) {
|
||||
let previousMetaText = initialMetaText
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
await page.waitForFunction((previous) => {
|
||||
const meta = document.querySelector('.meta')
|
||||
return !!meta && meta.textContent?.trim() !== previous
|
||||
}, previousMetaText, { timeout: 20000 })
|
||||
|
||||
return await page.evaluate(() => new Promise((resolve) => {
|
||||
let frameCount = 0
|
||||
const startedAt = performance.now()
|
||||
const tick = (now) => {
|
||||
frameCount += 1
|
||||
if (now - startedAt >= 800) {
|
||||
resolve({
|
||||
frameCount,
|
||||
metaText: document.querySelector('.meta')?.textContent?.trim() || '',
|
||||
})
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}))
|
||||
}
|
||||
catch (error) {
|
||||
if (!isExecutionContextDestroyed(error) || attempt === 2)
|
||||
throw error
|
||||
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {})
|
||||
await page.waitForSelector('.meta', { timeout: 30000 })
|
||||
previousMetaText = (await page.locator('.meta').textContent().catch(() => initialMetaText)).trim()
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unable to collect home streaming health after navigation retries')
|
||||
}
|
||||
|
||||
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 } })
|
||||
await installHoverProbe(page)
|
||||
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 homeUrl = `http://${host}:${port}/`
|
||||
await page.goto(homeUrl, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('text=markstream-angular playground', { timeout: 30000 })
|
||||
await page.waitForSelector('text=Open /test', { timeout: 30000 })
|
||||
|
||||
const homeProgressText = (await page.locator('.meta').textContent()).trim()
|
||||
const homeStreamingHealth = await collectHomeStreamingHealth(page, homeProgressText)
|
||||
const homeHoverSelector = await waitForHomeHoverTarget(page)
|
||||
const homeStreamingCodeHighlight = await waitForHomeStreamingCodeHighlight(page)
|
||||
const homeHoverDuringStreaming = await exerciseHomeHoverDuringStreaming(page, homeHoverSelector)
|
||||
const homeThinking = await collectHomeThinkingMetrics(page)
|
||||
const homeLinkTooltip = await collectHomeLinkTooltip(page)
|
||||
await waitForHomeCompletion(page)
|
||||
const homeCompletion = await collectHomeCompletionMetrics(page)
|
||||
await page.getByRole('button', { name: 'Open /test' }).click()
|
||||
await page.waitForURL(new RegExp(`http://${host}:${port}/test$`), { timeout: 15000 })
|
||||
await page.waitForSelector('text=markstream-angular /test', { timeout: 30000 })
|
||||
|
||||
await waitForStableEnhancements(page)
|
||||
const baseline = await collectBaselineMetrics(page)
|
||||
await verifyBaselineInteractions(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Thinking 嵌套重节点' }).click()
|
||||
await page.waitForSelector('.preview-surface .thinking-node', { timeout: 20000 })
|
||||
try {
|
||||
await page.waitForSelector('.preview-surface .thinking-node .markstream-angular-mermaid svg', { timeout: 30000 })
|
||||
}
|
||||
catch (error) {
|
||||
const thinkingDebug = await collectThinkingDebug(page)
|
||||
throw new Error(`Timed out waiting for thinking Mermaid render: ${JSON.stringify(thinkingDebug)}`, { cause: error })
|
||||
}
|
||||
await page.waitForSelector('.preview-surface .thinking-node [data-markstream-monaco="1"]', { timeout: 30000 })
|
||||
const thinking = await collectThinkingMetrics(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Diff 与代码流' }).click()
|
||||
await waitForDiffEditors(page)
|
||||
const diff = await collectDiffMetrics(page)
|
||||
await verifyDiffInteractions(page)
|
||||
|
||||
await page.getByRole('button', { name: '结构压力' }).click()
|
||||
await page.waitForSelector('.preview-surface table', { timeout: 20000 })
|
||||
await page.waitForFunction(() => {
|
||||
return document.querySelectorAll('.preview-surface details').length >= 1
|
||||
&& document.querySelectorAll('.preview-surface summary').length >= 1
|
||||
}, { timeout: 20000 })
|
||||
const stress = await collectStressMetrics(page)
|
||||
const streaming = await collectStreamingMetrics(page)
|
||||
|
||||
await page.getByRole('button', { name: '返回主 demo' }).click()
|
||||
await page.waitForURL(new RegExp(`http://${host}:${port}/$`), { timeout: 15000 })
|
||||
await page.waitForSelector('text=markstream-angular playground', { timeout: 30000 })
|
||||
|
||||
const screenshot = '/tmp/markstream-angular-playground-e2e.png'
|
||||
await page.screenshot({ path: screenshot, fullPage: true })
|
||||
await browser.close()
|
||||
|
||||
const result = {
|
||||
homeUrl,
|
||||
homeProgressText,
|
||||
homeStreamingHealth,
|
||||
homeHoverDuringStreaming,
|
||||
homeThinking,
|
||||
homeLinkTooltip,
|
||||
homeStreamingCodeHighlight,
|
||||
homeCompletion,
|
||||
baseline,
|
||||
thinking,
|
||||
diff,
|
||||
stress,
|
||||
streaming,
|
||||
consoleErrors: summarizeErrors(consoleErrors),
|
||||
pageErrors: summarizeErrors(pageErrors),
|
||||
screenshot,
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
|
||||
if (result.consoleErrors.length > 0 || result.pageErrors.length > 0) {
|
||||
throw new Error(`Unexpected browser errors: console=${result.consoleErrors.length}, page=${result.pageErrors.length}`)
|
||||
}
|
||||
if (result.homeStreamingHealth.frameCount < 20 || result.homeStreamingHealth.metaText === result.homeProgressText) {
|
||||
throw new Error(`Home page became unresponsive while streaming: ${JSON.stringify(result.homeStreamingHealth)}`)
|
||||
}
|
||||
if (result.homeCompletion.metaText !== '11268 / 11268 (100%)') {
|
||||
throw new Error(`Home page did not reach full completion: ${JSON.stringify(result.homeCompletion)}`)
|
||||
}
|
||||
if (!result.homeCompletion.containsTaylor || !result.homeCompletion.containsOrthogonalComplement || !result.homeCompletion.containsHelloWorldTail) {
|
||||
throw new Error(`Home page final content is missing expected sections: ${JSON.stringify(result.homeCompletion)}`)
|
||||
}
|
||||
if (result.homeCompletion.katexCount < 10) {
|
||||
throw new Error(`Home page did not keep KaTeX refreshed through the full stream: ${JSON.stringify(result.homeCompletion)}`)
|
||||
}
|
||||
if (
|
||||
result.homeHoverDuringStreaming.interactionCount < 3
|
||||
|| result.homeHoverDuringStreaming.maxOverLatencyMs > 1200
|
||||
|| result.homeHoverDuringStreaming.maxOutLatencyMs > 1200
|
||||
|| result.homeHoverDuringStreaming.maxPlaceholderCount > 0
|
||||
|| result.homeHoverDuringStreaming.finalProbe.mouseoverCount < result.homeHoverDuringStreaming.interactionCount
|
||||
|| result.homeHoverDuringStreaming.finalProbe.mouseoutCount < result.homeHoverDuringStreaming.interactionCount
|
||||
) {
|
||||
throw new Error(`Home hover probe was sluggish while streaming: ${JSON.stringify(result.homeHoverDuringStreaming)}`)
|
||||
}
|
||||
if (
|
||||
result.homeThinking.textLength < 200
|
||||
|| !result.homeThinking.containsComprehensiveAnswer
|
||||
|| result.homeThinking.paragraphCount < 2
|
||||
) {
|
||||
throw new Error(`Home thinking stream did not keep nested content expanded: ${JSON.stringify(result.homeThinking)}`)
|
||||
}
|
||||
if (!result.homeLinkTooltip.visible || !result.homeLinkTooltip.text.includes('https://github.com/Simon-He95/markstream-vue')) {
|
||||
throw new Error(`Home link tooltip did not match the shared singleton behavior: ${JSON.stringify(result.homeLinkTooltip)}`)
|
||||
}
|
||||
if (result.homeStreamingCodeHighlight.monacoCount < 1 || result.homeStreamingCodeHighlight.done) {
|
||||
throw new Error(`Home code blocks did not enter highlighted mode before stream completion: ${JSON.stringify(result.homeStreamingCodeHighlight)}`)
|
||||
}
|
||||
if (baseline.katexCount < 1 || baseline.mermaidSvgCount < 1 || baseline.infographicSvgCount < 1 || baseline.d2SvgCount < 1) {
|
||||
throw new Error(`Baseline enhancements did not all render: ${JSON.stringify(baseline)}`)
|
||||
}
|
||||
if (thinking.thinkingCount < 1 || thinking.mermaidSvgCount < 1 || thinking.monacoCount < 1 || thinking.listCount < 2) {
|
||||
throw new Error(`Thinking sample did not render nested heavy nodes as expected: ${JSON.stringify(thinking)}`)
|
||||
}
|
||||
if (diff.wrapperCount < 2 || diff.monacoCount < 2) {
|
||||
throw new Error(`Diff sample did not mount Monaco editors as expected: ${JSON.stringify(diff)}`)
|
||||
}
|
||||
if (stress.tableCount < 1 || stress.detailsCount < 1 || stress.summaryCount < 1) {
|
||||
throw new Error(`Stress sample missed expected structural nodes: ${JSON.stringify(stress)}`)
|
||||
}
|
||||
if (!(streaming.earlyProgress > 0 && streaming.earlyProgress < 100) || streaming.finalProgress !== 100) {
|
||||
throw new Error(`Streaming progress did not behave as expected: ${JSON.stringify(streaming)}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const recentLogs = server.getLogs().trim()
|
||||
if (recentLogs) {
|
||||
console.error('--- playground-angular recent logs ---')
|
||||
console.error(recentLogs)
|
||||
console.error('--- end playground-angular recent logs ---')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
killProcessTree(server.child)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user