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,445 @@
|
||||
#!/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')
|
||||
|
||||
function isPortOpen(port, host = '127.0.0.1') {
|
||||
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 = 4173, end = 4199) {
|
||||
for (let port = start; port <= end; port++) {
|
||||
const open = await isPortOpen(port)
|
||||
if (!open)
|
||||
return port
|
||||
}
|
||||
throw new Error(`No free port found in ${start}-${end}`)
|
||||
}
|
||||
|
||||
async function waitForPort(port, ms = 20000) {
|
||||
const start = Date.now()
|
||||
while (true) {
|
||||
const open = await isPortOpen(port)
|
||||
if (open)
|
||||
return
|
||||
if (Date.now() - start > ms)
|
||||
throw new Error(`Timed out waiting for port ${port}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
}
|
||||
}
|
||||
|
||||
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 countLines(text) {
|
||||
return text.split(/\r?\n/).length
|
||||
}
|
||||
|
||||
function buildScenario() {
|
||||
const originalLines = [
|
||||
'type Task = { id: string, status: \'todo\' | \'done\' }',
|
||||
'',
|
||||
'export async function synchronizeTasks(tasks: Task[]) {',
|
||||
' const report: string[] = []',
|
||||
String.raw` report.push(\`input=\${tasks.length}\`)`,
|
||||
'',
|
||||
]
|
||||
|
||||
for (let i = 1; i <= 160; i++) {
|
||||
if (i % 20 === 0)
|
||||
originalLines.push(` // stable checkpoint ${i}`)
|
||||
const n = String(i).padStart(3, '0')
|
||||
originalLines.push(` report.push('stable-${n}')`)
|
||||
}
|
||||
|
||||
originalLines.push('', ' return report', '}')
|
||||
|
||||
const modifiedLines = [...originalLines]
|
||||
|
||||
const optimized = modifiedLines.findIndex(line => line.includes('stable-018'))
|
||||
if (optimized >= 0) {
|
||||
modifiedLines[optimized] = ` report.push('stable-018-optimized')`
|
||||
modifiedLines[optimized + 1] = ` report.push('stable-019-optimized')`
|
||||
modifiedLines[optimized + 2] = ` report.push('stable-020-optimized')`
|
||||
}
|
||||
|
||||
const hotfix = modifiedLines.findIndex(line => line.includes('stable-086'))
|
||||
if (hotfix >= 0) {
|
||||
modifiedLines[hotfix - 1] = ` report.push('stable-085-hotfix')`
|
||||
modifiedLines[hotfix] = ` report.push('stable-086-hotfix')`
|
||||
modifiedLines[hotfix + 1] = ` report.push('stable-087-hotfix')`
|
||||
}
|
||||
|
||||
const cache = modifiedLines.findIndex(line => line.includes('stable-132'))
|
||||
if (cache >= 0) {
|
||||
modifiedLines[cache - 1] = ` report.push('stable-131-cache-hit')`
|
||||
modifiedLines[cache] = ` report.push('stable-132-cache-hit')`
|
||||
modifiedLines[cache + 1] = ` report.push('stable-133-cache-miss')`
|
||||
}
|
||||
|
||||
const returnLine = modifiedLines.findIndex(line => line.trim() === 'return report')
|
||||
if (returnLine >= 0)
|
||||
modifiedLines[returnLine] = ' return report.filter(Boolean)'
|
||||
|
||||
return {
|
||||
original: originalLines.join('\n'),
|
||||
modified: modifiedLines.join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiffMarkdown(pair) {
|
||||
const original = pair.original.split(/\r?\n/)
|
||||
const modified = pair.modified.split(/\r?\n/)
|
||||
const max = Math.max(original.length, modified.length)
|
||||
const diffLines = []
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const left = original[i]
|
||||
const right = modified[i]
|
||||
|
||||
if (left === right) {
|
||||
diffLines.push(left ?? '')
|
||||
continue
|
||||
}
|
||||
if (left != null)
|
||||
diffLines.push(`-${left}`)
|
||||
if (right != null)
|
||||
diffLines.push(`+${right}`)
|
||||
}
|
||||
|
||||
return [
|
||||
'# Diff folding fixture',
|
||||
'',
|
||||
'This page is used by the Playwright smoke test.',
|
||||
'',
|
||||
'```diff ts:synchronizeTasks.ts',
|
||||
...diffLines,
|
||||
'```',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function estimateChangedLines(pair) {
|
||||
const original = pair.original.split(/\r?\n/)
|
||||
const modified = pair.modified.split(/\r?\n/)
|
||||
const max = Math.max(original.length, modified.length)
|
||||
let changed = 0
|
||||
for (let i = 0; i < max; i++) {
|
||||
if ((original[i] ?? '') !== (modified[i] ?? ''))
|
||||
changed++
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHiddenRegions(page, timeout = 20000) {
|
||||
await page.waitForFunction(() => {
|
||||
const hiddenTexts = Array.from(document.querySelectorAll('.diff-hidden-lines'))
|
||||
.map(el => (el.textContent ?? '').trim())
|
||||
.filter(Boolean)
|
||||
return hiddenTexts.some(text => /hidden lines|unchanged|unmodified/i.test(text))
|
||||
}, { timeout })
|
||||
}
|
||||
|
||||
async function collectResult(page, name, extra = {}) {
|
||||
return page.evaluate(({ name, extra }) => {
|
||||
const isVisibleButton = (element) => {
|
||||
if (!(element instanceof HTMLElement))
|
||||
return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(element)
|
||||
return (
|
||||
rect.width >= 16
|
||||
&& rect.height >= 16
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number.parseFloat(style.opacity || '1') > 0.01
|
||||
)
|
||||
}
|
||||
const roundPx = (value) => {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
const parsePx = (value) => {
|
||||
const n = Number.parseFloat(String(value ?? ''))
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
const diffEditors = Array.from(document.querySelectorAll('.monaco-diff-editor'))
|
||||
const hiddenTexts = Array.from(document.querySelectorAll('.diff-hidden-lines'))
|
||||
.map(el => (el.textContent ?? '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const originalVisibleLines = document.querySelectorAll('.editor.original .view-line').length
|
||||
const modifiedVisibleLines = document.querySelectorAll('.editor.modified .view-line').length
|
||||
const bodyText = document.body.textContent ?? ''
|
||||
const diffEditorMetrics = diffEditors.map((editorEl, index) => {
|
||||
const editorRect = editorEl.getBoundingClientRect()
|
||||
const host = editorEl.closest('.code-editor-container')
|
||||
const hostStyle = host instanceof HTMLElement ? window.getComputedStyle(host) : null
|
||||
const editorHiddenTexts = Array.from(editorEl.querySelectorAll('.diff-hidden-lines'))
|
||||
.map(el => (el.textContent ?? '').trim())
|
||||
.filter(Boolean)
|
||||
const foldButtonCandidates = Array.from(
|
||||
editorEl.querySelectorAll('.markstream-inline-fold-proxy, .diff-hidden-lines a'),
|
||||
)
|
||||
const foldButton
|
||||
= foldButtonCandidates.find(candidate => isVisibleButton(candidate))
|
||||
?? foldButtonCandidates[0]
|
||||
?? null
|
||||
const foldButtonRect = foldButton?.getBoundingClientRect?.() ?? null
|
||||
const hostHeightPx = host instanceof HTMLElement ? roundPx(host.getBoundingClientRect().height) : null
|
||||
const hostMaxHeightPx = parsePx(hostStyle?.maxHeight)
|
||||
const hostOverflow = hostStyle?.overflow ?? null
|
||||
|
||||
return {
|
||||
index,
|
||||
isSideBySide: editorEl.classList.contains('side-by-side'),
|
||||
foldButtonKind: foldButton?.classList.contains('markstream-inline-fold-proxy') ? 'proxy' : (foldButton ? 'native' : null),
|
||||
hiddenRegionTexts: editorHiddenTexts,
|
||||
hostHeightPx,
|
||||
hostInlineHeight: host instanceof HTMLElement ? host.style.height : '',
|
||||
hostMaxHeightPx,
|
||||
hostInlineMaxHeight: host instanceof HTMLElement ? host.style.maxHeight : '',
|
||||
hostOverflow,
|
||||
hostOverflowY: hostStyle?.overflowY ?? null,
|
||||
editorHeightPx: roundPx(editorEl.getBoundingClientRect().height),
|
||||
foldButtonRect: foldButtonRect
|
||||
? {
|
||||
x: roundPx(foldButtonRect.x),
|
||||
y: roundPx(foldButtonRect.y),
|
||||
width: roundPx(foldButtonRect.width),
|
||||
height: roundPx(foldButtonRect.height),
|
||||
}
|
||||
: null,
|
||||
foldButtonVisible: !!(foldButtonRect
|
||||
&& foldButtonRect.width >= 16
|
||||
&& foldButtonRect.height >= 16
|
||||
&& foldButtonRect.left >= editorRect.left - 1
|
||||
&& foldButtonRect.right <= editorRect.right + 1
|
||||
&& foldButtonRect.top >= editorRect.top - 1
|
||||
&& foldButtonRect.bottom <= editorRect.bottom + 1),
|
||||
hostShrunkAfterFolding: editorHiddenTexts.length > 0
|
||||
? !!(hostHeightPx != null && hostMaxHeightPx != null && hostHeightPx + 1 < hostMaxHeightPx && hostOverflow !== 'auto')
|
||||
: null,
|
||||
}
|
||||
})
|
||||
const foldedMetrics = diffEditorMetrics.filter(metric => metric.hiddenRegionTexts.length > 0)
|
||||
const inlineMetrics = foldedMetrics.filter(metric => metric.isSideBySide === false)
|
||||
const foldedHostShrinks = foldedMetrics.length > 0 && foldedMetrics.every(metric => metric.hostShrunkAfterFolding === true)
|
||||
const inlineFoldButtonVisible = inlineMetrics.length > 0 && inlineMetrics.every(metric => metric.foldButtonVisible === true)
|
||||
|
||||
return {
|
||||
name,
|
||||
...extra,
|
||||
diffEditors: diffEditors.length,
|
||||
hiddenRegionCount: hiddenTexts.length,
|
||||
hiddenRegionTexts: hiddenTexts,
|
||||
hasHiddenRegionText: hiddenTexts.some(text => /hidden lines|unchanged|unmodified/i.test(text)),
|
||||
bodyContainsHiddenLinesText: /hidden lines|unchanged|unmodified/i.test(bodyText),
|
||||
originalVisibleLines,
|
||||
modifiedVisibleLines,
|
||||
foldedHostShrinks,
|
||||
inlineFoldButtonVisible,
|
||||
diffEditorMetrics,
|
||||
}
|
||||
}, { name, extra })
|
||||
}
|
||||
|
||||
async function runControlledScenario(context, baseUrl) {
|
||||
const pair = buildScenario()
|
||||
const markdown = buildDiffMarkdown(pair)
|
||||
const page = await context.newPage()
|
||||
const url = `${baseUrl}/test#data=raw:${encodeURIComponent(markdown)}`
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
|
||||
await waitForHiddenRegions(page)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const result = await collectResult(page, 'test-route', {
|
||||
url,
|
||||
originalLineCount: countLines(pair.original),
|
||||
modifiedLineCount: countLines(pair.modified),
|
||||
changedLineEstimate: estimateChangedLines(pair),
|
||||
})
|
||||
|
||||
await page.close()
|
||||
return result
|
||||
}
|
||||
|
||||
async function runHomeScenario(context, baseUrl) {
|
||||
const page = await context.newPage()
|
||||
const url = `${baseUrl}/`
|
||||
const diffEditor = page.locator('.monaco-diff-editor').last()
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
|
||||
await diffEditor.scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(500)
|
||||
await waitForHiddenRegions(page, 30000)
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
const result = await collectResult(page, 'index-route', { url })
|
||||
await page.close()
|
||||
return result
|
||||
}
|
||||
|
||||
async function runHomeInlineScenario(context, baseUrl) {
|
||||
const page = await context.newPage()
|
||||
const url = `${baseUrl}/`
|
||||
const diffEditor = page.locator('.monaco-diff-editor').last()
|
||||
|
||||
await page.setViewportSize({ width: 430, height: 900 })
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
|
||||
await diffEditor.scrollIntoViewIfNeeded()
|
||||
await waitForHiddenRegions(page, 30000)
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
const initial = await collectResult(page, 'index-route-inline', { url })
|
||||
const foldButton = page.locator('.markstream-inline-fold-proxy:visible, .diff-hidden-lines a:visible').first()
|
||||
await foldButton.click()
|
||||
await page.waitForFunction(() => document.querySelectorAll('.diff-hidden-lines').length === 0, { timeout: 10000 })
|
||||
const afterExpandHiddenRegionCount = await page.locator('.diff-hidden-lines').count()
|
||||
|
||||
await page.close()
|
||||
return {
|
||||
...initial,
|
||||
afterExpandHiddenRegionCount,
|
||||
afterExpandFoldCleared: afterExpandHiddenRegionCount === 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
|
||||
if (!Number.isFinite(port))
|
||||
throw new Error(`Invalid PORT: ${process.env.PORT}`)
|
||||
|
||||
const vite = spawn(
|
||||
'pnpm',
|
||||
['-C', playgroundDir, 'dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
},
|
||||
)
|
||||
|
||||
const logs = []
|
||||
vite.stdout.on('data', chunk => logs.push(String(chunk)))
|
||||
vite.stderr.on('data', chunk => logs.push(String(chunk)))
|
||||
|
||||
const cleanup = () => killProcessTree(vite)
|
||||
process.on('SIGINT', cleanup)
|
||||
process.on('SIGTERM', cleanup)
|
||||
process.on('exit', cleanup)
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
|
||||
const browser = await chromium.launch(resolveChromeLaunchOptions())
|
||||
const context = await browser.newContext()
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem('vmr-settings-stream-delay', '4')
|
||||
localStorage.setItem('vmr-settings-stream-chunk-size', '16')
|
||||
localStorage.setItem('vmr-test-render-mode', 'monaco')
|
||||
localStorage.setItem('vmr-test-code-stream', 'false')
|
||||
localStorage.setItem('vmr-test-viewport-priority', 'false')
|
||||
localStorage.setItem('vmr-test-batch-rendering', 'false')
|
||||
localStorage.setItem('vmr-test-typewriter', 'false')
|
||||
localStorage.setItem('vmr-test-show-settings', 'false')
|
||||
})
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
const controlled = await runControlledScenario(context, baseUrl)
|
||||
const home = await runHomeScenario(context, baseUrl)
|
||||
const homeInline = await runHomeInlineScenario(context, baseUrl)
|
||||
await browser.close()
|
||||
|
||||
const ok = controlled.hasHiddenRegionText && controlled.hiddenRegionCount > 0
|
||||
&& home.hasHiddenRegionText && home.hiddenRegionCount > 0
|
||||
&& home.foldedHostShrinks
|
||||
&& homeInline.hasHiddenRegionText && homeInline.hiddenRegionCount > 0
|
||||
&& homeInline.inlineFoldButtonVisible
|
||||
&& homeInline.afterExpandFoldCleared
|
||||
&& home.diffEditors === 1
|
||||
|
||||
const result = {
|
||||
ok,
|
||||
controlled,
|
||||
home,
|
||||
homeInline,
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
|
||||
if (!ok) {
|
||||
console.error('Recent Vite logs:\n', logs.slice(-40).join(''))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user