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