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,229 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright-core'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
const playgroundDir = path.join(repoRoot, 'playground')
|
||||
const host = '127.0.0.1'
|
||||
|
||||
const issueMarkdown = `- 代入数据:$c=0.75\\times10^3\\ \\text{J/(kg·℃)}$,$m=1.1\\ \\text{kg}$,$\\Delta t=40℃$
|
||||
- 计算得:$Q_1=0.75\\times10^3\\ \\text{J/(kg·℃)}\\times1.1\\ \\text{kg}\\times40℃=3.3\\times 10^{4}\\ \\text{J}$
|
||||
- 第(1)问:学生遗漏了比热容的数量级$10^3$,将$0.75\\times10^3\\ \\text{J/(kg·℃)}$错写为$0.75\\ \\text{J/(kg·℃)}$,导致计算出的$Q_1$错误。`
|
||||
|
||||
function isPortOpen(port) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host, port })
|
||||
socket.on('connect', () => {
|
||||
socket.end()
|
||||
resolve(true)
|
||||
})
|
||||
socket.on('error', () => {
|
||||
socket.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function findFreePort(start = 4291, end = 4330) {
|
||||
for (let port = start; port <= end; port++) {
|
||||
if (!await isPortOpen(port))
|
||||
return port
|
||||
}
|
||||
throw new Error(`No free port found in ${start}-${end}`)
|
||||
}
|
||||
|
||||
async function waitForPort(port, timeoutMs = 60000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isPortOpen(port))
|
||||
return
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${host}:${port}`)
|
||||
}
|
||||
|
||||
function killProcessTree(child) {
|
||||
if (!child || child.killed)
|
||||
return
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
catch {}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (!child.killed)
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
catch {}
|
||||
}, 3000).unref?.()
|
||||
}
|
||||
|
||||
function resolveChromeLaunchOptions() {
|
||||
const candidates = [
|
||||
process.env.PLAYWRIGHT_CHROME_PATH,
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
].filter(Boolean)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return {
|
||||
executablePath: candidate,
|
||||
headless: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
channel: 'chrome',
|
||||
headless: true,
|
||||
}
|
||||
}
|
||||
|
||||
function startDevServer(port) {
|
||||
const logs = []
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
child.stdout.on('data', chunk => logs.push(String(chunk)))
|
||||
child.stderr.on('data', chunk => logs.push(String(chunk)))
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logs.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createIssueUrl(port) {
|
||||
return `http://${host}:${port}/test#data=raw:${encodeURIComponent(issueMarkdown)}`
|
||||
}
|
||||
|
||||
async function waitForIssueMath(page) {
|
||||
await page.locator('.preview-surface').waitFor({ state: 'visible', timeout: 30000 })
|
||||
await page.waitForFunction(() => {
|
||||
const root = document.querySelector('.preview-surface')
|
||||
if (!root)
|
||||
return false
|
||||
|
||||
const inlineNodes = Array.from(root.querySelectorAll('[data-markstream-math="inline"]'))
|
||||
if (inlineNodes.length !== 8)
|
||||
return false
|
||||
|
||||
return inlineNodes.every((node) => {
|
||||
const mode = node.getAttribute('data-markstream-mode')
|
||||
return mode === 'katex'
|
||||
&& Boolean(node.querySelector('.katex'))
|
||||
&& !node.querySelector('.math-inline--fallback')
|
||||
&& !node.querySelector('.math-inline__loading')
|
||||
})
|
||||
}, { timeout: 30000 })
|
||||
}
|
||||
|
||||
async function readSummary(page) {
|
||||
return page.evaluate(() => {
|
||||
const root = document.querySelector('.preview-surface')
|
||||
const inlineNodes = Array.from(root?.querySelectorAll('[data-markstream-math="inline"]') || [])
|
||||
return {
|
||||
inlineCount: inlineNodes.length,
|
||||
inlineModes: inlineNodes.map(node => node.getAttribute('data-markstream-mode')),
|
||||
katexCount: root?.querySelectorAll('[data-markstream-math="inline"] .katex').length || 0,
|
||||
fallbackInlineCount: root?.querySelectorAll('.math-inline--fallback').length || 0,
|
||||
fallbackBlockCount: root?.querySelectorAll('.math-block__fallback').length || 0,
|
||||
loadingInlineCount: root?.querySelectorAll('.math-inline__loading').length || 0,
|
||||
katexErrorCount: root?.querySelectorAll('.katex-error').length || 0,
|
||||
previewText: root?.textContent || '',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = await findFreePort()
|
||||
const server = startDevServer(port)
|
||||
let browser
|
||||
let page
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
browser = await chromium.launch(resolveChromeLaunchOptions())
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 1200 } })
|
||||
|
||||
const relevantConsoleIssues = []
|
||||
const pageErrors = []
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if ((msg.type() === 'error' || msg.type() === 'warning') && /katex|cdotp|character metrics|undefined control sequence/i.test(msg.text()))
|
||||
relevantConsoleIssues.push(`[${msg.type()}] ${msg.text()}`)
|
||||
})
|
||||
page.on('pageerror', error => pageErrors.push(String(error?.message || error)))
|
||||
|
||||
await page.goto(createIssueUrl(port), { waitUntil: 'load' })
|
||||
await waitForIssueMath(page)
|
||||
|
||||
const summary = await readSummary(page)
|
||||
|
||||
if (summary.inlineCount !== 8)
|
||||
throw new Error(`Expected 8 inline math nodes, got ${summary.inlineCount}`)
|
||||
if (summary.katexCount !== 8)
|
||||
throw new Error(`Expected 8 rendered KaTeX nodes, got ${summary.katexCount}`)
|
||||
if (summary.inlineModes.some(mode => mode !== 'katex'))
|
||||
throw new Error(`Expected all inline math nodes to be in katex mode, got ${summary.inlineModes.join(', ')}`)
|
||||
if (summary.fallbackInlineCount !== 0 || summary.fallbackBlockCount !== 0)
|
||||
throw new Error(`Unexpected fallback nodes: inline=${summary.fallbackInlineCount}, block=${summary.fallbackBlockCount}`)
|
||||
if (summary.loadingInlineCount !== 0)
|
||||
throw new Error(`Unexpected loading math nodes left in preview: ${summary.loadingInlineCount}`)
|
||||
if (summary.katexErrorCount !== 0)
|
||||
throw new Error(`Unexpected KaTeX error nodes in preview: ${summary.katexErrorCount}`)
|
||||
if (summary.previewText.includes('$c=0.75\\times10^3\\ \\text{J/(kg·℃)}$'))
|
||||
throw new Error('Preview still contains raw fallback text for the first issue formula')
|
||||
if (relevantConsoleIssues.length)
|
||||
throw new Error(`Unexpected KaTeX console issues:\n${relevantConsoleIssues.join('\n')}`)
|
||||
if (pageErrors.length)
|
||||
throw new Error(`Unexpected page errors:\n${pageErrors.join('\n')}`)
|
||||
|
||||
console.log('KaTeX unicode-unit e2e passed.')
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
catch (error) {
|
||||
if (page) {
|
||||
try {
|
||||
const screenshot = '/tmp/markstream-katex-unicode-unit-regression.png'
|
||||
await page.screenshot({ path: screenshot, fullPage: true })
|
||||
console.error(`Saved failure screenshot to ${screenshot}`)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
console.error(server.getLogs())
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
await browser?.close().catch(() => {})
|
||||
killProcessTree(server.child)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[e2e-katex-unicode-unit-regression] failed')
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
Reference in New Issue
Block a user