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
|
||||
// Validate that unchanged-region rail arrows reveal only the targeted region
|
||||
// and reveal exactly five lines in the expected direction.
|
||||
|
||||
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(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 parseCount(label) {
|
||||
const match = label.match(/\d+/)
|
||||
return match ? Number.parseInt(match[0], 10) : Number.NaN
|
||||
}
|
||||
|
||||
function formatCountLabel(count) {
|
||||
return `${count} unmodified ${count === 1 ? 'line' : 'lines'}`
|
||||
}
|
||||
|
||||
function buildExpectedCounts(beforeLabels, targetLabel, delta) {
|
||||
const counts = new Map()
|
||||
beforeLabels.forEach((label) => {
|
||||
counts.set(label, (counts.get(label) || 0) + 1)
|
||||
})
|
||||
counts.set(targetLabel, (counts.get(targetLabel) || 0) - 1)
|
||||
if (counts.get(targetLabel) === 0) counts.delete(targetLabel)
|
||||
const nextLabel = formatCountLabel(parseCount(targetLabel) - delta)
|
||||
counts.set(nextLabel, (counts.get(nextLabel) || 0) + 1)
|
||||
return Object.fromEntries(Array.from(counts.entries()).sort())
|
||||
}
|
||||
|
||||
async function collectState(page, targetLabel) {
|
||||
return page.evaluate((label) => {
|
||||
const bridgeSelector =
|
||||
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge:not([hidden])'
|
||||
const bridges = Array.from(document.querySelectorAll(bridgeSelector))
|
||||
const modifiedEditor =
|
||||
document.querySelector('.editor.modified') ||
|
||||
document.querySelector('.monaco-editor.modified') ||
|
||||
document.querySelector('.monaco-editor')
|
||||
|
||||
const findNeighbors = (bridge) => {
|
||||
const rect = bridge.getBoundingClientRect()
|
||||
let previousVisibleLine = null
|
||||
let nextVisibleLine = null
|
||||
const lineNumbers = modifiedEditor?.querySelectorAll('.line-numbers') || []
|
||||
lineNumbers.forEach((node) => {
|
||||
const lineNumber = Number.parseInt(node.textContent?.trim() || '', 10)
|
||||
if (!Number.isFinite(lineNumber)) return
|
||||
const top = node.getBoundingClientRect().top
|
||||
if (top < rect.top - 1) {
|
||||
previousVisibleLine =
|
||||
previousVisibleLine == null
|
||||
? lineNumber
|
||||
: Math.max(previousVisibleLine, lineNumber)
|
||||
}
|
||||
else if (top > rect.bottom + 1) {
|
||||
nextVisibleLine =
|
||||
nextVisibleLine == null
|
||||
? lineNumber
|
||||
: Math.min(nextVisibleLine, lineNumber)
|
||||
}
|
||||
})
|
||||
return { previousVisibleLine, nextVisibleLine }
|
||||
}
|
||||
|
||||
const items = bridges.map((bridge) => {
|
||||
const summary = bridge.querySelector('.stream-monaco-unchanged-summary')
|
||||
const labelText = summary?.textContent?.trim() || ''
|
||||
const rect = bridge.getBoundingClientRect()
|
||||
const { previousVisibleLine, nextVisibleLine } = findNeighbors(bridge)
|
||||
return {
|
||||
label: labelText,
|
||||
top: +rect.top.toFixed(2),
|
||||
previousVisibleLine,
|
||||
nextVisibleLine,
|
||||
}
|
||||
})
|
||||
|
||||
const counts = {}
|
||||
items.forEach((item) => {
|
||||
counts[item.label] = (counts[item.label] || 0) + 1
|
||||
})
|
||||
|
||||
const target = items.find(item => item.label === label) || null
|
||||
return {
|
||||
items,
|
||||
labels: items.map(item => item.label),
|
||||
counts,
|
||||
target,
|
||||
}
|
||||
}, targetLabel)
|
||||
}
|
||||
|
||||
async function runDirectionCase(page, targetLabel, direction) {
|
||||
const before = await collectState(page, targetLabel)
|
||||
if (!before.target)
|
||||
throw new Error(`Could not find target unchanged block: ${targetLabel}`)
|
||||
|
||||
const button = page
|
||||
.locator('.stream-monaco-diff-unchanged-bridge:not([hidden])')
|
||||
.filter({ has: page.locator('.stream-monaco-unchanged-summary', { hasText: targetLabel }) })
|
||||
.locator(`.stream-monaco-unchanged-reveal[data-direction="${direction}"]`)
|
||||
.first()
|
||||
|
||||
await button.click()
|
||||
await page.waitForTimeout(350)
|
||||
|
||||
const after = await collectState(page, targetLabel)
|
||||
const revealCount = 5
|
||||
const targetAfterLabel = formatCountLabel(
|
||||
parseCount(targetLabel) - revealCount,
|
||||
)
|
||||
const expectedCounts = buildExpectedCounts(
|
||||
before.labels,
|
||||
targetLabel,
|
||||
revealCount,
|
||||
)
|
||||
|
||||
const expectedPreviousVisibleLine =
|
||||
direction === 'down'
|
||||
? before.target.previousVisibleLine + revealCount
|
||||
: before.target.previousVisibleLine
|
||||
const expectedNextVisibleLine =
|
||||
direction === 'up'
|
||||
? before.target.nextVisibleLine - revealCount
|
||||
: before.target.nextVisibleLine
|
||||
|
||||
const matchingTarget = after.items.find(
|
||||
item =>
|
||||
item.label === targetAfterLabel &&
|
||||
item.previousVisibleLine === expectedPreviousVisibleLine &&
|
||||
item.nextVisibleLine === expectedNextVisibleLine,
|
||||
)
|
||||
|
||||
const normalizedAfterCounts = Object.fromEntries(
|
||||
Object.entries(after.counts).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
)
|
||||
|
||||
const ok =
|
||||
before.target.previousVisibleLine != null &&
|
||||
before.target.nextVisibleLine != null &&
|
||||
Boolean(matchingTarget) &&
|
||||
JSON.stringify(normalizedAfterCounts) === JSON.stringify(expectedCounts)
|
||||
|
||||
return {
|
||||
ok,
|
||||
direction,
|
||||
targetLabel,
|
||||
targetAfterLabel,
|
||||
before,
|
||||
after,
|
||||
expectedCounts,
|
||||
expectedPreviousVisibleLine,
|
||||
expectedNextVisibleLine,
|
||||
matchingTarget,
|
||||
}
|
||||
}
|
||||
|
||||
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 runCase = async (direction) => {
|
||||
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 result = await runDirectionCase(page, targetLabel, direction)
|
||||
await page.close()
|
||||
return result
|
||||
}
|
||||
|
||||
const down = await runCase('down')
|
||||
const up = await runCase('up')
|
||||
await browser.close()
|
||||
|
||||
const ok = down.ok && up.ok
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok,
|
||||
targetLabel,
|
||||
down,
|
||||
up,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
if (!ok) process.exitCode = 1
|
||||
}
|
||||
catch (error) {
|
||||
console.error(String(error))
|
||||
if (logs.length > 0) console.error(logs.join(''))
|
||||
process.exitCode = 1
|
||||
}
|
||||
finally {
|
||||
killProcessTree(vite)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user