Files
BoosAPI/desktop/libs/stream-monaco/scripts/playwright-diff-gutter-validate.mjs
T
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

273 lines
7.8 KiB
JavaScript

#!/usr/bin/env node
// Validate reference diff gutter spacing across multiple line-count scales.
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 = 5181, end = 5190) {
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?.()
}
function parseTargets(args) {
const values = args
.map((arg) => Number(arg))
.filter((value) => Number.isInteger(value) && value >= 6)
return values.length > 0 ? values : [6, 48, 512, 4096]
}
function round(value) {
return Math.round(value * 1000) / 1000
}
async function collectMetrics(page, lines, port) {
const url = `http://127.0.0.1:${port}/diff-ux?style=background&scenario=pierre-reference&theme=snazzy-light&lines=${encodeURIComponent(
String(lines),
)}&capture=1`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.editor-card', { timeout: 20000 })
await page.waitForTimeout(1400)
const screenshotPath = path.join('/tmp', `stream-monaco-diff-gutter-${lines}.png`)
await page.locator('.editor-card').screenshot({ path: screenshotPath })
const metrics = await page.evaluate(() => {
const getTextRect = (el) => {
const range = document.createRange()
range.selectNodeContents(el)
const rect = range.getBoundingClientRect()
return { left: rect.left, right: rect.right, width: rect.width }
}
const firstVisibleTokenRect = (line) => {
const spans = Array.from(line.querySelectorAll('span'))
for (const span of spans) {
const text = span.textContent || ''
if (!text.trim()) continue
const rect = getTextRect(span)
if (rect.width > 0) return rect
}
return null
}
const root = document.querySelector('.editor')
const markerWidth =
Number.parseFloat(
getComputedStyle(root ?? document.body).getPropertyValue(
'--stream-monaco-gutter-marker-width',
),
) || 4
const getPaneMetrics = (rootSelector) => {
const pane = document.querySelector(rootSelector)
const margin = pane?.querySelector('.margin')
const lineNumber = pane?.querySelector('.line-numbers')
const viewLine = pane?.querySelector('.view-lines .view-line')
if (!margin || !lineNumber || !viewLine) return null
const marginRect = margin.getBoundingClientRect()
const lineNumberBoxRect = lineNumber.getBoundingClientRect()
const lineNumberTextRect = getTextRect(lineNumber)
const tokenRect = firstVisibleTokenRect(viewLine)
if (!tokenRect) return null
return {
marginWidth: marginRect.width,
numberBoxWidth: lineNumberBoxRect.width,
numberTextWidth: lineNumberTextRect.width,
visibleLineNumber: lineNumber.textContent?.trim() ?? '',
boxLeftGap: lineNumberBoxRect.left - marginRect.left,
boxRightGap: tokenRect.left - lineNumberBoxRect.right,
textStripeGap: lineNumberTextRect.left - marginRect.left - markerWidth,
textCodeGap: tokenRect.left - lineNumberTextRect.right,
}
}
return {
original: getPaneMetrics('.editor.original'),
modified: getPaneMetrics('.editor.modified'),
}
})
const original = metrics.original
const modified = metrics.modified
if (!original || !modified) {
return {
ok: false,
lines,
screenshotPath,
reason: 'Unable to resolve pane metrics',
}
}
const summary = {
lines,
screenshotPath,
original: {
marginWidth: round(original.marginWidth),
numberBoxWidth: round(original.numberBoxWidth),
visibleLineNumber: original.visibleLineNumber,
boxLeftGap: round(original.boxLeftGap),
boxRightGap: round(original.boxRightGap),
textStripeGap: round(original.textStripeGap),
textCodeGap: round(original.textCodeGap),
symmetryDelta: round(Math.abs(original.textStripeGap - original.textCodeGap)),
},
modified: {
marginWidth: round(modified.marginWidth),
numberBoxWidth: round(modified.numberBoxWidth),
visibleLineNumber: modified.visibleLineNumber,
boxLeftGap: round(modified.boxLeftGap),
boxRightGap: round(modified.boxRightGap),
textStripeGap: round(modified.textStripeGap),
textCodeGap: round(modified.textCodeGap),
symmetryDelta: round(Math.abs(modified.textStripeGap - modified.textCodeGap)),
},
}
const marginWidthDelta = Math.abs(
summary.original.marginWidth - summary.modified.marginWidth,
)
const symmetryDelta = Math.max(
summary.original.symmetryDelta,
summary.modified.symmetryDelta,
)
const paneDelta = Math.max(
Math.abs(summary.original.textStripeGap - summary.modified.textStripeGap),
Math.abs(summary.original.textCodeGap - summary.modified.textCodeGap),
)
return {
...summary,
ok: marginWidthDelta < 0.5 && symmetryDelta < 0.5 && paneDelta < 0.5,
marginWidthDelta: round(marginWidthDelta),
paneDelta: round(paneDelta),
}
}
async function run() {
const targets = parseTargets(process.argv.slice(2))
const port = process.env.PORT
? Number(process.env.PORT)
: await findFreePort()
const vite = spawn(
'pnpm',
[
'-C',
demoDir,
'dev',
'--host',
'127.0.0.1',
'--port',
String(port),
'--strictPort',
],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
)
const logs = []
vite.stdout.on('data', (d) => logs.push(String(d)))
vite.stderr.on('data', (d) => logs.push(String(d)))
const onExit = () => killProcessTree(vite)
process.on('SIGINT', onExit)
process.on('SIGTERM', onExit)
process.on('exit', onExit)
try {
await waitForPort(port)
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({
viewport: { width: 1960, height: 720 },
deviceScaleFactor: 2,
})
const results = []
for (const lines of targets) {
// eslint-disable-next-line no-await-in-loop
results.push(await collectMetrics(page, lines, port))
}
await browser.close()
const ok = results.every((result) => result.ok)
console.log(
JSON.stringify(
{
ok,
targets,
results,
},
null,
2,
),
)
if (!ok) process.exitCode = 1
} catch (error) {
console.error('Diff gutter validation failed.')
console.error(logs.slice(-40).join(''))
throw error
} finally {
killProcessTree(vite)
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})