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,257 @@
|
||||
#!/usr/bin/env node
|
||||
// Validate that expanded unchanged regions disappear immediately and stay gone while scrolling.
|
||||
|
||||
import net from 'node:net'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
|
||||
|
||||
function isPortOpen(port, host = '127.0.0.1') {
|
||||
return new Promise((resolve) => {
|
||||
const sock = net.createConnection({ host, port })
|
||||
sock.on('connect', () => {
|
||||
sock.end()
|
||||
resolve(true)
|
||||
})
|
||||
sock.on('error', () => {
|
||||
sock.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function findFreePort(start = 4205, end = 4215) {
|
||||
for (let port = start; port <= end; port++) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
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) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const open = await isPortOpen(port)
|
||||
if (open) return
|
||||
if (Date.now() - start > ms)
|
||||
throw new Error(`Timed out waiting for port ${port}`)
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => setTimeout(r, 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?.()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const targetLabel = process.argv[2] || '71 unmodified lines'
|
||||
const scenario = process.argv[3] || 'streaming'
|
||||
const style = process.argv[4] || 'background'
|
||||
const theme = process.argv[5] || 'snazzy-light'
|
||||
const unchangedStyle = process.argv[6] || 'line-info'
|
||||
|
||||
const port = await findFreePort()
|
||||
const vite = spawn(
|
||||
process.execPath,
|
||||
['./node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', String(port)],
|
||||
{
|
||||
cwd: demoDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
const logs = []
|
||||
vite.stdout.on('data', (data) => logs.push(String(data)))
|
||||
vite.stderr.on('data', (data) => logs.push(String(data)))
|
||||
const onExit = () => killProcessTree(vite)
|
||||
process.on('exit', onExit)
|
||||
process.on('SIGINT', () => process.exit(130))
|
||||
process.on('SIGTERM', () => process.exit(143))
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 1465, height: 1100 } })
|
||||
const url = `http://127.0.0.1:${port}/diff-ux?style=${style}&scenario=${scenario}&theme=${encodeURIComponent(
|
||||
theme,
|
||||
)}&unchangedStyle=${encodeURIComponent(unchangedStyle)}`
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
await page.waitForSelector(
|
||||
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge',
|
||||
{ timeout: 20000 },
|
||||
)
|
||||
|
||||
const visibleTextsBefore = await page
|
||||
.locator(
|
||||
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge:not([hidden]) .stream-monaco-unchanged-summary',
|
||||
)
|
||||
.allTextContents()
|
||||
|
||||
await page
|
||||
.locator('.stream-monaco-diff-unchanged-overlay .stream-monaco-unchanged-summary')
|
||||
.filter({ hasText: targetLabel })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
await page.waitForTimeout(20)
|
||||
|
||||
const immediateScrollTarget = page
|
||||
.locator('.stream-monaco-diff-unchanged-overlay .stream-monaco-unchanged-summary')
|
||||
.filter({ hasText: '66 unmodified lines' })
|
||||
.first()
|
||||
|
||||
if (await immediateScrollTarget.count()) {
|
||||
const box = await immediateScrollTarget.boundingBox()
|
||||
if (box) {
|
||||
await page.mouse.move(
|
||||
box.x + box.width * 0.5,
|
||||
box.y + box.height * 0.5,
|
||||
)
|
||||
await page.mouse.wheel(0, 180)
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(180)
|
||||
|
||||
const afterClick = await page.evaluate((label) => {
|
||||
const visibleTexts = Array.from(
|
||||
document.querySelectorAll(
|
||||
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge:not([hidden]) .stream-monaco-unchanged-summary',
|
||||
),
|
||||
).map((node) => node.textContent?.trim())
|
||||
const visibleTarget = visibleTexts.includes(label)
|
||||
const centerTarget = Array.from(
|
||||
document.querySelectorAll('.diff-hidden-lines .center'),
|
||||
).some((node) => node.textContent?.trim() === label)
|
||||
return { visibleTexts, visibleTarget, centerTarget }
|
||||
}, targetLabel)
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.mouse.wheel(0, 120)
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.waitForTimeout(100)
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
const root = document.querySelector('.stream-monaco-diff-root')
|
||||
if (root instanceof HTMLElement && root.scrollHeight > root.clientHeight) {
|
||||
root.scrollTop = Math.min(root.scrollTop + 160, root.scrollHeight)
|
||||
root.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||
}
|
||||
|
||||
const scrollables = Array.from(
|
||||
document.querySelectorAll('.monaco-scrollable-element.editor-scrollable'),
|
||||
)
|
||||
scrollables.forEach((node) => {
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
node.scrollTop = 1200
|
||||
node.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||
})
|
||||
})
|
||||
|
||||
await page.waitForTimeout(40)
|
||||
|
||||
const afterScroll = await page.evaluate((label) => {
|
||||
const visibleTexts = Array.from(
|
||||
document.querySelectorAll(
|
||||
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge:not([hidden]) .stream-monaco-unchanged-summary',
|
||||
),
|
||||
).map((node) => node.textContent?.trim())
|
||||
|
||||
const bridgeNodes = Array.from(
|
||||
document.querySelectorAll(
|
||||
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge:not([hidden])',
|
||||
),
|
||||
)
|
||||
const centerNodes = Array.from(
|
||||
document.querySelectorAll('.diff-hidden-lines .center'),
|
||||
)
|
||||
|
||||
const measurements = visibleTexts
|
||||
.filter(Boolean)
|
||||
.map((text) => {
|
||||
const bridge = bridgeNodes.find(
|
||||
node => node.textContent?.trim() === text,
|
||||
)
|
||||
const center = centerNodes.find(
|
||||
node => node.textContent?.trim() === text,
|
||||
)
|
||||
const bridgeRect = bridge?.getBoundingClientRect()
|
||||
const centerRect = center?.getBoundingClientRect()
|
||||
return {
|
||||
text,
|
||||
hasCenter: Boolean(center),
|
||||
deltaTop:
|
||||
bridgeRect && centerRect
|
||||
? +(bridgeRect.top - centerRect.top).toFixed(2)
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
visibleTexts,
|
||||
visibleTarget: visibleTexts.includes(label),
|
||||
bridgeCount: document.querySelectorAll(
|
||||
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge:not([hidden])',
|
||||
).length,
|
||||
measurements,
|
||||
}
|
||||
}, targetLabel)
|
||||
|
||||
const ok =
|
||||
visibleTextsBefore.includes(targetLabel) &&
|
||||
afterClick.visibleTarget === false &&
|
||||
afterClick.centerTarget === false &&
|
||||
afterScroll.visibleTarget === false &&
|
||||
afterScroll.measurements.every(
|
||||
item => item.hasCenter && item.deltaTop != null && Math.abs(item.deltaTop) <= 12,
|
||||
)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok,
|
||||
targetLabel,
|
||||
visibleTextsBefore,
|
||||
afterClick,
|
||||
afterScroll,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
await browser.close()
|
||||
killProcessTree(vite)
|
||||
process.exit(ok ? 0 : 1)
|
||||
}
|
||||
catch (error) {
|
||||
killProcessTree(vite)
|
||||
if (logs.length > 0) console.error(logs.join(''))
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user