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.
446 lines
15 KiB
JavaScript
446 lines
15 KiB
JavaScript
#!/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-core'
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
const repoRoot = path.resolve(__dirname, '..')
|
|
const playgroundDir = path.join(repoRoot, 'playground')
|
|
|
|
function isPortOpen(port, host = '127.0.0.1') {
|
|
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 = 4173, end = 4199) {
|
|
for (let port = start; port <= end; port++) {
|
|
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) {
|
|
const open = await isPortOpen(port)
|
|
if (open)
|
|
return
|
|
if (Date.now() - start > ms)
|
|
throw new Error(`Timed out waiting for port ${port}`)
|
|
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 countLines(text) {
|
|
return text.split(/\r?\n/).length
|
|
}
|
|
|
|
function buildScenario() {
|
|
const originalLines = [
|
|
'type Task = { id: string, status: \'todo\' | \'done\' }',
|
|
'',
|
|
'export async function synchronizeTasks(tasks: Task[]) {',
|
|
' const report: string[] = []',
|
|
String.raw` report.push(\`input=\${tasks.length}\`)`,
|
|
'',
|
|
]
|
|
|
|
for (let i = 1; i <= 160; i++) {
|
|
if (i % 20 === 0)
|
|
originalLines.push(` // stable checkpoint ${i}`)
|
|
const n = String(i).padStart(3, '0')
|
|
originalLines.push(` report.push('stable-${n}')`)
|
|
}
|
|
|
|
originalLines.push('', ' return report', '}')
|
|
|
|
const modifiedLines = [...originalLines]
|
|
|
|
const optimized = modifiedLines.findIndex(line => line.includes('stable-018'))
|
|
if (optimized >= 0) {
|
|
modifiedLines[optimized] = ` report.push('stable-018-optimized')`
|
|
modifiedLines[optimized + 1] = ` report.push('stable-019-optimized')`
|
|
modifiedLines[optimized + 2] = ` report.push('stable-020-optimized')`
|
|
}
|
|
|
|
const hotfix = modifiedLines.findIndex(line => line.includes('stable-086'))
|
|
if (hotfix >= 0) {
|
|
modifiedLines[hotfix - 1] = ` report.push('stable-085-hotfix')`
|
|
modifiedLines[hotfix] = ` report.push('stable-086-hotfix')`
|
|
modifiedLines[hotfix + 1] = ` report.push('stable-087-hotfix')`
|
|
}
|
|
|
|
const cache = modifiedLines.findIndex(line => line.includes('stable-132'))
|
|
if (cache >= 0) {
|
|
modifiedLines[cache - 1] = ` report.push('stable-131-cache-hit')`
|
|
modifiedLines[cache] = ` report.push('stable-132-cache-hit')`
|
|
modifiedLines[cache + 1] = ` report.push('stable-133-cache-miss')`
|
|
}
|
|
|
|
const returnLine = modifiedLines.findIndex(line => line.trim() === 'return report')
|
|
if (returnLine >= 0)
|
|
modifiedLines[returnLine] = ' return report.filter(Boolean)'
|
|
|
|
return {
|
|
original: originalLines.join('\n'),
|
|
modified: modifiedLines.join('\n'),
|
|
}
|
|
}
|
|
|
|
function buildDiffMarkdown(pair) {
|
|
const original = pair.original.split(/\r?\n/)
|
|
const modified = pair.modified.split(/\r?\n/)
|
|
const max = Math.max(original.length, modified.length)
|
|
const diffLines = []
|
|
|
|
for (let i = 0; i < max; i++) {
|
|
const left = original[i]
|
|
const right = modified[i]
|
|
|
|
if (left === right) {
|
|
diffLines.push(left ?? '')
|
|
continue
|
|
}
|
|
if (left != null)
|
|
diffLines.push(`-${left}`)
|
|
if (right != null)
|
|
diffLines.push(`+${right}`)
|
|
}
|
|
|
|
return [
|
|
'# Diff folding fixture',
|
|
'',
|
|
'This page is used by the Playwright smoke test.',
|
|
'',
|
|
'```diff ts:synchronizeTasks.ts',
|
|
...diffLines,
|
|
'```',
|
|
'',
|
|
].join('\n')
|
|
}
|
|
|
|
function estimateChangedLines(pair) {
|
|
const original = pair.original.split(/\r?\n/)
|
|
const modified = pair.modified.split(/\r?\n/)
|
|
const max = Math.max(original.length, modified.length)
|
|
let changed = 0
|
|
for (let i = 0; i < max; i++) {
|
|
if ((original[i] ?? '') !== (modified[i] ?? ''))
|
|
changed++
|
|
}
|
|
return changed
|
|
}
|
|
|
|
function resolveChromeLaunchOptions() {
|
|
const candidates = [
|
|
process.env.PLAYWRIGHT_CHROME_PATH,
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
'/usr/bin/google-chrome',
|
|
'/usr/bin/chromium',
|
|
'/usr/bin/chromium-browser',
|
|
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
].filter(Boolean)
|
|
|
|
for (const candidate of candidates) {
|
|
if (existsSync(candidate)) {
|
|
return {
|
|
executablePath: candidate,
|
|
headless: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
channel: 'chrome',
|
|
headless: true,
|
|
}
|
|
}
|
|
|
|
async function waitForHiddenRegions(page, timeout = 20000) {
|
|
await page.waitForFunction(() => {
|
|
const hiddenTexts = Array.from(document.querySelectorAll('.diff-hidden-lines'))
|
|
.map(el => (el.textContent ?? '').trim())
|
|
.filter(Boolean)
|
|
return hiddenTexts.some(text => /hidden lines|unchanged|unmodified/i.test(text))
|
|
}, { timeout })
|
|
}
|
|
|
|
async function collectResult(page, name, extra = {}) {
|
|
return page.evaluate(({ name, extra }) => {
|
|
const isVisibleButton = (element) => {
|
|
if (!(element instanceof HTMLElement))
|
|
return false
|
|
const rect = element.getBoundingClientRect()
|
|
const style = window.getComputedStyle(element)
|
|
return (
|
|
rect.width >= 16
|
|
&& rect.height >= 16
|
|
&& style.display !== 'none'
|
|
&& style.visibility !== 'hidden'
|
|
&& Number.parseFloat(style.opacity || '1') > 0.01
|
|
)
|
|
}
|
|
const roundPx = (value) => {
|
|
return Math.round(value * 100) / 100
|
|
}
|
|
const parsePx = (value) => {
|
|
const n = Number.parseFloat(String(value ?? ''))
|
|
return Number.isFinite(n) ? n : null
|
|
}
|
|
const diffEditors = Array.from(document.querySelectorAll('.monaco-diff-editor'))
|
|
const hiddenTexts = Array.from(document.querySelectorAll('.diff-hidden-lines'))
|
|
.map(el => (el.textContent ?? '').trim())
|
|
.filter(Boolean)
|
|
|
|
const originalVisibleLines = document.querySelectorAll('.editor.original .view-line').length
|
|
const modifiedVisibleLines = document.querySelectorAll('.editor.modified .view-line').length
|
|
const bodyText = document.body.textContent ?? ''
|
|
const diffEditorMetrics = diffEditors.map((editorEl, index) => {
|
|
const editorRect = editorEl.getBoundingClientRect()
|
|
const host = editorEl.closest('.code-editor-container')
|
|
const hostStyle = host instanceof HTMLElement ? window.getComputedStyle(host) : null
|
|
const editorHiddenTexts = Array.from(editorEl.querySelectorAll('.diff-hidden-lines'))
|
|
.map(el => (el.textContent ?? '').trim())
|
|
.filter(Boolean)
|
|
const foldButtonCandidates = Array.from(
|
|
editorEl.querySelectorAll('.markstream-inline-fold-proxy, .diff-hidden-lines a'),
|
|
)
|
|
const foldButton
|
|
= foldButtonCandidates.find(candidate => isVisibleButton(candidate))
|
|
?? foldButtonCandidates[0]
|
|
?? null
|
|
const foldButtonRect = foldButton?.getBoundingClientRect?.() ?? null
|
|
const hostHeightPx = host instanceof HTMLElement ? roundPx(host.getBoundingClientRect().height) : null
|
|
const hostMaxHeightPx = parsePx(hostStyle?.maxHeight)
|
|
const hostOverflow = hostStyle?.overflow ?? null
|
|
|
|
return {
|
|
index,
|
|
isSideBySide: editorEl.classList.contains('side-by-side'),
|
|
foldButtonKind: foldButton?.classList.contains('markstream-inline-fold-proxy') ? 'proxy' : (foldButton ? 'native' : null),
|
|
hiddenRegionTexts: editorHiddenTexts,
|
|
hostHeightPx,
|
|
hostInlineHeight: host instanceof HTMLElement ? host.style.height : '',
|
|
hostMaxHeightPx,
|
|
hostInlineMaxHeight: host instanceof HTMLElement ? host.style.maxHeight : '',
|
|
hostOverflow,
|
|
hostOverflowY: hostStyle?.overflowY ?? null,
|
|
editorHeightPx: roundPx(editorEl.getBoundingClientRect().height),
|
|
foldButtonRect: foldButtonRect
|
|
? {
|
|
x: roundPx(foldButtonRect.x),
|
|
y: roundPx(foldButtonRect.y),
|
|
width: roundPx(foldButtonRect.width),
|
|
height: roundPx(foldButtonRect.height),
|
|
}
|
|
: null,
|
|
foldButtonVisible: !!(foldButtonRect
|
|
&& foldButtonRect.width >= 16
|
|
&& foldButtonRect.height >= 16
|
|
&& foldButtonRect.left >= editorRect.left - 1
|
|
&& foldButtonRect.right <= editorRect.right + 1
|
|
&& foldButtonRect.top >= editorRect.top - 1
|
|
&& foldButtonRect.bottom <= editorRect.bottom + 1),
|
|
hostShrunkAfterFolding: editorHiddenTexts.length > 0
|
|
? !!(hostHeightPx != null && hostMaxHeightPx != null && hostHeightPx + 1 < hostMaxHeightPx && hostOverflow !== 'auto')
|
|
: null,
|
|
}
|
|
})
|
|
const foldedMetrics = diffEditorMetrics.filter(metric => metric.hiddenRegionTexts.length > 0)
|
|
const inlineMetrics = foldedMetrics.filter(metric => metric.isSideBySide === false)
|
|
const foldedHostShrinks = foldedMetrics.length > 0 && foldedMetrics.every(metric => metric.hostShrunkAfterFolding === true)
|
|
const inlineFoldButtonVisible = inlineMetrics.length > 0 && inlineMetrics.every(metric => metric.foldButtonVisible === true)
|
|
|
|
return {
|
|
name,
|
|
...extra,
|
|
diffEditors: diffEditors.length,
|
|
hiddenRegionCount: hiddenTexts.length,
|
|
hiddenRegionTexts: hiddenTexts,
|
|
hasHiddenRegionText: hiddenTexts.some(text => /hidden lines|unchanged|unmodified/i.test(text)),
|
|
bodyContainsHiddenLinesText: /hidden lines|unchanged|unmodified/i.test(bodyText),
|
|
originalVisibleLines,
|
|
modifiedVisibleLines,
|
|
foldedHostShrinks,
|
|
inlineFoldButtonVisible,
|
|
diffEditorMetrics,
|
|
}
|
|
}, { name, extra })
|
|
}
|
|
|
|
async function runControlledScenario(context, baseUrl) {
|
|
const pair = buildScenario()
|
|
const markdown = buildDiffMarkdown(pair)
|
|
const page = await context.newPage()
|
|
const url = `${baseUrl}/test#data=raw:${encodeURIComponent(markdown)}`
|
|
|
|
await page.goto(url, { waitUntil: 'networkidle' })
|
|
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
|
|
await waitForHiddenRegions(page)
|
|
await page.waitForTimeout(500)
|
|
|
|
const result = await collectResult(page, 'test-route', {
|
|
url,
|
|
originalLineCount: countLines(pair.original),
|
|
modifiedLineCount: countLines(pair.modified),
|
|
changedLineEstimate: estimateChangedLines(pair),
|
|
})
|
|
|
|
await page.close()
|
|
return result
|
|
}
|
|
|
|
async function runHomeScenario(context, baseUrl) {
|
|
const page = await context.newPage()
|
|
const url = `${baseUrl}/`
|
|
const diffEditor = page.locator('.monaco-diff-editor').last()
|
|
|
|
await page.goto(url, { waitUntil: 'networkidle' })
|
|
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
|
|
await diffEditor.scrollIntoViewIfNeeded()
|
|
await page.waitForTimeout(500)
|
|
await waitForHiddenRegions(page, 30000)
|
|
await page.waitForTimeout(800)
|
|
|
|
const result = await collectResult(page, 'index-route', { url })
|
|
await page.close()
|
|
return result
|
|
}
|
|
|
|
async function runHomeInlineScenario(context, baseUrl) {
|
|
const page = await context.newPage()
|
|
const url = `${baseUrl}/`
|
|
const diffEditor = page.locator('.monaco-diff-editor').last()
|
|
|
|
await page.setViewportSize({ width: 430, height: 900 })
|
|
await page.goto(url, { waitUntil: 'networkidle' })
|
|
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
|
|
await diffEditor.scrollIntoViewIfNeeded()
|
|
await waitForHiddenRegions(page, 30000)
|
|
await page.waitForTimeout(800)
|
|
|
|
const initial = await collectResult(page, 'index-route-inline', { url })
|
|
const foldButton = page.locator('.markstream-inline-fold-proxy:visible, .diff-hidden-lines a:visible').first()
|
|
await foldButton.click()
|
|
await page.waitForFunction(() => document.querySelectorAll('.diff-hidden-lines').length === 0, { timeout: 10000 })
|
|
const afterExpandHiddenRegionCount = await page.locator('.diff-hidden-lines').count()
|
|
|
|
await page.close()
|
|
return {
|
|
...initial,
|
|
afterExpandHiddenRegionCount,
|
|
afterExpandFoldCleared: afterExpandHiddenRegionCount === 0,
|
|
}
|
|
}
|
|
|
|
async function run() {
|
|
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
|
|
if (!Number.isFinite(port))
|
|
throw new Error(`Invalid PORT: ${process.env.PORT}`)
|
|
|
|
const vite = spawn(
|
|
'pnpm',
|
|
['-C', playgroundDir, 'dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'],
|
|
{
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: process.env,
|
|
},
|
|
)
|
|
|
|
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 context = await browser.newContext()
|
|
await context.addInitScript(() => {
|
|
localStorage.setItem('vmr-settings-stream-delay', '4')
|
|
localStorage.setItem('vmr-settings-stream-chunk-size', '16')
|
|
localStorage.setItem('vmr-test-render-mode', 'monaco')
|
|
localStorage.setItem('vmr-test-code-stream', 'false')
|
|
localStorage.setItem('vmr-test-viewport-priority', 'false')
|
|
localStorage.setItem('vmr-test-batch-rendering', 'false')
|
|
localStorage.setItem('vmr-test-typewriter', 'false')
|
|
localStorage.setItem('vmr-test-show-settings', 'false')
|
|
})
|
|
|
|
const baseUrl = `http://127.0.0.1:${port}`
|
|
const controlled = await runControlledScenario(context, baseUrl)
|
|
const home = await runHomeScenario(context, baseUrl)
|
|
const homeInline = await runHomeInlineScenario(context, baseUrl)
|
|
await browser.close()
|
|
|
|
const ok = controlled.hasHiddenRegionText && controlled.hiddenRegionCount > 0
|
|
&& home.hasHiddenRegionText && home.hiddenRegionCount > 0
|
|
&& home.foldedHostShrinks
|
|
&& homeInline.hasHiddenRegionText && homeInline.hiddenRegionCount > 0
|
|
&& homeInline.inlineFoldButtonVisible
|
|
&& homeInline.afterExpandFoldCleared
|
|
&& home.diffEditors === 1
|
|
|
|
const result = {
|
|
ok,
|
|
controlled,
|
|
home,
|
|
homeInline,
|
|
}
|
|
|
|
console.log(JSON.stringify(result, null, 2))
|
|
|
|
if (!ok) {
|
|
console.error('Recent Vite logs:\n', logs.slice(-40).join(''))
|
|
process.exit(1)
|
|
}
|
|
}
|
|
finally {
|
|
cleanup()
|
|
}
|
|
}
|
|
|
|
run().catch((error) => {
|
|
console.error(error)
|
|
process.exit(1)
|
|
})
|