forked from boos/BoosAPI
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,288 @@
|
||||
#!/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'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
const markstreamRoot = '/Users/Simon/Github/markstream-vue'
|
||||
const playgroundDir = path.join(markstreamRoot, 'playground')
|
||||
const host = '127.0.0.1'
|
||||
const node23Bin = '/Users/Simon/.local/share/fnm/node-versions/v23.11.0/installation/bin'
|
||||
|
||||
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 = 4236, end = 4280) {
|
||||
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 = 30000) {
|
||||
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',
|
||||
].filter(Boolean)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return {
|
||||
executablePath: candidate,
|
||||
headless: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
channel: 'chrome',
|
||||
headless: true,
|
||||
}
|
||||
}
|
||||
|
||||
function colorLuminance(color) {
|
||||
const channels = String(color || '').match(/\d+(?:\.\d+)?/g)
|
||||
if (!channels || channels.length < 3)
|
||||
return null
|
||||
const [r, g, b] = channels.slice(0, 3).map(Number)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
async function snapshot(page) {
|
||||
return page.evaluate(() => {
|
||||
function visibleTexts(selector) {
|
||||
return Array.from(document.querySelectorAll(selector))
|
||||
.filter((node) => {
|
||||
if (!(node instanceof HTMLElement))
|
||||
return false
|
||||
const text = node.innerText?.trim() ?? ''
|
||||
if (!text)
|
||||
return false
|
||||
const style = window.getComputedStyle(node)
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number.parseFloat(style.opacity || '1') > 0.05
|
||||
})
|
||||
.map(node => node.innerText.trim())
|
||||
}
|
||||
|
||||
const container = document.querySelector('.code-block-container')
|
||||
const header = document.querySelector('.code-block-header')
|
||||
const diffRoot = document.querySelector('.stream-monaco-diff-root')
|
||||
const editorBackground = diffRoot?.querySelector('.monaco-editor .monaco-editor-background')
|
||||
const themeSelect = document.querySelector('select[aria-label="Code block theme"]')
|
||||
const settingsPanel = document.querySelector('.settings-toggle')?.parentElement
|
||||
|
||||
const containerStyle = container instanceof HTMLElement ? window.getComputedStyle(container) : null
|
||||
const headerStyle = header instanceof HTMLElement ? window.getComputedStyle(header) : null
|
||||
const editorStyle = editorBackground instanceof HTMLElement ? window.getComputedStyle(editorBackground) : null
|
||||
|
||||
return {
|
||||
containerClass: container instanceof HTMLElement ? container.className : '',
|
||||
rootClasses: diffRoot instanceof HTMLElement ? Array.from(diffRoot.classList) : [],
|
||||
containerBg: containerStyle?.backgroundColor || '',
|
||||
headerBg: headerStyle?.backgroundColor || '',
|
||||
editorBg: editorStyle?.backgroundColor || '',
|
||||
selectedTheme: themeSelect instanceof HTMLSelectElement ? themeSelect.value : '',
|
||||
settingsOpen: settingsPanel instanceof HTMLElement
|
||||
? settingsPanel.querySelector('select[aria-label="Code block theme"]') instanceof HTMLElement
|
||||
: false,
|
||||
visibleNativeCompactTexts: visibleTexts('.diff-hidden-lines-compact .text'),
|
||||
visibleNativeCenterTexts: visibleTexts('.diff-hidden-lines .center'),
|
||||
visibleHiddenLinesInBody: /hidden lines/i.test(document.body.innerText || ''),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function openSettings(page) {
|
||||
const toggle = page.locator('.settings-toggle').first()
|
||||
await toggle.waitFor({ state: 'visible', timeout: 30000 })
|
||||
const select = page.locator('select[aria-label="Code block theme"]')
|
||||
if (await select.count()) {
|
||||
const visible = await select.first().isVisible().catch(() => false)
|
||||
if (visible)
|
||||
return
|
||||
}
|
||||
await toggle.click()
|
||||
await select.first().waitFor({ state: 'visible', timeout: 30000 })
|
||||
}
|
||||
|
||||
async function toggleDarkMode(page) {
|
||||
await openSettings(page)
|
||||
const darkModeRow = page.locator('label', { hasText: 'Dark Mode' }).locator('..')
|
||||
const button = darkModeRow.locator('button').first()
|
||||
await button.waitFor({ state: 'visible', timeout: 30000 })
|
||||
await button.click()
|
||||
}
|
||||
|
||||
async function captureTransition(page) {
|
||||
const frames = []
|
||||
for (let i = 0; i < 72; i++) {
|
||||
await page.waitForTimeout(16)
|
||||
frames.push(await snapshot(page))
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
async function ensureSelectedTheme(page, value) {
|
||||
await openSettings(page)
|
||||
const select = page.locator('select[aria-label="Code block theme"]').first()
|
||||
await select.selectOption(value)
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
|
||||
const vite = spawn(
|
||||
'pnpm',
|
||||
['-C', playgroundDir, 'dev', '--host', host, '--port', String(port), '--strictPort'],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${node23Bin}:${process.env.PATH || ''}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
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 page = await browser.newPage({
|
||||
viewport: { width: 1600, height: 1200 },
|
||||
colorScheme: 'light',
|
||||
})
|
||||
|
||||
const pageErrors = []
|
||||
const consoleErrors = []
|
||||
page.on('pageerror', (error) => {
|
||||
pageErrors.push(String(error))
|
||||
})
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error')
|
||||
consoleErrors.push(message.text())
|
||||
})
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('vmr-settings-selected-theme', 'vitesse-dark')
|
||||
localStorage.setItem('vueuse-color-scheme', 'light')
|
||||
})
|
||||
|
||||
await page.goto(`http://${host}:${port}/`, { waitUntil: 'networkidle' })
|
||||
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
|
||||
await page.waitForTimeout(1600)
|
||||
|
||||
await ensureSelectedTheme(page, 'vitesse-dark')
|
||||
|
||||
const initial = await snapshot(page)
|
||||
await toggleDarkMode(page)
|
||||
const toDarkFrames = await captureTransition(page)
|
||||
const afterDark = toDarkFrames[toDarkFrames.length - 1] ?? await snapshot(page)
|
||||
await toggleDarkMode(page)
|
||||
const toLightFrames = await captureTransition(page)
|
||||
const afterLight = toLightFrames[toLightFrames.length - 1] ?? await snapshot(page)
|
||||
|
||||
await browser.close()
|
||||
|
||||
const allFrames = [initial, ...toDarkFrames, ...toLightFrames]
|
||||
const anyHiddenFlash = allFrames.some(frame =>
|
||||
frame.visibleHiddenLinesInBody
|
||||
|| frame.visibleNativeCompactTexts.length > 0
|
||||
|| frame.visibleNativeCenterTexts.some(text => /hidden lines/i.test(text)),
|
||||
)
|
||||
|
||||
const initialLum = colorLuminance(initial.containerBg)
|
||||
const darkLum = colorLuminance(afterDark.containerBg)
|
||||
const lightLum = colorLuminance(afterLight.containerBg)
|
||||
|
||||
const result = {
|
||||
ok:
|
||||
initial.selectedTheme === 'vitesse-dark'
|
||||
&& initial.rootClasses.includes('stream-monaco-diff-appearance-light')
|
||||
&& initialLum != null && initialLum > 170
|
||||
&& afterDark.rootClasses.includes('stream-monaco-diff-appearance-dark')
|
||||
&& darkLum != null && darkLum < 90
|
||||
&& afterLight.rootClasses.includes('stream-monaco-diff-appearance-light')
|
||||
&& lightLum != null && lightLum > 170
|
||||
&& !anyHiddenFlash
|
||||
&& pageErrors.length === 0
|
||||
&& consoleErrors.length === 0,
|
||||
initial,
|
||||
afterDark,
|
||||
afterLight,
|
||||
anyHiddenFlash,
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
if (!result.ok)
|
||||
process.exitCode = 1
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.stack || error.message : String(error)
|
||||
console.error(message)
|
||||
process.exitCode = 1
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user