Files
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

343 lines
11 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-nuxt')
const host = '127.0.0.1'
function assert(condition, message) {
if (!condition)
throw new Error(message)
}
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 = 4300, end = 4360) {
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 = 90000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 200))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
async function waitForHttpReady(url, timeoutMs = 90000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(url)
if (response.ok)
return
}
catch {}
await new Promise(resolve => setTimeout(resolve, 250))
}
throw new Error(`Timed out waiting for HTTP readiness: ${url}`)
}
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 createProcessRunner(command, args, cwd, extraEnv = {}) {
const logBuffer = []
const child = spawn(command, args, {
cwd,
env: {
...process.env,
CI: '1',
NUXT_TELEMETRY_DISABLED: '1',
...extraEnv,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
const appendLogs = (chunk) => {
const text = String(chunk)
logBuffer.push(text)
if (logBuffer.length > 240)
logBuffer.splice(0, logBuffer.length - 240)
}
child.stdout.on('data', appendLogs)
child.stderr.on('data', appendLogs)
return {
child,
getLogs() {
return logBuffer.join('')
},
}
}
async function runCommand(command, args, cwd, label) {
const runner = createProcessRunner(command, args, cwd)
const exitCode = await new Promise((resolve, reject) => {
runner.child.on('error', reject)
runner.child.on('exit', resolve)
})
if (exitCode !== 0) {
throw new Error(`${label} failed with exit code ${exitCode}\n${runner.getLogs()}`)
}
}
function startNuxtDev(port) {
return createProcessRunner('pnpm', ['exec', 'nuxt', 'dev', '--host', host, '--port', String(port)], playgroundDir)
}
function startNuxtPreview(port) {
return createProcessRunner(
'pnpm',
['exec', 'nuxt', 'preview'],
playgroundDir,
{
HOST: host,
PORT: String(port),
NITRO_HOST: host,
NITRO_PORT: String(port),
},
)
}
async function assertRawHtml(baseUrl, mode) {
const response = await fetch(`${baseUrl}/ssr-lab`)
assert(response.ok, `[${mode}] raw SSR fetch failed with status ${response.status}`)
const html = await response.text()
const expectedSnippets = [
'data-ssr-case="basic"',
'data-ssr-case="enhanced"',
'data-ssr-case="fallback"',
'SSR Basic Coverage',
'data-ssr-inline-html="1"',
'data-ssr-html-block="1"',
'data-markstream-code-block="1"',
'data-markstream-pre="1"',
'data-markstream-math="inline"',
'data-markstream-mode="katex"',
'data-markstream-mermaid="1"',
'data-markstream-d2="1"',
'data-markstream-infographic="1"',
'data-ssr-fallback="math-inline"',
'data-ssr-fallback="mermaid"',
]
for (const snippet of expectedSnippets)
assert(html.includes(snippet), `[${mode}] raw SSR HTML is missing expected snippet: ${snippet}`)
}
function collectUnexpectedIssues(entries) {
const patterns = [
/hydration/i,
/window is not defined/i,
/referenceerror/i,
/cannot read properties/i,
/failed to initialize mermaid renderer/i,
/failed to render/i,
]
return entries.filter(({ type, text }) => {
if (type === 'pageerror')
return String(text || '').trim() !== 'Event'
if (/Could not create web worker\(s\)\./.test(text))
return false
return patterns.some(pattern => pattern.test(text))
})
}
async function waitForEnhancedWidgets(page) {
await page.locator('[data-ssr-case="enhanced"]').scrollIntoViewIfNeeded()
await page.waitForSelector('[data-ssr-case="hydration"][data-ssr-hydrated="true"]', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-code-block="1"][data-markstream-enhanced="true"]', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-math="inline"][data-markstream-mode="katex"] .katex', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-math="block"][data-markstream-mode="katex"] .katex-display', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-mermaid="1"][data-markstream-mode="preview"] ._mermaid svg', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-d2="1"][data-markstream-mode="preview"] svg.markstream-d2-root-svg', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-infographic="1"][data-markstream-mode="preview"] svg', { timeout: 90000 })
await page.waitForFunction(() => {
const readyCount = document.querySelector('[data-ssr-status="ready-count"]')?.textContent?.trim()
return readyCount === '5'
}, { timeout: 90000 })
}
async function assertFallbackSection(page, mode) {
const fallbackState = await page.evaluate(() => {
const section = document.querySelector('[data-ssr-case="fallback"]')
return {
mathInline: !!section?.querySelector('[data-ssr-fallback="math-inline"]'),
mathBlock: !!section?.querySelector('[data-ssr-fallback="math-block"]'),
mermaid: !!section?.querySelector('[data-ssr-fallback="mermaid"]'),
d2: !!section?.querySelector('[data-ssr-fallback="d2"]'),
infographic: !!section?.querySelector('[data-ssr-fallback="infographic"]'),
previewLeak: !!section?.querySelector('[data-markstream-mode="preview"]'),
}
})
assert(fallbackState.mathInline, `[${mode}] fallback section lost inline math fallback`)
assert(fallbackState.mathBlock, `[${mode}] fallback section lost block math fallback`)
assert(fallbackState.mermaid, `[${mode}] fallback section lost mermaid fallback`)
assert(fallbackState.d2, `[${mode}] fallback section lost d2 fallback`)
assert(fallbackState.infographic, `[${mode}] fallback section lost infographic fallback`)
assert(!fallbackState.previewLeak, `[${mode}] fallback section unexpectedly switched to preview mode`)
}
async function assertBrowserBehavior(baseUrl, mode) {
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage()
const issues = []
page.on('console', (msg) => {
const type = msg.type()
if (type === 'error' || type === 'warning')
issues.push({ type, text: msg.text() })
})
page.on('pageerror', (error) => {
issues.push({ type: 'pageerror', text: String(error?.message ?? error) })
})
try {
await page.goto(`${baseUrl}/ssr-lab`, { waitUntil: 'domcontentloaded', timeout: 90000 })
await waitForEnhancedWidgets(page, mode)
await assertFallbackSection(page, mode)
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 })
await waitForEnhancedWidgets(page, `${mode} reload`)
await assertFallbackSection(page, `${mode} reload`)
await page.goto(`${baseUrl}/`, { waitUntil: 'domcontentloaded', timeout: 90000 })
await page.goto(`${baseUrl}/ssr-lab`, { waitUntil: 'domcontentloaded', timeout: 90000 })
await waitForEnhancedWidgets(page, `${mode} route`)
await assertFallbackSection(page, `${mode} route`)
}
finally {
await browser.close()
}
const unexpectedIssues = collectUnexpectedIssues(issues)
assert(unexpectedIssues.length === 0, `[${mode}] browser reported unexpected issues:\n${unexpectedIssues.map(issue => `${issue.type}: ${issue.text}`).join('\n')}`)
}
async function runDevMode() {
const port = await findFreePort(4300, 4330)
const runner = startNuxtDev(port)
const baseUrl = `http://${host}:${port}`
try {
await waitForPort(port)
await waitForHttpReady(`${baseUrl}/ssr-lab`)
await assertRawHtml(baseUrl, 'nuxt dev')
await assertBrowserBehavior(baseUrl, 'nuxt dev')
}
catch (error) {
throw new Error(`${error.message}\n\nNuxt dev logs:\n${runner.getLogs()}`)
}
finally {
killProcessTree(runner.child)
}
}
async function runPreviewMode() {
await runCommand('pnpm', ['exec', 'nuxt', 'build'], playgroundDir, 'Nuxt build')
const port = await findFreePort(4331, 4360)
const runner = startNuxtPreview(port)
const baseUrl = `http://${host}:${port}`
try {
await waitForPort(port)
await waitForHttpReady(`${baseUrl}/ssr-lab`)
await assertRawHtml(baseUrl, 'nuxt preview')
await assertBrowserBehavior(baseUrl, 'nuxt preview')
}
catch (error) {
throw new Error(`${error.message}\n\nNuxt preview logs:\n${runner.getLogs()}`)
}
finally {
killProcessTree(runner.child)
}
}
async function main() {
await runCommand('pnpm', ['build'], repoRoot, 'Library build')
await runDevMode()
await runPreviewMode()
console.log('Nuxt SSR e2e passed in dev and preview modes.')
}
main()
.then(() => {
process.exit(0)
})
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})