e0a5f57793
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.
392 lines
11 KiB
JavaScript
392 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
// Validate narrow-width inline diff layout and hover behavior.
|
|
|
|
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 round(value) {
|
|
return Math.round(value * 1000) / 1000
|
|
}
|
|
|
|
function parseScenario(value) {
|
|
if (value === 'streaming') return 'streaming'
|
|
if (value === 'pierre-reference' || value === 'reference')
|
|
return 'pierre-reference'
|
|
return 'line-info-reference'
|
|
}
|
|
|
|
function parseUnchangedStyle(value) {
|
|
if (value === 'line-info-basic') return 'line-info-basic'
|
|
if (value === 'metadata') return 'metadata'
|
|
if (value === 'simple') return 'simple'
|
|
return 'line-info'
|
|
}
|
|
|
|
function parseWidths(values) {
|
|
const parsed = values
|
|
.map((value) => Number(value))
|
|
.filter((value) => Number.isFinite(value) && value >= 320)
|
|
return parsed.length > 0 ? parsed : [980, 760, 620, 520, 420]
|
|
}
|
|
|
|
async function forceFrameWidth(page, width) {
|
|
await page.evaluate((targetWidth) => {
|
|
const frame = document.querySelector('.line-info-compare-frame')
|
|
const card = document.querySelector('.editor-card')
|
|
const pageRoot = document.querySelector('.page')
|
|
if (frame instanceof HTMLElement) {
|
|
frame.style.width = `${targetWidth}px`
|
|
frame.style.maxWidth = `${targetWidth}px`
|
|
}
|
|
if (card instanceof HTMLElement) {
|
|
card.style.width = `${targetWidth}px`
|
|
card.style.maxWidth = `${targetWidth}px`
|
|
}
|
|
if (pageRoot instanceof HTMLElement) {
|
|
pageRoot.style.width = `${targetWidth}px`
|
|
pageRoot.style.maxWidth = `${targetWidth}px`
|
|
}
|
|
}, width)
|
|
}
|
|
|
|
async function hoverFirstVisibleDiff(page) {
|
|
const rect = await page.evaluate(() => {
|
|
const isVisible = (node) => {
|
|
if (!(node instanceof HTMLElement)) return false
|
|
const style = getComputedStyle(node)
|
|
if (style.display === 'none' || style.visibility === 'hidden')
|
|
return false
|
|
const bounds = node.getBoundingClientRect()
|
|
return bounds.width > 0 && bounds.height > 0
|
|
}
|
|
|
|
const selectors = [
|
|
'.editor.modified .char-insert',
|
|
'.editor.modified .char-delete',
|
|
'.editor.modified .inline-deleted-text',
|
|
'.editor.modified .line-insert',
|
|
'.editor.modified .line-delete',
|
|
'.editor.original .inline-deleted-text',
|
|
'.editor.original .line-delete',
|
|
]
|
|
|
|
for (const selector of selectors) {
|
|
const node = Array.from(document.querySelectorAll(selector)).find(
|
|
isVisible,
|
|
)
|
|
if (!(node instanceof HTMLElement)) continue
|
|
const bounds = node.getBoundingClientRect()
|
|
return {
|
|
x: bounds.left + Math.min(bounds.width * 0.5, 180),
|
|
y: bounds.top + bounds.height / 2,
|
|
}
|
|
}
|
|
|
|
return null
|
|
})
|
|
|
|
if (!rect) return false
|
|
await page.mouse.move(rect.x, rect.y)
|
|
await page.waitForTimeout(320)
|
|
return true
|
|
}
|
|
|
|
async function collectMetrics(page, options) {
|
|
const {
|
|
port,
|
|
scenario,
|
|
width,
|
|
style = 'background',
|
|
theme = 'snazzy-light',
|
|
lines = '6',
|
|
unchangedStyle = 'line-info',
|
|
} = options
|
|
const url = `http://127.0.0.1:${port}/diff-ux?style=${style}&scenario=${scenario}&theme=${encodeURIComponent(
|
|
theme,
|
|
)}&lines=${encodeURIComponent(lines)}&unchangedStyle=${encodeURIComponent(
|
|
unchangedStyle,
|
|
)}&capture=1`
|
|
|
|
await page.goto(url, { waitUntil: 'networkidle' })
|
|
await page.waitForSelector('.editor-card', { timeout: 20000 })
|
|
await forceFrameWidth(page, width)
|
|
await page.waitForTimeout(1400)
|
|
await hoverFirstVisibleDiff(page)
|
|
|
|
const screenshotPath = path.join(
|
|
'/tmp',
|
|
`stream-monaco-inline-${scenario}-${unchangedStyle}-${width}.png`,
|
|
)
|
|
await page
|
|
.locator(
|
|
scenario === 'line-info-reference'
|
|
? '.line-info-compare-frame'
|
|
: '.editor-card',
|
|
)
|
|
.screenshot({ path: screenshotPath })
|
|
|
|
const metrics = await page.evaluate(() => {
|
|
const isVisible = (node) => {
|
|
if (!(node instanceof HTMLElement)) return false
|
|
const style = getComputedStyle(node)
|
|
if (style.display === 'none' || style.visibility === 'hidden')
|
|
return false
|
|
const rect = node.getBoundingClientRect()
|
|
return rect.width > 0 && rect.height > 0
|
|
}
|
|
|
|
const rectData = (node) => {
|
|
if (!(node instanceof HTMLElement)) return null
|
|
const rect = node.getBoundingClientRect()
|
|
return {
|
|
left: rect.left,
|
|
right: rect.right,
|
|
top: rect.top,
|
|
bottom: rect.bottom,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
}
|
|
}
|
|
|
|
const countVisible = (selector) =>
|
|
Array.from(document.querySelectorAll(selector)).filter(isVisible).length
|
|
|
|
const frame =
|
|
document.querySelector('.line-info-compare-frame') ??
|
|
document.querySelector('.editor-card')
|
|
const card = document.querySelector('.editor-card')
|
|
const tabs = document.querySelector('.line-info-tabs')
|
|
const fileStats = document.querySelector('.file-stats')
|
|
const diffRoot = document.querySelector('.monaco-diff-editor')
|
|
const original = document.querySelector('.editor.original')
|
|
const modified = document.querySelector('.editor.modified')
|
|
|
|
return {
|
|
diffClass: diffRoot?.className || '',
|
|
frame: rectData(frame),
|
|
card: rectData(card),
|
|
tabs: rectData(tabs),
|
|
fileStats: rectData(fileStats),
|
|
original: rectData(original),
|
|
modified: rectData(modified),
|
|
originalCenters: countVisible(
|
|
'.editor.original .diff-hidden-lines .center',
|
|
),
|
|
originalWidgets: countVisible(
|
|
'.editor.original .diff-hidden-lines-widget, .editor.original .fold-unchanged',
|
|
),
|
|
bridgeCount: countVisible('.stream-monaco-diff-unchanged-bridge'),
|
|
paneDividerCount: countVisible('.stream-monaco-unchanged-pane-divider'),
|
|
deletedMarginCount: countVisible('.inline-deleted-margin-view-zone'),
|
|
tooltipCount: countVisible(
|
|
'.monaco-hover, .monaco-editor-hover, .hover-widget, [role="tooltip"]',
|
|
),
|
|
visibleHunkActionCount: countVisible('.stream-monaco-diff-hunk-actions'),
|
|
rootClasses:
|
|
document.querySelector('.editor')?.className ||
|
|
document.querySelector('.editor-card')?.className ||
|
|
'',
|
|
}
|
|
})
|
|
|
|
const frameWidth = round(metrics.frame?.width ?? width)
|
|
const cardWidth = round(metrics.card?.width ?? 0)
|
|
const tabsWidth = round(metrics.tabs?.width ?? 0)
|
|
const fileStatsRight = round(metrics.fileStats?.right ?? 0)
|
|
const cardRight = round(metrics.card?.right ?? 0)
|
|
const cardOverflow = round(Math.max(0, cardWidth - frameWidth))
|
|
const tabsOverflow = round(
|
|
Math.max(0, (metrics.tabs?.right ?? 0) - (metrics.frame?.right ?? 0)),
|
|
)
|
|
const fileStatsOverflow = round(Math.max(0, fileStatsRight - cardRight))
|
|
const inlineMode =
|
|
!metrics.diffClass.includes('side-by-side') ||
|
|
(metrics.original?.width ?? 0) < 24
|
|
|
|
let ok = cardOverflow <= 1 && tabsOverflow <= 1 && fileStatsOverflow <= 1
|
|
if (inlineMode) {
|
|
ok =
|
|
ok &&
|
|
metrics.originalCenters === 0 &&
|
|
metrics.originalWidgets === 0 &&
|
|
metrics.bridgeCount === 0 &&
|
|
metrics.paneDividerCount === 0 &&
|
|
metrics.tooltipCount <= 1 &&
|
|
metrics.visibleHunkActionCount <= 1
|
|
}
|
|
|
|
return {
|
|
ok,
|
|
width,
|
|
inlineMode,
|
|
screenshotPath,
|
|
frameWidth,
|
|
cardWidth,
|
|
tabsWidth,
|
|
cardOverflow,
|
|
tabsOverflow,
|
|
fileStatsOverflow,
|
|
diffClass: metrics.diffClass,
|
|
originalWidth: round(metrics.original?.width ?? 0),
|
|
modifiedWidth: round(metrics.modified?.width ?? 0),
|
|
originalCenters: metrics.originalCenters,
|
|
originalWidgets: metrics.originalWidgets,
|
|
bridgeCount: metrics.bridgeCount,
|
|
paneDividerCount: metrics.paneDividerCount,
|
|
deletedMarginCount: metrics.deletedMarginCount,
|
|
tooltipCount: metrics.tooltipCount,
|
|
visibleHunkActionCount: metrics.visibleHunkActionCount,
|
|
}
|
|
}
|
|
|
|
async function run() {
|
|
const scenario = parseScenario(process.argv[2])
|
|
const unchangedStyle = parseUnchangedStyle(process.argv[3])
|
|
const widths = parseWidths(process.argv.slice(4))
|
|
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: 1600, height: 1700 },
|
|
deviceScaleFactor: 2,
|
|
})
|
|
|
|
const results = []
|
|
for (const width of widths) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
results.push(
|
|
await collectMetrics(page, {
|
|
port,
|
|
scenario,
|
|
width,
|
|
unchangedStyle,
|
|
}),
|
|
)
|
|
}
|
|
|
|
await browser.close()
|
|
|
|
const ok = results.every((result) => result.ok)
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
ok,
|
|
scenario,
|
|
unchangedStyle,
|
|
widths,
|
|
results,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
|
|
if (!ok) process.exitCode = 1
|
|
} catch (error) {
|
|
console.error(
|
|
JSON.stringify(
|
|
{
|
|
ok: false,
|
|
scenario,
|
|
unchangedStyle,
|
|
widths,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
logs: logs.slice(-40),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
process.exitCode = 1
|
|
} finally {
|
|
onExit()
|
|
}
|
|
}
|
|
|
|
run()
|