Files
BoosAPI/desktop/libs/markstream-vue/scripts/e2e-angular-playground.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

821 lines
31 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-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)
})