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:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,143 @@
// Fuzz runner to repeatedly simulate streaming updates and detect duplicated tails
// The simulation mirrors the logic used in EditorManager/index: use optimistic
// lastKnownCode but appendBuffer flush happens only on frame boundaries.
function createSim() {
let lastKnownCode = ''
let pendingUpdate = null
const appendBuffer = []
let appendBufferScheduled = false
function appendCode(s) {
appendBuffer.push(s)
if (!appendBufferScheduled) appendBufferScheduled = true
}
function flushAppendBuffer(model) {
if (!appendBufferScheduled) return
appendBufferScheduled = false
const text = appendBuffer.join('')
appendBuffer.length = 0
model.value += text
}
function flushPendingUpdate(model) {
if (!pendingUpdate) return
const newCode = pendingUpdate
pendingUpdate = null
// Use authoritative model value when appendBuffer pending
const prevCode = (appendBuffer.length > 0) ? model.value : (lastKnownCode ?? model.value)
if (prevCode === newCode) return
if (newCode.startsWith(prevCode) && prevCode.length < newCode.length) {
const suffix = newCode.slice(prevCode.length)
if (suffix) appendCode(suffix)
lastKnownCode = newCode
return
}
model.value = newCode
lastKnownCode = newCode
}
function updateCode(code) {
pendingUpdate = code
}
return { updateCode, flushPendingUpdate, flushAppendBuffer }
}
const markdown = `
# Create Vue project
npm create vue@latest electron-vue-chat
# Navigate to project
cd electron-vue-chat
# Install dependencies
npm install
npm install electron electron-builder vue-router
# Install dev dependencies
npm install -D electron-dev-server concurrently wait-on
`
function oneRun(opts) {
// opts: { flushEvery, randomizeFlush, jitter }
const sim = createSim()
const model = { value: '' }
let i = 0
const len = markdown.length
let step = 0
for (i = 1; i <= len; i++) {
const contents = markdown.slice(0, i)
sim.updateCode(contents)
// decide whether to flush this frame
let doFlush = false
if (opts.randomizeFlush) {
// random flush with probability p
doFlush = Math.random() < opts.flushProbability
}
else {
doFlush = (i % opts.flushEvery) === 0
}
if (doFlush) {
// apply pending update then flush buffer
sim.flushPendingUpdate(model)
// optionally apply jitter: maybe flush append buffer now or delayed
if (opts.jitter) {
if (Math.random() < 0.5) sim.flushAppendBuffer(model)
// else we delay and allow next flush to consume
}
else {
sim.flushAppendBuffer(model)
}
}
}
// final flush at the end
sim.flushPendingUpdate(model)
sim.flushAppendBuffer(model)
return model.value
}
function detectDup(s) {
// heuristics: look for repeated substrings of length >= 3 at the end
// e.g., 'wait-on' -> 'wait-t-onon' contains 'onon'
// we'll search for any repeat of last 2..6 char sequences
for (let k = 2; k <= 8; k++) {
if (s.length < k * 2) continue
const a = s.slice(-k)
const b = s.slice(-k * 2, -k)
if (a === b) return { dup: true, fragment: a }
}
return { dup: false }
}
function runFuzz(runs = 1000) {
let failures = []
for (let r = 0; r < runs; r++) {
const opts = {}
// randomly choose a flush strategy
if (Math.random() < 0.5) {
opts.randomizeFlush = false
// flushEvery between 1 and 200
opts.flushEvery = Math.floor(Math.random() * 200) + 1
}
else {
opts.randomizeFlush = true
opts.flushProbability = Math.random() * 0.5 + 0.01
}
opts.jitter = Math.random() < 0.4
const out = oneRun(opts)
const res = detectDup(out)
if (res.dup) {
failures.push({ run: r, opts, dup: res.fragment, out })
}
}
return failures
}
const runs = Number(process.argv[2] || 1000)
console.log('starting fuzz runs:', runs)
const fails = runFuzz(runs)
console.log('failures:', fails.length)
if (fails.length > 0) console.log(fails.slice(0, 10))
else console.log('no duplicates detected')
@@ -0,0 +1,261 @@
#!/usr/bin/env node
// Headless Playwright benchmark that creates a Monaco editor in a page and
// runs a high-frequency update scenario to collect timing and long-task info.
import { chromium } from 'playwright'
async function run({ updates = 2000, freqHz = 200, append = false } = {}) {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
// minimal HTML that loads Monaco from CDN and creates a container
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>html,body,#editor{height:100%;margin:0;padding:0}</style>
</head>
<body>
<div id="editor"></div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js"></script>
<script>
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' } });
window.__benchReady = new Promise((res) => {
require(['vs/editor/editor.main'], function () {
window.editor = monaco.editor.create(document.getElementById('editor'), {
value: '',
language: 'javascript',
automaticLayout: true,
});
res(true)
})
})
</script>
</body>
</html>`
await page.setContent(html, { waitUntil: 'load' })
await page.evaluate(() => window.__benchReady)
// run the benchmark inside the page
const result = await page.evaluate(async ({ updates, freqHz, append, payloadSize, random }) => {
const editor = window.editor
const model = editor.getModel()
// observe long tasks
let longTasks = 0
try {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration && entry.duration > 50)
longTasks++
}
})
obs.observe({ type: 'longtask', buffered: true })
}
catch { }
const interval = 1000 / freqHz
const start = performance.now()
for (let i = 0; i < updates; i++) {
const text = 'a'.repeat(i % 1000)
if (append) {
// append via executeEdits
const lastLine = model.getLineCount()
const lastCol = model.getLineMaxColumn(lastLine)
model.applyEdits([{ range: new monaco.Range(lastLine, lastCol, lastLine, lastCol), text }])
}
else {
model.setValue(text)
}
// busy wait to emulate frequency
const t0 = performance.now()
while (performance.now() - t0 < interval) {}
}
// allow observers to flush
await new Promise(r => setTimeout(r, 200))
const end = performance.now()
return { updates, freqHz, elapsedMs: Math.round(end - start), longTasks }
}, { updates, freqHz, append })
await browser.close()
return result
}
if (import.meta.url === `file://${process.argv[1]}` || (process.argv[1] && process.argv[1].endsWith('playwright-bench.mjs'))) {
// Robust CLI parsing: support running directly or via `pnpm run bench:playwright -- ...`
// process.argv can contain additional npm/pnpm wrapper args, so parse slice(2)
const raw = process.argv.slice(2)
// Accept: <updates> <freqHz> [append]
let updates = 2000
let freqHz = 200
let append = false
if (raw.length >= 1) {
const n = Number(raw[0])
if (!Number.isNaN(n) && isFinite(n))
updates = n
}
if (raw.length >= 2) {
const n = Number(raw[1])
if (!Number.isNaN(n) && isFinite(n))
freqHz = n
}
// allow append to appear anywhere in args
if (raw.some(a => a === 'append' || a === '--append'))
append = true
// If caller asked for 'compare', run three benches with different throttle values
const rawArgs = raw
if (rawArgs.includes('compare') || rawArgs.includes('--compare')) {
// parse optional payload size and random flag from args
// payloadSize: any numeric arg after the first two (updates, freqHz)
let payloadSize = 1000
const numericArgs = rawArgs.map(a => Number(a)).filter(n => !Number.isNaN(n) && isFinite(n))
if (numericArgs.length >= 3)
payloadSize = numericArgs[2]
const random = rawArgs.includes('random') || rawArgs.includes('--random')
// throttle values to compare (ms)
const throttleValues = [0, 50, 100]
const results = []
for (const t of throttleValues) {
try {
const r = await runWithThrottle({ updates, freqHz, append, throttle: t, payloadSize, random })
results.push({ throttle: t, payloadSize, random, result: r })
}
catch (err) {
console.error('compare run failed for throttle', t, err)
results.push({ throttle: t, payloadSize, random, error: String(err) })
}
}
console.log(JSON.stringify({ compare: true, updates, freqHz, append, payloadSize, random, runs: results }, null, 2))
process.exit(0)
}
run({ updates, freqHz, append }).then(r => console.log(JSON.stringify(r, null, 2))).catch((err) => { console.error(err); process.exit(1) })
}
// Helper that loads the page, sets up a throttled update wrapper in-page, and runs the bench
async function runWithThrottle({ updates = 2000, freqHz = 200, append = false, throttle = 50, payloadSize = 1000, random = false } = {}) {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>html,body,#editor{height:100%;margin:0;padding:0}</style>
</head>
<body>
<div id="editor"></div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js"></script>
<script>
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' } });
window.__benchReady = new Promise((res) => {
require(['vs/editor/editor.main'], function () {
window.editor = monaco.editor.create(document.getElementById('editor'), {
value: '',
language: 'javascript',
automaticLayout: true,
});
// create a throttled updateCode wrapper that batches setValue calls
(function(){
let pending = null
let timer = null
const throttle = ${throttle}
const payloadSize = ${payloadSize}
const random = ${random}
function makePayload(i) {
if (random) {
// simple pseudo-random string of payloadSize
let s = ''
for (let k = 0; k < payloadSize; k++) s += String.fromCharCode(97 + (Math.floor(Math.random() * 26)))
return s
}
return 'x'.repeat(payloadSize)
}
window.__updateWithThrottle = function(text){
if (throttle === 0) {
try { window.editor.getModel().setValue(text) } catch(e) {}
return
}
pending = text
if (timer != null) return
timer = setTimeout(() => {
try { window.editor.getModel().setValue(pending || '') } catch(e) {}
pending = null
timer = null
}, throttle)
}
})()
res(true)
})
})
</script>
</body>
</html>`
await page.setContent(html, { waitUntil: 'load' })
await page.evaluate(() => window.__benchReady)
const result = await page.evaluate(async ({ updates, freqHz, append, payloadSize, random }) => {
const editor = window.editor
const model = editor.getModel()
// observe long tasks
let longTasks = 0
try {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration && entry.duration > 50)
longTasks++
}
})
obs.observe({ type: 'longtask', buffered: true })
}
catch { }
const interval = 1000 / freqHz
const start = performance.now()
for (let i = 0; i < updates; i++) {
let text
if (random) {
// simple deterministic-ish random per iteration
const arr = new Array(payloadSize)
for (let k = 0; k < payloadSize; k++) arr[k] = String.fromCharCode(97 + ((i + k) % 26))
text = arr.join('')
}
else {
text = 'x'.repeat(payloadSize)
}
if (append) {
const lastLine = model.getLineCount()
const lastCol = model.getLineMaxColumn(lastLine)
model.applyEdits([{ range: new monaco.Range(lastLine, lastCol, lastLine, lastCol), text }])
}
else {
// use the throttled API if present
if (typeof window.__updateWithThrottle === 'function') {
window.__updateWithThrottle(text)
}
else {
model.setValue(text)
}
}
const t0 = performance.now()
while (performance.now() - t0 < interval) {}
}
await new Promise(r => setTimeout(r, 200))
const end = performance.now()
return { updates, freqHz, elapsedMs: Math.round(end - start), longTasks }
}, { updates, freqHz, append, payloadSize, random })
await browser.close()
return result
}
@@ -0,0 +1,272 @@
#!/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)
})
@@ -0,0 +1,373 @@
#!/usr/bin/env node
// Validate default Diff UX hunk actions against Monaco model contents.
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')
const fixtures = {
inserted: {
pair: {
original: [
'const alpha = 1',
'const beta = 2',
'const gamma = 3',
].join('\n'),
modified: [
'const alpha = 1',
'const beta = 2',
'const inserted = 99',
'const gamma = 3',
].join('\n'),
},
hoverRootSelector: '.editor.modified',
hoverLineText: 'inserted = 99',
},
deleted: {
pair: {
original: [
'const alpha = 1',
'const legacy = 0',
'const beta = 2',
'const gamma = 3',
].join('\n'),
modified: [
'const alpha = 1',
'const beta = 2',
'const gamma = 3',
].join('\n'),
},
hoverRootSelector: '.editor.original',
hoverLineText: 'legacy = 0',
},
}
const cases = [
{
name: 'revert-lower-removes-inserted-lines',
fixture: 'inserted',
side: 'lower',
action: 'revert',
expected: {
original: fixtures.inserted.pair.original,
modified: fixtures.inserted.pair.original,
},
expectedDiffCount: 0,
},
{
name: 'revert-upper-restores-deleted-lines',
fixture: 'deleted',
side: 'upper',
action: 'revert',
expected: {
original: fixtures.deleted.pair.original,
modified: fixtures.deleted.pair.original,
},
expectedDiffCount: 0,
},
{
name: 'stage-lower-copies-inserted-lines-to-original',
fixture: 'inserted',
side: 'lower',
action: 'stage',
expected: {
original: fixtures.inserted.pair.modified,
modified: fixtures.inserted.pair.modified,
},
expectedDiffCount: 0,
},
{
name: 'stage-upper-drops-deleted-lines-from-original',
fixture: 'deleted',
side: 'upper',
action: 'stage',
expected: {
original: fixtures.deleted.pair.modified,
modified: fixtures.deleted.pair.modified,
},
expectedDiffCount: 0,
},
]
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 = 5173, end = 5290) {
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 = 30000) {
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?.()
}
async function invokeDiffTestApi(page, methodName, ...args) {
return page.evaluate(
({ methodName, args }) => {
const api = window.__streamMonacoDiffTestApi
const method = api?.[methodName]
if (typeof method !== 'function')
throw new Error(`Missing diff test API method: ${methodName}`)
return method(...args)
},
{ methodName, args },
)
}
async function waitForDiffFixture(page) {
await page.waitForFunction(() => {
const api = window.__streamMonacoDiffTestApi
const values = api?.getDiffValues?.()
const summary = api?.getDiffSummary?.()
return (
values &&
summary &&
values.original !== values.modified &&
summary.diffCount > 0
)
}, { timeout: 8000 })
}
async function hoverDiffLine(page, rootSelector, lineText) {
const line = page
.locator(`${rootSelector} .view-lines .view-line`)
.filter({ hasText: lineText })
.first()
await line.waitFor({ state: 'visible', timeout: 5000 })
await line.hover()
await page.waitForTimeout(400)
}
async function clickHunkAction(page, side, action) {
const button = page.locator(
`.stream-monaco-diff-hunk-actions[data-side="${side}"] button[data-action="${action}"]`,
)
await button.waitFor({ state: 'visible', timeout: 5000 })
if (await button.isDisabled()) {
throw new Error(`Expected ${side}/${action} button to be enabled`)
}
await button.click()
}
async function waitForExpectedState(page, expected, expectedDiffCount) {
await page.waitForFunction(
({ expected, expectedDiffCount }) => {
const api = window.__streamMonacoDiffTestApi
const values = api?.getDiffValues?.()
const summary = api?.getDiffSummary?.()
return (
values &&
summary &&
values.original === expected.original &&
values.modified === expected.modified &&
summary.diffCount === expectedDiffCount
)
},
{ expected, expectedDiffCount },
{ timeout: 8000 },
)
}
async function runCase(page, scenario) {
const fixture = fixtures[scenario.fixture]
if (!fixture) throw new Error(`Unknown fixture: ${scenario.fixture}`)
let before = null
try {
await invokeDiffTestApi(page, 'setDiffPair', fixture.pair, {
preserveViewState: false,
})
await waitForDiffFixture(page)
before = await invokeDiffTestApi(page, 'getDiffValues')
await hoverDiffLine(page, fixture.hoverRootSelector, fixture.hoverLineText)
await clickHunkAction(page, scenario.side, scenario.action)
await waitForExpectedState(
page,
scenario.expected,
scenario.expectedDiffCount ?? 0,
)
const after = await invokeDiffTestApi(page, 'getDiffValues')
const summary = await invokeDiffTestApi(page, 'getDiffSummary')
const actionLogs = await invokeDiffTestApi(page, 'getActionLogs')
const expectedPrefix = `${scenario.action.toUpperCase()} ${scenario.side} |`
const firstLog = actionLogs[0] ?? null
const ok =
after.original === scenario.expected.original &&
after.modified === scenario.expected.modified &&
summary.diffCount === (scenario.expectedDiffCount ?? 0) &&
typeof firstLog === 'string' &&
firstLog.startsWith(expectedPrefix)
return {
name: scenario.name,
side: scenario.side,
action: scenario.action,
ok,
firstLog,
before,
after,
summary,
}
} catch (error) {
const after =
await invokeDiffTestApi(page, 'getDiffValues').catch(() => null)
const summary =
await invokeDiffTestApi(page, 'getDiffSummary').catch(() => null)
const actionLogs =
await invokeDiffTestApi(page, 'getActionLogs').catch(() => [])
return {
name: scenario.name,
side: scenario.side,
action: scenario.action,
ok: false,
error: String(error),
before,
after,
summary,
actionLogs,
}
}
}
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',
demoDir,
'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 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: 1440, height: 1100 },
deviceScaleFactor: 1,
})
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (error) => {
pageErrors.push(String(error))
})
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
const url = `http://127.0.0.1:${port}/diff-ux?style=background&scenario=streaming&theme=vitesse-light&appearance=light`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.editor-card', { timeout: 20000 })
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
await page.waitForFunction(
() => typeof window.__streamMonacoDiffTestApi?.getDiffValues === 'function',
{ timeout: 20000 },
)
await page.waitForTimeout(800)
const results = []
for (const scenario of cases) {
// eslint-disable-next-line no-await-in-loop
results.push(await runCase(page, scenario))
}
await browser.close()
const ok =
pageErrors.length === 0 &&
consoleErrors.length === 0 &&
results.every((result) => result.ok)
const result = {
ok,
url,
pageErrors,
consoleErrors,
results,
}
console.log(JSON.stringify(result, null, 2))
if (!ok) {
console.error('Diff hunk action validation failed; recent Vite logs:\n')
console.error(logs.slice(-60).join(''))
process.exit(1)
}
} finally {
killProcessTree(vite)
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,366 @@
#!/usr/bin/env node
// Validate async custom onDiffHunkAction patch/stash interception flow.
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')
const fixtures = {
inserted: {
pair: {
original: [
'const alpha = 1',
'const beta = 2',
'const gamma = 3',
].join('\n'),
modified: [
'const alpha = 1',
'const beta = 2',
'const inserted = 99',
'const gamma = 3',
].join('\n'),
},
hoverRootSelector: '.editor.modified',
hoverLineText: 'inserted = 99',
},
deleted: {
pair: {
original: [
'const alpha = 1',
'const legacy = 0',
'const beta = 2',
'const gamma = 3',
].join('\n'),
modified: [
'const alpha = 1',
'const beta = 2',
'const gamma = 3',
].join('\n'),
},
hoverRootSelector: '.editor.original',
hoverLineText: 'legacy = 0',
},
}
const cases = [
{
name: 'custom-patch-revert-lower',
fixture: 'inserted',
side: 'lower',
action: 'revert',
expected: {
original: fixtures.inserted.pair.original,
modified: fixtures.inserted.pair.original,
},
expectedDiffCount: 0,
expectedActionLogPrefix: 'REVERT lower |',
expectedPatchLogPrefix: 'PATCH FLOW REVERT lower |',
},
{
name: 'custom-patch-stage-upper',
fixture: 'deleted',
side: 'upper',
action: 'stage',
expected: {
original: fixtures.deleted.pair.modified,
modified: fixtures.deleted.pair.modified,
},
expectedDiffCount: 0,
expectedActionLogPrefix: 'STAGE upper |',
expectedPatchLogPrefix: 'PATCH FLOW STAGE upper |',
},
]
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 = 5173, end = 5290) {
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 = 30000) {
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?.()
}
async function invokeDiffTestApi(page, methodName, ...args) {
return page.evaluate(
({ methodName, args }) => {
const api = window.__streamMonacoDiffTestApi
const method = api?.[methodName]
if (typeof method !== 'function')
throw new Error(`Missing diff test API method: ${methodName}`)
return method(...args)
},
{ methodName, args },
)
}
async function waitForDiffFixture(page) {
await page.waitForFunction(() => {
const api = window.__streamMonacoDiffTestApi
const values = api?.getDiffValues?.()
const summary = api?.getDiffSummary?.()
return (
values &&
summary &&
values.original !== values.modified &&
summary.diffCount > 0
)
}, { timeout: 8000 })
}
async function hoverDiffLine(page, rootSelector, lineText) {
const line = page
.locator(`${rootSelector} .view-lines .view-line`)
.filter({ hasText: lineText })
.first()
await line.waitFor({ state: 'visible', timeout: 5000 })
await line.hover()
await page.waitForTimeout(400)
}
async function clickHunkAction(page, side, action) {
const button = page.locator(
`.stream-monaco-diff-hunk-actions[data-side="${side}"] button[data-action="${action}"]`,
)
await button.waitFor({ state: 'visible', timeout: 5000 })
if (await button.isDisabled()) {
throw new Error(`Expected ${side}/${action} button to be enabled`)
}
await button.click()
}
async function waitForExpectedState(page, expected, expectedDiffCount) {
await page.waitForFunction(
({ expected, expectedDiffCount }) => {
const api = window.__streamMonacoDiffTestApi
const values = api?.getDiffValues?.()
const summary = api?.getDiffSummary?.()
return (
values &&
summary &&
values.original === expected.original &&
values.modified === expected.modified &&
summary.diffCount === expectedDiffCount
)
},
{ expected, expectedDiffCount },
{ timeout: 8000 },
)
}
async function runCase(page, scenario) {
const fixture = fixtures[scenario.fixture]
if (!fixture) throw new Error(`Unknown fixture: ${scenario.fixture}`)
let before = null
try {
await invokeDiffTestApi(page, 'setHunkActionMode', 'custom-patch')
await invokeDiffTestApi(page, 'setDiffPair', fixture.pair, {
preserveViewState: false,
})
await waitForDiffFixture(page)
before = await invokeDiffTestApi(page, 'getDiffValues')
await hoverDiffLine(page, fixture.hoverRootSelector, fixture.hoverLineText)
await clickHunkAction(page, scenario.side, scenario.action)
await waitForExpectedState(
page,
scenario.expected,
scenario.expectedDiffCount,
)
const after = await invokeDiffTestApi(page, 'getDiffValues')
const summary = await invokeDiffTestApi(page, 'getDiffSummary')
const actionLogs = await invokeDiffTestApi(page, 'getActionLogs')
const patchFlowLogs = await invokeDiffTestApi(page, 'getPatchFlowLogs')
const mode = await invokeDiffTestApi(page, 'getHunkActionMode')
const firstActionLog = actionLogs[0] ?? null
const firstPatchLog = patchFlowLogs[0] ?? null
const ok =
mode === 'custom-patch'
&& after.original === scenario.expected.original
&& after.modified === scenario.expected.modified
&& summary.diffCount === scenario.expectedDiffCount
&& typeof firstActionLog === 'string'
&& firstActionLog.startsWith(scenario.expectedActionLogPrefix)
&& typeof firstPatchLog === 'string'
&& firstPatchLog.startsWith(scenario.expectedPatchLogPrefix)
return {
name: scenario.name,
side: scenario.side,
action: scenario.action,
ok,
mode,
firstActionLog,
firstPatchLog,
before,
after,
summary,
}
} catch (error) {
const after =
await invokeDiffTestApi(page, 'getDiffValues').catch(() => null)
const summary =
await invokeDiffTestApi(page, 'getDiffSummary').catch(() => null)
const actionLogs =
await invokeDiffTestApi(page, 'getActionLogs').catch(() => [])
const patchFlowLogs =
await invokeDiffTestApi(page, 'getPatchFlowLogs').catch(() => [])
const mode =
await invokeDiffTestApi(page, 'getHunkActionMode').catch(() => null)
return {
name: scenario.name,
side: scenario.side,
action: scenario.action,
ok: false,
error: String(error),
mode,
before,
after,
summary,
actionLogs,
patchFlowLogs,
}
}
}
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',
demoDir,
'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 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: 1440, height: 1100 },
deviceScaleFactor: 1,
})
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (error) => {
pageErrors.push(String(error))
})
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
const url = `http://127.0.0.1:${port}/diff-ux?style=background&scenario=streaming&theme=vitesse-light&appearance=light`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.editor-card', { timeout: 20000 })
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
await page.waitForFunction(
() => typeof window.__streamMonacoDiffTestApi?.setHunkActionMode === 'function',
{ timeout: 20000 },
)
await page.waitForTimeout(800)
const results = []
for (const scenario of cases) {
results.push(await runCase(page, scenario))
}
await invokeDiffTestApi(page, 'setHunkActionMode', 'default')
await browser.close()
const ok =
pageErrors.length === 0
&& consoleErrors.length === 0
&& results.every((result) => result.ok)
const result = {
ok,
url,
pageErrors,
consoleErrors,
results,
}
console.log(JSON.stringify(result, null, 2))
if (!ok) {
console.error('Custom diff hunk patch-flow validation failed; recent Vite logs:\n')
console.error(logs.slice(-60).join(''))
process.exit(1)
}
} finally {
killProcessTree(vite)
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,368 @@
#!/usr/bin/env node
// Validate async custom onDiffHunkAction server updateDiff(...) flow.
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')
const fixtures = {
inserted: {
pair: {
original: [
'const alpha = 1',
'const beta = 2',
'const gamma = 3',
].join('\n'),
modified: [
'const alpha = 1',
'const beta = 2',
'const inserted = 99',
'const gamma = 3',
].join('\n'),
},
hoverRootSelector: '.editor.modified',
hoverLineText: 'inserted = 99',
},
deleted: {
pair: {
original: [
'const alpha = 1',
'const legacy = 0',
'const beta = 2',
'const gamma = 3',
].join('\n'),
modified: [
'const alpha = 1',
'const beta = 2',
'const gamma = 3',
].join('\n'),
},
hoverRootSelector: '.editor.original',
hoverLineText: 'legacy = 0',
},
}
const cases = [
{
name: 'custom-update-diff-revert-lower',
fixture: 'inserted',
side: 'lower',
action: 'revert',
expected: {
original: fixtures.inserted.pair.original,
modified: fixtures.inserted.pair.original,
},
expectedDiffCount: 0,
expectedActionLogPrefix: 'REVERT lower |',
expectedPatchLogPrefix: 'UPDATE DIFF FLOW REVERT lower |',
},
{
name: 'custom-update-diff-stage-upper',
fixture: 'deleted',
side: 'upper',
action: 'stage',
expected: {
original: fixtures.deleted.pair.modified,
modified: fixtures.deleted.pair.modified,
},
expectedDiffCount: 0,
expectedActionLogPrefix: 'STAGE upper |',
expectedPatchLogPrefix: 'UPDATE DIFF FLOW STAGE upper |',
},
]
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 = 5173, end = 5290) {
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 = 30000) {
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?.()
}
async function invokeDiffTestApi(page, methodName, ...args) {
return page.evaluate(
({ methodName, args }) => {
const api = window.__streamMonacoDiffTestApi
const method = api?.[methodName]
if (typeof method !== 'function')
throw new Error(`Missing diff test API method: ${methodName}`)
return method(...args)
},
{ methodName, args },
)
}
async function waitForDiffFixture(page) {
await page.waitForFunction(() => {
const api = window.__streamMonacoDiffTestApi
const values = api?.getDiffValues?.()
const summary = api?.getDiffSummary?.()
return (
values &&
summary &&
values.original !== values.modified &&
summary.diffCount > 0
)
}, { timeout: 8000 })
}
async function hoverDiffLine(page, rootSelector, lineText) {
const line = page
.locator(`${rootSelector} .view-lines .view-line`)
.filter({ hasText: lineText })
.first()
await line.waitFor({ state: 'visible', timeout: 5000 })
await line.hover()
await page.waitForTimeout(400)
}
async function clickHunkAction(page, side, action) {
const button = page.locator(
`.stream-monaco-diff-hunk-actions[data-side="${side}"] button[data-action="${action}"]`,
)
await button.waitFor({ state: 'visible', timeout: 5000 })
if (await button.isDisabled()) {
throw new Error(`Expected ${side}/${action} button to be enabled`)
}
await button.click()
}
async function waitForExpectedState(page, expected, expectedDiffCount) {
await page.waitForFunction(
({ expected, expectedDiffCount }) => {
const api = window.__streamMonacoDiffTestApi
const values = api?.getDiffValues?.()
const summary = api?.getDiffSummary?.()
return (
values &&
summary &&
values.original === expected.original &&
values.modified === expected.modified &&
summary.diffCount === expectedDiffCount
)
},
{ expected, expectedDiffCount },
{ timeout: 8000 },
)
}
async function runCase(page, scenario) {
const fixture = fixtures[scenario.fixture]
if (!fixture) throw new Error(`Unknown fixture: ${scenario.fixture}`)
let before = null
try {
await invokeDiffTestApi(page, 'setHunkActionMode', 'custom-update-diff')
await invokeDiffTestApi(page, 'setDiffPair', fixture.pair, {
preserveViewState: false,
})
await waitForDiffFixture(page)
before = await invokeDiffTestApi(page, 'getDiffValues')
await hoverDiffLine(page, fixture.hoverRootSelector, fixture.hoverLineText)
await clickHunkAction(page, scenario.side, scenario.action)
await waitForExpectedState(
page,
scenario.expected,
scenario.expectedDiffCount,
)
const after = await invokeDiffTestApi(page, 'getDiffValues')
const summary = await invokeDiffTestApi(page, 'getDiffSummary')
const actionLogs = await invokeDiffTestApi(page, 'getActionLogs')
const patchFlowLogs = await invokeDiffTestApi(page, 'getPatchFlowLogs')
const mode = await invokeDiffTestApi(page, 'getHunkActionMode')
const firstActionLog = actionLogs[0] ?? null
const firstPatchLog = patchFlowLogs[0] ?? null
const ok =
mode === 'custom-update-diff'
&& after.original === scenario.expected.original
&& after.modified === scenario.expected.modified
&& summary.diffCount === scenario.expectedDiffCount
&& typeof firstActionLog === 'string'
&& firstActionLog.startsWith(scenario.expectedActionLogPrefix)
&& typeof firstPatchLog === 'string'
&& firstPatchLog.startsWith(scenario.expectedPatchLogPrefix)
return {
name: scenario.name,
side: scenario.side,
action: scenario.action,
ok,
mode,
firstActionLog,
firstPatchLog,
before,
after,
summary,
}
} catch (error) {
const after =
await invokeDiffTestApi(page, 'getDiffValues').catch(() => null)
const summary =
await invokeDiffTestApi(page, 'getDiffSummary').catch(() => null)
const actionLogs =
await invokeDiffTestApi(page, 'getActionLogs').catch(() => [])
const patchFlowLogs =
await invokeDiffTestApi(page, 'getPatchFlowLogs').catch(() => [])
const mode =
await invokeDiffTestApi(page, 'getHunkActionMode').catch(() => null)
return {
name: scenario.name,
side: scenario.side,
action: scenario.action,
ok: false,
error: String(error),
mode,
before,
after,
summary,
actionLogs,
patchFlowLogs,
}
}
}
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',
demoDir,
'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 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: 1440, height: 1100 },
deviceScaleFactor: 1,
})
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (error) => {
pageErrors.push(String(error))
})
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
const url = `http://127.0.0.1:${port}/diff-ux?style=background&scenario=streaming&theme=vitesse-light&appearance=light`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.editor-card', { timeout: 20000 })
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
await page.waitForFunction(
() => typeof window.__streamMonacoDiffTestApi?.setHunkActionMode === 'function',
{ timeout: 20000 },
)
await page.waitForTimeout(800)
const results = []
for (const scenario of cases) {
results.push(await runCase(page, scenario))
}
await invokeDiffTestApi(page, 'setHunkActionMode', 'default')
await browser.close()
const ok =
pageErrors.length === 0
&& consoleErrors.length === 0
&& results.every((result) => result.ok)
const result = {
ok,
url,
pageErrors,
consoleErrors,
results,
}
console.log(JSON.stringify(result, null, 2))
if (!ok) {
console.error(
'Custom diff hunk updateDiff-flow validation failed; recent Vite logs:\n',
)
console.error(logs.slice(-60).join(''))
process.exit(1)
}
} finally {
killProcessTree(vite)
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,391 @@
#!/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()
@@ -0,0 +1,140 @@
#!/usr/bin/env node
// Smoke test for DiffEditor streaming rendering.
// Starts the Vite demo, opens /diff in headless Chromium, and asserts Monaco renders text.
import net from 'node:net'
import process from 'node:process'
import { spawn } from 'node:child_process'
import path from 'node:path'
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 = 5173, end = 5190) {
for (let port = start; port <= end; port++) {
// If nothing is listening, consider it free.
// (We only bind after Vite starts, so this is best-effort.)
// 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()
// eslint-disable-next-line no-constant-condition
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 { }
// Fallback hard kill after a short grace period.
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch { }
}, 3000).unref?.()
}
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', 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 url = `http://127.0.0.1:${port}/diff`
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
await page.waitForTimeout(1200)
const before = await page.evaluate(() => {
const viewLines = Array.from(document.querySelectorAll('.monaco-editor .view-lines'))
const text = viewLines.map(v => (v.textContent ?? '')).join('\n').trim()
return { viewLinesCount: viewLines.length, textLen: text.length, sample: text.slice(0, 120) }
})
// Wait for at least one streaming tick from the demo.
await page.waitForTimeout(700)
const after = await page.evaluate(() => {
const viewLines = Array.from(document.querySelectorAll('.monaco-editor .view-lines'))
const text = viewLines.map(v => (v.textContent ?? '')).join('\n').trim()
return { viewLinesCount: viewLines.length, textLen: text.length }
})
await browser.close()
// We only assert that Monaco renders *some* text and stays rendered after
// streaming ticks. Diff line wrapping/reflow can change `textLen`, so do
// not assert monotonic growth.
const ok = before.textLen > 0 && after.textLen > 0
const result = { ok, url, before, after }
console.log(JSON.stringify(result, null, 2))
if (!ok) {
console.error('Diff smoke failed; recent Vite logs:\n', logs.slice(-40).join(''))
process.exit(1)
}
}
finally {
killProcessTree(vite)
}
}
run().catch((err) => {
console.error(err)
process.exit(1)
})
@@ -0,0 +1,259 @@
#!/usr/bin/env node
// Validate that streaming appends do not leave stale full-tail diff
// highlights when only sparse earlier lines actually differ.
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 socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.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, timeoutMs = 20000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
// eslint-disable-next-line no-await-in-loop
if (await isPortOpen(port)) return
// eslint-disable-next-line no-await-in-loop
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for port ${port}`)
}
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 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', 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: 1800, height: 1100 } })
await page.goto(`http://127.0.0.1:${port}/diff`, { waitUntil: 'networkidle' })
const result = await page.evaluate(async () => {
const mod = await import('/@fs/Users/Simon/Github/stream-monaco/src/index.ts')
document.body.innerHTML = '<div id="probe" style="width:1600px;height:900px"></div>'
const container = document.getElementById('probe')
if (!(container instanceof HTMLElement))
throw new Error('Missing probe container')
mod.preloadMonacoWorkers?.()
const api = mod.useMonaco({
readOnly: true,
theme: 'vitesse-light',
themes: ['vitesse-dark', 'vitesse-light'],
diffAlgorithm: 'legacy',
renderSideBySide: true,
diffLineStyle: 'background',
diffHideUnchangedRegions: false,
diffUpdateThrottleMs: 120,
ignoreTrimWhitespace: false,
renderIndicators: true,
})
const readChangedLineNumbers = () => {
const readSide = (selector) =>
[...new Set(
Array.from(document.querySelectorAll(selector))
.map((node) => ({
line: Number.parseInt(node.textContent?.trim() || '', 10),
className: node.className,
}))
.filter((entry) =>
Number.isFinite(entry.line) &&
/line-delete|line-insert|stream-monaco-fallback-line-number-delete|stream-monaco-fallback-line-number-insert/.test(
entry.className,
),
)
.map(entry => entry.line),
)].sort((a, b) => a - b)
return {
original: readSide('.editor.original .line-numbers'),
modified: readSide('.editor.modified .line-numbers'),
stale: document.querySelector('.stream-monaco-diff-root')?.classList.contains(
'stream-monaco-diff-native-stale',
) ?? false,
}
}
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
const buildLines = (count) =>
Array.from({ length: count }, (_, index) => {
const lineNumber = index + 1
return ` "line${String(lineNumber).padStart(2, '0')}": "value-${String(lineNumber).padStart(2, '0')}",`
})
const scenarios = [
{
name: 'single-early-diff',
originalLines: ['{', ...buildLines(22), '}'],
mutate(modifiedLines) {
modifiedLines[3] = ' "line03": "value-03-updated",'
},
startLine: 4,
sampleUntilLine: 24,
expectedAtLine(uptoLine) {
return uptoLine >= 4 ? [4] : []
},
},
{
name: 'sparse-double-diff',
originalLines: ['{', ...buildLines(22), '}'],
mutate(modifiedLines) {
modifiedLines[3] = ' "line03": "value-03-updated",'
modifiedLines[19] = ' "line19": "value-19-updated",'
},
startLine: 4,
sampleUntilLine: 24,
expectedAtLine(uptoLine) {
if (uptoLine < 4) return []
if (uptoLine < 20) return [4]
return [4, 20]
},
},
]
const runScenario = async (scenario) => {
const modifiedLines = [...scenario.originalLines]
scenario.mutate(modifiedLines)
await api.createDiffEditor(
container,
scenario.originalLines.slice(0, scenario.startLine).join('\n'),
modifiedLines.slice(0, scenario.startLine).join('\n'),
'json',
)
const frames = []
for (let lineNumber = scenario.startLine + 1; lineNumber <= scenario.sampleUntilLine; lineNumber++) {
api.updateDiff(
scenario.originalLines.slice(0, lineNumber).join('\n'),
modifiedLines.slice(0, lineNumber).join('\n'),
'json',
)
// Intentionally sample before Monaco's native diff is guaranteed to
// have settled so we validate the stale-diff fallback path.
// eslint-disable-next-line no-await-in-loop
await wait(40)
frames.push({
uptoLine: lineNumber,
expected: scenario.expectedAtLine(lineNumber),
...readChangedLineNumbers(),
})
}
await wait(240)
const settled = {
expected: scenario.expectedAtLine(scenario.sampleUntilLine),
...readChangedLineNumbers(),
}
return {
name: scenario.name,
frames,
settled,
}
}
const scenarioResults = []
for (const scenario of scenarios) {
// eslint-disable-next-line no-await-in-loop
scenarioResults.push(await runScenario(scenario))
}
return { scenarios: scenarioResults }
})
const scenarios = result.scenarios.map((scenario) => {
const framesOk = scenario.frames.every((frame) => {
return (
JSON.stringify(frame.original) === JSON.stringify(frame.expected) &&
JSON.stringify(frame.modified) === JSON.stringify(frame.expected)
)
})
const settledOk
= JSON.stringify(scenario.settled.original) === JSON.stringify(scenario.settled.expected)
&& JSON.stringify(scenario.settled.modified) === JSON.stringify(scenario.settled.expected)
return {
...scenario,
ok: framesOk && settledOk,
}
})
const ok = scenarios.every(scenario => scenario.ok)
console.log(JSON.stringify({ ok, scenarios }, 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()
@@ -0,0 +1,774 @@
#!/usr/bin/env node
// Validate that diff presentation toggles refresh in place without remounting
// the Monaco diff tree or shifting the host geometry.
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 = 5173, 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 = 30000) {
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 colorLuminance(color) {
const channels = String(color || '').match(/\d+(?:\.\d+)?/g)
if (!channels || channels.length < 3) return null
const [r, g, b] = channels.slice(0, 3).map(Number)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
function rectDelta(a, b) {
if (!a || !b) return Number.POSITIVE_INFINITY
return Math.max(
Math.abs((a.left ?? 0) - (b.left ?? 0)),
Math.abs((a.top ?? 0) - (b.top ?? 0)),
Math.abs((a.width ?? 0) - (b.width ?? 0)),
Math.abs((a.height ?? 0) - (b.height ?? 0)),
)
}
function maxRectDelta(frames, key, baseline) {
return frames.reduce((max, frame) => {
return Math.max(max, rectDelta(frame[key], baseline[key]))
}, 0)
}
function allFramesTrue(frames, selector) {
return frames.every((frame) => selector(frame) === true)
}
async function invokeDiffTestApi(page, methodName, ...args) {
await page.evaluate(
({ methodName, args }) => {
const api = window.__streamMonacoDiffTestApi
const method = api?.[methodName]
if (typeof method !== 'function')
throw new Error(`Missing diff test API method: ${methodName}`)
return method(...args)
},
{ methodName, args },
)
}
async function primeModifiedScroll(page, top = 240) {
await invokeDiffTestApi(page, 'setModifiedScrollTop', top)
await page.waitForTimeout(180)
const actual = await page.evaluate(() => {
return window.__streamMonacoDiffTestApi?.getModifiedScrollTop?.() ?? 0
})
if (actual < Math.min(40, top * 0.2)) {
throw new Error(
`Failed to prime modified scroll position. expected around ${top}, got ${actual}`,
)
}
}
async function startTransitionProbe(page, frameCount = 42) {
await page.evaluate((requestedFrames) => {
const rectData = (node) => {
if (!(node instanceof HTMLElement)) return null
const rect = node.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}
}
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 countVisible = (selector) =>
Array.from(document.querySelectorAll(selector)).filter(isVisible).length
const readVisibleWidgets = (rootSelector) => {
const root = document.querySelector(rootSelector)
if (!(root instanceof HTMLElement)) return []
const rootRect = root.getBoundingClientRect()
return Array.from(root.querySelectorAll('.diff-hidden-lines-widget'))
.map((node) => {
if (!(node instanceof HTMLElement)) return null
const rect = node.getBoundingClientRect()
if (
rect.height <= 0 ||
rect.bottom <= rootRect.top + 1 ||
rect.top >= rootRect.bottom - 1
) {
return null
}
return {
topOffset: rect.top - rootRect.top,
height: rect.height,
}
})
.filter(Boolean)
.sort((a, b) => a.topOffset - b.topOffset)
}
const readVisibleAnchor = (rootSelector) => {
const root = document.querySelector(rootSelector)
if (!(root instanceof HTMLElement)) return null
const rootRect = root.getBoundingClientRect()
const anchor = Array.from(root.querySelectorAll('.line-numbers'))
.map((node) => {
const rect = node.getBoundingClientRect()
const lineNumber = Number.parseInt(node.textContent?.trim() || '', 10)
return { lineNumber, rect }
})
.filter(({ lineNumber, rect }) => {
return (
Number.isFinite(lineNumber) &&
rect.height > 0 &&
rect.bottom > rootRect.top + 1 &&
rect.top < rootRect.bottom - 1
)
})
.sort((a, b) => a.rect.top - b.rect.top)[0]
if (!anchor) return null
return {
lineNumber: anchor.lineNumber,
topOffset: anchor.rect.top - rootRect.top,
}
}
const readVisibleLineTops = (rootSelector) => {
const root = document.querySelector(rootSelector)
if (!(root instanceof HTMLElement)) return {}
const rootRect = root.getBoundingClientRect()
return Object.fromEntries(
Array.from(root.querySelectorAll('.line-numbers'))
.map((node) => {
if (!(node instanceof HTMLElement)) return null
const lineNumber = Number.parseInt(
node.textContent?.trim() || '',
10,
)
const rect = node.getBoundingClientRect()
if (
!Number.isFinite(lineNumber) ||
rect.height <= 0 ||
rect.bottom <= rootRect.top + 1 ||
rect.top >= rootRect.bottom - 1
) {
return null
}
return [String(lineNumber), rect.top - rootRect.top]
})
.filter(Boolean),
)
}
const readVisibleSummaryStates = (selector) => {
return Array.from(document.querySelectorAll(selector))
.filter(isVisible)
.map((node) => {
if (!(node instanceof HTMLButtonElement)) return null
const style = getComputedStyle(node)
return {
cursor: style.cursor,
pointerEvents: style.pointerEvents,
disabled: node.disabled,
}
})
.filter(Boolean)
}
const baselineNodes = {
host: document.querySelector('.editor-card'),
diffRoot: document.querySelector('.monaco-diff-editor'),
original: document.querySelector('.editor.original'),
modified: document.querySelector('.editor.modified'),
overlay: document.querySelector('.stream-monaco-diff-unchanged-overlay'),
}
const capture = () => {
const host = document.querySelector('.editor-card')
const frame = document.querySelector('.line-info-compare-frame')
const diffRoot = document.querySelector('.monaco-diff-editor')
const original = document.querySelector('.editor.original')
const modified = document.querySelector('.editor.modified')
const overlay = document.querySelector(
'.stream-monaco-diff-unchanged-overlay',
)
const backgroundProbe =
diffRoot?.querySelector('.monaco-editor .monaco-editor-background') ??
diffRoot?.querySelector('.monaco-editor-background')
const rootContainer = document.querySelector('.stream-monaco-diff-root')
return {
frameRect: rectData(frame),
hostRect: rectData(host),
diffRect: rectData(diffRoot),
overlayRect: rectData(overlay),
modifiedScrollTop:
window.__streamMonacoDiffTestApi?.getModifiedScrollTop?.() ?? 0,
modifiedAnchor: readVisibleAnchor('.editor.modified'),
modifiedVisibleWidgets: readVisibleWidgets('.editor.modified'),
modifiedVisibleLineTops: readVisibleLineTops('.editor.modified'),
visibleBridgeRailCount: countVisible(
'.stream-monaco-diff-unchanged-bridge .stream-monaco-unchanged-rail',
),
visibleBridgeRevealCount: countVisible(
'.stream-monaco-diff-unchanged-bridge .stream-monaco-unchanged-reveal',
),
visibleMetadataSummaryStates: readVisibleSummaryStates(
'.stream-monaco-diff-unchanged-bridge .stream-monaco-unchanged-summary-metadata',
),
visibleSimpleSummaryStates: readVisibleSummaryStates(
'.stream-monaco-diff-unchanged-bridge .stream-monaco-unchanged-summary-simple',
),
bridgeCount: countVisible('.stream-monaco-diff-unchanged-bridge'),
backgroundColor:
backgroundProbe instanceof HTMLElement
? getComputedStyle(backgroundProbe).backgroundColor
: '',
rootClasses:
rootContainer instanceof HTMLElement
? Array.from(rootContainer.classList)
: [],
nodes: {
hostSame: host === baselineNodes.host,
diffRootSame: diffRoot === baselineNodes.diffRoot,
originalSame: original === baselineNodes.original,
modifiedSame: modified === baselineNodes.modified,
overlaySame: overlay === baselineNodes.overlay,
},
}
}
const baseline = capture()
window.__streamMonacoTransitionProbe = new Promise((resolve) => {
const frames = []
let remaining = Math.max(1, requestedFrames)
const tick = () => {
frames.push(capture())
remaining--
if (remaining <= 0) {
resolve({ baseline, frames })
return
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
})
}, frameCount)
}
async function collectTransition(page, trigger) {
await startTransitionProbe(page)
await trigger()
return page.evaluate(() => window.__streamMonacoTransitionProbe)
}
function summarizeTransition(name, result, options = {}) {
const {
expectedRootClass,
geometryBudget = 0.75,
requireGeometryStable = true,
expectLuminance = null,
requireModifiedScrollStable = true,
scrollBudget = 1,
requireModifiedAnchorStable = false,
anchorBudget = 1,
expectedVisibleWidgetHeight = null,
expectAnyModifiedLineShiftUpAtLeast = null,
expectedVisibleRailCount = null,
expectedVisibleRevealCount = null,
requireMetadataSummaryNonInteractive = false,
requireSimpleSummaryNonInteractive = false,
} = options
const baseline = result.baseline
const frames = result.frames
const finalFrame = frames.at(-1) ?? baseline
const hostMaxDelta = maxRectDelta(frames, 'hostRect', baseline)
const diffMaxDelta = maxRectDelta(frames, 'diffRect', baseline)
const frameMaxDelta = maxRectDelta(frames, 'frameRect', baseline)
const overlayMaxDelta =
baseline.overlayRect && finalFrame.overlayRect
? maxRectDelta(frames, 'overlayRect', baseline)
: 0
const modifiedScrollMaxDelta = frames.reduce((max, frame) => {
return Math.max(
max,
Math.abs(
(frame.modifiedScrollTop ?? 0) - (baseline.modifiedScrollTop ?? 0),
),
)
}, 0)
const anchorLineStable = frames.every((frame) => {
return (
frame.modifiedAnchor?.lineNumber != null &&
frame.modifiedAnchor?.lineNumber === baseline.modifiedAnchor?.lineNumber
)
})
const modifiedAnchorMaxDelta = frames.reduce((max, frame) => {
if (
baseline.modifiedAnchor?.lineNumber == null ||
frame.modifiedAnchor?.lineNumber !== baseline.modifiedAnchor?.lineNumber
) {
return Number.POSITIVE_INFINITY
}
return Math.max(
max,
Math.abs(
(frame.modifiedAnchor?.topOffset ?? 0) -
(baseline.modifiedAnchor?.topOffset ?? 0),
),
)
}, 0)
const finalWidgetHeights = (finalFrame.modifiedVisibleWidgets ?? []).map(
(widget) => round(widget.height),
)
const widgetHeightOk =
expectedVisibleWidgetHeight == null
? true
: finalWidgetHeights.length > 0 &&
finalWidgetHeights.every(
(height) => Math.abs(height - expectedVisibleWidgetHeight) <= 1,
)
const baselineVisibleLineTops = baseline.modifiedVisibleLineTops ?? {}
const finalVisibleLineTops = finalFrame.modifiedVisibleLineTops ?? {}
const sharedLineShiftDeltas = Object.keys(baselineVisibleLineTops)
.filter((lineNumber) => lineNumber in finalVisibleLineTops)
.map((lineNumber) => {
return round(
finalVisibleLineTops[lineNumber] - baselineVisibleLineTops[lineNumber],
)
})
const minSharedLineShift =
sharedLineShiftDeltas.length > 0 ? Math.min(...sharedLineShiftDeltas) : null
const maxSharedLineShift =
sharedLineShiftDeltas.length > 0 ? Math.max(...sharedLineShiftDeltas) : null
const lineShiftOk =
expectAnyModifiedLineShiftUpAtLeast == null
? true
: sharedLineShiftDeltas.some(
(delta) => delta <= -expectAnyModifiedLineShiftUpAtLeast,
)
const visibleRailCountOk =
expectedVisibleRailCount == null
? true
: (finalFrame.visibleBridgeRailCount ?? 0) === expectedVisibleRailCount
const visibleRevealCountOk =
expectedVisibleRevealCount == null
? true
: (finalFrame.visibleBridgeRevealCount ?? 0) ===
expectedVisibleRevealCount
const metadataSummaryStates = finalFrame.visibleMetadataSummaryStates ?? []
const metadataSummaryNonInteractiveOk = requireMetadataSummaryNonInteractive
? metadataSummaryStates.length > 0 &&
metadataSummaryStates.every((state) => {
return (
state.cursor !== 'pointer' &&
state.pointerEvents === 'none' &&
state.disabled === true
)
})
: true
const simpleSummaryStates = finalFrame.visibleSimpleSummaryStates ?? []
const simpleSummaryNonInteractiveOk = requireSimpleSummaryNonInteractive
? simpleSummaryStates.length > 0 &&
simpleSummaryStates.every((state) => {
return (
state.cursor !== 'pointer' &&
state.pointerEvents === 'none' &&
state.disabled === true
)
})
: true
const nodeStable = {
host: allFramesTrue(frames, (frame) => frame.nodes.hostSame),
diffRoot: allFramesTrue(frames, (frame) => frame.nodes.diffRootSame),
original: allFramesTrue(frames, (frame) => frame.nodes.originalSame),
modified: allFramesTrue(frames, (frame) => frame.nodes.modifiedSame),
overlay: allFramesTrue(frames, (frame) => frame.nodes.overlaySame),
}
const finalLuminance = colorLuminance(finalFrame.backgroundColor)
const luminanceOk =
expectLuminance == null
? true
: expectLuminance === 'dark'
? finalLuminance != null && finalLuminance < 120
: finalLuminance != null && finalLuminance > 150
const geometryOk = requireGeometryStable
? hostMaxDelta <= geometryBudget &&
diffMaxDelta <= geometryBudget &&
frameMaxDelta <= geometryBudget
: true
const scrollOk = requireModifiedScrollStable
? modifiedScrollMaxDelta <= scrollBudget
: true
const anchorOk = requireModifiedAnchorStable
? anchorLineStable && modifiedAnchorMaxDelta <= anchorBudget
: true
const rootClassOk = expectedRootClass
? finalFrame.rootClasses.includes(expectedRootClass)
: true
const ok =
Object.values(nodeStable).every(Boolean) &&
geometryOk &&
scrollOk &&
anchorOk &&
widgetHeightOk &&
lineShiftOk &&
visibleRailCountOk &&
visibleRevealCountOk &&
metadataSummaryNonInteractiveOk &&
simpleSummaryNonInteractiveOk &&
rootClassOk &&
luminanceOk
return {
name,
ok,
expectedRootClass,
geometryBudget,
requireGeometryStable,
requireModifiedScrollStable,
scrollBudget,
requireModifiedAnchorStable,
anchorBudget,
rootClassOk,
scrollOk,
anchorOk,
widgetHeightOk,
lineShiftOk,
visibleRailCountOk,
visibleRevealCountOk,
metadataSummaryNonInteractiveOk,
simpleSummaryNonInteractiveOk,
luminanceOk,
finalLuminance: round(finalLuminance ?? Number.NaN),
nodeStable,
geometry: {
hostMaxDelta: round(hostMaxDelta),
diffMaxDelta: round(diffMaxDelta),
frameMaxDelta: round(frameMaxDelta),
overlayMaxDelta: round(overlayMaxDelta),
},
scroll: {
before: round(baseline.modifiedScrollTop ?? 0),
after: round(finalFrame.modifiedScrollTop ?? 0),
maxDelta: round(modifiedScrollMaxDelta),
},
anchor: {
before: baseline.modifiedAnchor ?? null,
after: finalFrame.modifiedAnchor ?? null,
lineStable: anchorLineStable,
maxDelta: round(modifiedAnchorMaxDelta),
},
unchangedWidgets: {
expectedHeight: expectedVisibleWidgetHeight,
finalHeights: finalWidgetHeights,
},
lineShift: {
min: minSharedLineShift,
max: maxSharedLineShift,
expectedShiftUpAtLeast: expectAnyModifiedLineShiftUpAtLeast,
},
bridgeChrome: {
expectedVisibleRailCount,
actualVisibleRailCount: finalFrame.visibleBridgeRailCount ?? 0,
expectedVisibleRevealCount,
actualVisibleRevealCount: finalFrame.visibleBridgeRevealCount ?? 0,
},
metadataSummary: {
requireNonInteractive: requireMetadataSummaryNonInteractive,
states: metadataSummaryStates,
},
simpleSummary: {
requireNonInteractive: requireSimpleSummaryNonInteractive,
states: simpleSummaryStates,
},
bridgeCount: {
before: baseline.bridgeCount,
after: finalFrame.bridgeCount,
},
finalRootClasses: finalFrame.rootClasses,
}
}
async function main() {
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: 1600 },
deviceScaleFactor: 2,
})
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (error) => {
pageErrors.push(String(error))
})
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
const url = `http://127.0.0.1:${port}/diff-ux?style=background&scenario=line-info-reference&theme=vitesse-light&appearance=light&unchangedStyle=line-info`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.editor-card', { timeout: 20000 })
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
await page.waitForSelector('.stream-monaco-diff-unchanged-overlay', {
timeout: 20000,
})
await page.waitForTimeout(1400)
const results = []
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'appearance-light-to-dark',
await collectTransition(page, async () => {
await page.getByRole('button', { name: /^Dark$/ }).click()
}),
{
expectedRootClass: 'stream-monaco-diff-appearance-dark',
requireGeometryStable: true,
expectLuminance: 'dark',
requireModifiedScrollStable: true,
expectedVisibleWidgetHeight: 32,
},
),
)
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'appearance-dark-to-light',
await collectTransition(page, async () => {
await page.getByRole('button', { name: /^Light$/ }).click()
}),
{
expectedRootClass: 'stream-monaco-diff-appearance-light',
requireGeometryStable: true,
expectLuminance: 'light',
requireModifiedScrollStable: true,
expectedVisibleWidgetHeight: 32,
},
),
)
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'style-line-info-to-basic',
await collectTransition(page, async () => {
await page.getByRole('button', { name: /^Line Info Basic$/ }).click()
}),
{
expectedRootClass:
'stream-monaco-diff-unchanged-style-line-info-basic',
requireGeometryStable: true,
requireModifiedScrollStable: true,
expectedVisibleWidgetHeight: 32,
},
),
)
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'style-basic-to-metadata',
await collectTransition(page, async () => {
await page
.getByRole('button', { name: /^Metadata$/ })
.first()
.click()
}),
{
expectedRootClass: 'stream-monaco-diff-unchanged-style-metadata',
requireGeometryStable: true,
requireModifiedScrollStable: true,
expectedVisibleWidgetHeight: 32,
expectedVisibleRailCount: 0,
expectedVisibleRevealCount: 0,
requireMetadataSummaryNonInteractive: true,
},
),
)
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'set-diff-models-same-content',
await collectTransition(page, async () => {
await invokeDiffTestApi(page, 'swapModelsSameContent')
}),
{
expectedRootClass: 'stream-monaco-diff-unchanged-style-metadata',
requireGeometryStable: true,
requireModifiedScrollStable: false,
requireModifiedAnchorStable: true,
anchorBudget: 1,
},
),
)
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'set-diff-models-changed-content',
await collectTransition(page, async () => {
await invokeDiffTestApi(page, 'swapModelsChangedContent')
}),
{
expectedRootClass: 'stream-monaco-diff-unchanged-style-metadata',
requireGeometryStable: true,
requireModifiedScrollStable: false,
},
),
)
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'style-metadata-to-simple',
await collectTransition(page, async () => {
await page
.getByRole('button', { name: /^Simple$/ })
.first()
.click()
}),
{
expectedRootClass: 'stream-monaco-diff-unchanged-style-simple',
requireGeometryStable: false,
requireModifiedScrollStable: true,
expectedVisibleRailCount: 0,
expectedVisibleRevealCount: 0,
requireSimpleSummaryNonInteractive: true,
},
),
)
await browser.close()
const ok =
pageErrors.length === 0 &&
consoleErrors.length === 0 &&
results.every((result) => result.ok)
const output = {
ok,
url,
pageErrors,
consoleErrors,
results,
}
console.log(JSON.stringify(output, null, 2))
if (!ok) {
console.error('Transition validation failed; recent Vite logs:\n')
console.error(logs.slice(-60).join(''))
process.exit(1)
}
} catch (error) {
console.error('Transition validation startup/runtime error:')
console.error(error)
if (logs.length > 0) {
console.error('Recent Vite logs:\n')
console.error(logs.slice(-60).join(''))
}
throw error
} finally {
killProcessTree(vite)
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -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()
@@ -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()
@@ -0,0 +1,384 @@
#!/usr/bin/env node
// Capture the diff UX demo and compare it against a reference screenshot.
import fs from 'node:fs/promises'
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 = 5173, 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 parseScenario(value) {
if (value === 'streaming') return 'streaming'
if (value === 'line-info-reference' || value === 'line-info')
return 'line-info-reference'
return 'pierre-reference'
}
function captureSelectorForScenario(scenario) {
return scenario === 'line-info-reference'
? '.line-info-compare-frame'
: '.editor-card'
}
function viewportForScenario(scenario) {
return scenario === 'line-info-reference'
? { width: 1960, height: 1600 }
: { width: 1960, height: 720 }
}
function parseUnchangedStyle(value) {
if (value === 'line-info-basic') return 'line-info-basic'
if (value === 'simple') return 'simple'
return value === 'metadata' ? 'metadata' : 'line-info'
}
async function captureScreenshot(
browser,
port,
style,
scenario,
theme,
unchangedStyle,
outputPath,
) {
const page = await browser.newPage({
viewport: viewportForScenario(scenario),
deviceScaleFactor: 2,
})
const url = `http://127.0.0.1:${port}/diff-ux?style=${style}&scenario=${scenario}&theme=${encodeURIComponent(
theme,
)}&unchangedStyle=${encodeURIComponent(unchangedStyle)}&capture=1`
await page.goto(url, { waitUntil: 'networkidle' })
const captureSelector = captureSelectorForScenario(scenario)
await page.waitForSelector(captureSelector, { timeout: 20000 })
await page.waitForTimeout(1400)
const target = page.locator(captureSelector)
await target.screenshot({ path: outputPath })
const stats = await page.evaluate(() => {
const values = Array.from(
document.querySelectorAll('.file-stats .delta'),
).map((node) => node.textContent?.trim() ?? '')
return {
deltas: values,
title: document.querySelector('.file-name')?.textContent?.trim() ?? '',
}
})
await page.close()
return { outputPath, url, captureSelector, unchangedStyle, stats }
}
async function compareImages(
browser,
actualPath,
referencePath,
diffPath,
threshold,
) {
const [actualBase64, referenceBase64] = await Promise.all([
fs.readFile(actualPath, 'base64'),
fs.readFile(referencePath, 'base64'),
])
const page = await browser.newPage({
viewport: { width: 1200, height: 900 },
})
await page.setContent('<!doctype html><html><body></body></html>')
const result = await page.evaluate(
async ({ actualBase64, referenceBase64, threshold }) => {
const loadImage = (src) =>
new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () =>
reject(new Error(`Failed to load image: ${src.slice(0, 32)}...`))
img.src = src
})
const actual = await loadImage(`data:image/png;base64,${actualBase64}`)
const reference = await loadImage(
`data:image/png;base64,${referenceBase64}`,
)
const width = Math.max(actual.width, reference.width)
const height = Math.max(actual.height, reference.height)
const actualCanvas = document.createElement('canvas')
actualCanvas.width = width
actualCanvas.height = height
const actualCtx = actualCanvas.getContext('2d')
actualCtx.clearRect(0, 0, width, height)
actualCtx.drawImage(actual, 0, 0)
const referenceCanvas = document.createElement('canvas')
referenceCanvas.width = width
referenceCanvas.height = height
const referenceCtx = referenceCanvas.getContext('2d')
referenceCtx.clearRect(0, 0, width, height)
referenceCtx.drawImage(reference, 0, 0)
const diffCanvas = document.createElement('canvas')
diffCanvas.width = width
diffCanvas.height = height
const diffCtx = diffCanvas.getContext('2d')
const actualData = actualCtx.getImageData(0, 0, width, height).data
const referenceData = referenceCtx.getImageData(0, 0, width, height).data
const diffImage = diffCtx.createImageData(width, height)
let diffPixels = 0
let totalDelta = 0
let maxChannelDelta = 0
for (let i = 0; i < actualData.length; i += 4) {
const ar = actualData[i]
const ag = actualData[i + 1]
const ab = actualData[i + 2]
const aa = actualData[i + 3]
const rr = referenceData[i]
const rg = referenceData[i + 1]
const rb = referenceData[i + 2]
const ra = referenceData[i + 3]
const deltaR = Math.abs(ar - rr)
const deltaG = Math.abs(ag - rg)
const deltaB = Math.abs(ab - rb)
const deltaA = Math.abs(aa - ra)
const pixelDelta = deltaR + deltaG + deltaB + deltaA
totalDelta += pixelDelta
maxChannelDelta = Math.max(
maxChannelDelta,
deltaR,
deltaG,
deltaB,
deltaA,
)
const outIndex = i
const baseR = Math.round(rr * 0.82 + ar * 0.18)
const baseG = Math.round(rg * 0.82 + ag * 0.18)
const baseB = Math.round(rb * 0.82 + ab * 0.18)
if (pixelDelta > threshold) {
diffPixels += 1
diffImage.data[outIndex] = 255
diffImage.data[outIndex + 1] = Math.max(80, Math.round(baseG * 0.45))
diffImage.data[outIndex + 2] = 160
diffImage.data[outIndex + 3] = 255
continue
}
diffImage.data[outIndex] = baseR
diffImage.data[outIndex + 1] = baseG
diffImage.data[outIndex + 2] = baseB
diffImage.data[outIndex + 3] = Math.max(40, ra)
}
diffCtx.putImageData(diffImage, 0, 0)
return {
width,
height,
sameDimensions:
actual.width === reference.width &&
actual.height === reference.height,
actualSize: { width: actual.width, height: actual.height },
referenceSize: { width: reference.width, height: reference.height },
diffPixels,
totalPixels: width * height,
mismatchRatio: diffPixels / (width * height),
meanChannelDelta: totalDelta / (width * height * 4),
maxChannelDelta,
diffDataUrl: diffCanvas.toDataURL('image/png'),
}
},
{ actualBase64, referenceBase64, threshold },
)
const diffBase64 = result.diffDataUrl.replace(/^data:image\/png;base64,/, '')
await fs.writeFile(diffPath, Buffer.from(diffBase64, 'base64'))
await page.close()
const { diffDataUrl: _diffDataUrl, ...metrics } = result
return {
...metrics,
diffPath,
exactMatch: metrics.diffPixels === 0,
}
}
async function run() {
const referenceArg = process.argv[2]
if (!referenceArg) {
throw new Error(
'Usage: node scripts/playwright-diff-ux-compare.mjs <reference.png> [background|bar] [pierre-reference|streaming|line-info-reference] [actual.png] [diff.png] [pixel-threshold] [theme] [line-info|line-info-basic|metadata|simple]',
)
}
const styleArg = process.argv[3] === 'bar' ? 'bar' : 'background'
const scenarioArg = parseScenario(process.argv[4])
const referencePath = path.resolve(referenceArg)
const actualPath = process.argv[5]
? path.resolve(process.argv[5])
: path.join('/tmp', `stream-monaco-diff-ux-${scenarioArg}-${styleArg}.png`)
const diffPath = process.argv[6]
? path.resolve(process.argv[6])
: path.join(
'/tmp',
`stream-monaco-diff-ux-${scenarioArg}-${styleArg}-diff.png`,
)
const rawThreshold = Number(process.argv[7] ?? '28')
const threshold = Number.isFinite(rawThreshold) ? rawThreshold : 28
const themeArg = process.argv[8] || 'snazzy-light'
const unchangedStyleArg = parseUnchangedStyle(process.argv[9])
const port = process.env.PORT
? Number(process.env.PORT)
: await findFreePort()
await fs.access(referencePath)
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)
let browser
try {
await waitForPort(port)
browser = await chromium.launch({ headless: true })
const capture = await captureScreenshot(
browser,
port,
styleArg,
scenarioArg,
themeArg,
unchangedStyleArg,
actualPath,
)
const comparison = await compareImages(
browser,
actualPath,
referencePath,
diffPath,
threshold,
)
await browser.close()
console.log(
JSON.stringify(
{
ok: true,
style: styleArg,
scenario: scenarioArg,
theme: themeArg,
unchangedStyle: unchangedStyleArg,
threshold,
referencePath,
actualPath,
diffPath,
capture,
comparison,
},
null,
2,
),
)
} catch (error) {
if (browser) {
try {
await browser.close()
} catch {}
}
console.error('Diff UX comparison failed.')
console.error(logs.slice(-40).join(''))
throw error
} finally {
killProcessTree(vite)
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,295 @@
#!/usr/bin/env node
// Capture a deterministic screenshot of the diff UX demo for visual review.
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 = 5173, 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 === 'line-info-reference' || value === 'line-info')
return 'line-info-reference'
return 'pierre-reference'
}
function captureSelectorForScenario(scenario) {
return scenario === 'line-info-reference'
? '.line-info-compare-frame'
: '.editor-card'
}
function viewportForScenario(scenario) {
return scenario === 'line-info-reference'
? { width: 1960, height: 1600 }
: { width: 1960, height: 720 }
}
function parseUnchangedStyle(value) {
if (value === 'line-info-basic') return 'line-info-basic'
if (value === 'simple') return 'simple'
return value === 'metadata' ? 'metadata' : 'line-info'
}
async function run() {
const styleArg = process.argv[2] === 'bar' ? 'bar' : 'background'
const outputArg = process.argv[3]
const scenarioArg = parseScenario(process.argv[4])
const themeArg = process.argv[5] || 'snazzy-light'
const linesArg = process.argv[6] || '6'
const unchangedStyleArg = parseUnchangedStyle(process.argv[7])
const port = process.env.PORT
? Number(process.env.PORT)
: await findFreePort()
const outputPath = outputArg
? path.resolve(outputArg)
: path.join(
'/tmp',
`stream-monaco-diff-ux-${scenarioArg}-${themeArg}-${styleArg}.png`,
)
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: viewportForScenario(scenarioArg),
deviceScaleFactor: 2,
})
const url = `http://127.0.0.1:${port}/diff-ux?style=${styleArg}&scenario=${scenarioArg}&theme=${encodeURIComponent(
themeArg,
)}&lines=${encodeURIComponent(
linesArg,
)}&unchangedStyle=${encodeURIComponent(unchangedStyleArg)}&capture=1`
await page.goto(url, { waitUntil: 'networkidle' })
const captureSelector = captureSelectorForScenario(scenarioArg)
await page.waitForSelector(captureSelector, { timeout: 20000 })
await page.waitForTimeout(1400)
const target = page.locator(captureSelector)
await target.screenshot({ path: outputPath })
const stats = 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,
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,
}
}
const values = Array.from(
document.querySelectorAll('.file-stats .delta'),
).map((node) => node.textContent?.trim() ?? '')
return {
deltas: values,
title: document.querySelector('.file-name')?.textContent?.trim() ?? '',
gutter: {
original: getPaneMetrics('.editor.original'),
modified: getPaneMetrics('.editor.modified'),
},
}
})
await browser.close()
console.log(
JSON.stringify(
{
ok: true,
style: styleArg,
scenario: scenarioArg,
theme: themeArg,
lines: linesArg,
unchangedStyle: unchangedStyleArg,
captureSelector,
outputPath,
url,
stats: {
...stats,
gutter:
stats.gutter?.original && stats.gutter?.modified
? {
original: {
marginWidth: round(stats.gutter.original.marginWidth),
numberBoxWidth: round(
stats.gutter.original.numberBoxWidth,
),
visibleLineNumber:
stats.gutter.original.visibleLineNumber,
boxLeftGap: round(stats.gutter.original.boxLeftGap),
boxRightGap: round(stats.gutter.original.boxRightGap),
textStripeGap: round(stats.gutter.original.textStripeGap),
textCodeGap: round(stats.gutter.original.textCodeGap),
symmetryDelta: round(
Math.abs(
stats.gutter.original.textStripeGap -
stats.gutter.original.textCodeGap,
),
),
},
modified: {
marginWidth: round(stats.gutter.modified.marginWidth),
numberBoxWidth: round(
stats.gutter.modified.numberBoxWidth,
),
visibleLineNumber:
stats.gutter.modified.visibleLineNumber,
boxLeftGap: round(stats.gutter.modified.boxLeftGap),
boxRightGap: round(stats.gutter.modified.boxRightGap),
textStripeGap: round(stats.gutter.modified.textStripeGap),
textCodeGap: round(stats.gutter.modified.textCodeGap),
symmetryDelta: round(
Math.abs(
stats.gutter.modified.textStripeGap -
stats.gutter.modified.textCodeGap,
),
),
},
}
: null,
},
},
null,
2,
),
)
} catch (error) {
console.error('Diff UX screenshot capture failed.')
console.error(logs.slice(-40).join(''))
throw error
} finally {
killProcessTree(vite)
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,288 @@
#!/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'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const markstreamRoot = '/Users/Simon/Github/markstream-vue'
const playgroundDir = path.join(markstreamRoot, 'playground')
const host = '127.0.0.1'
const node23Bin = '/Users/Simon/.local/share/fnm/node-versions/v23.11.0/installation/bin'
function isPortOpen(port) {
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 = 4236, end = 4280) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeout = 30000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeout) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
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 resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function colorLuminance(color) {
const channels = String(color || '').match(/\d+(?:\.\d+)?/g)
if (!channels || channels.length < 3)
return null
const [r, g, b] = channels.slice(0, 3).map(Number)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
async function snapshot(page) {
return page.evaluate(() => {
function visibleTexts(selector) {
return Array.from(document.querySelectorAll(selector))
.filter((node) => {
if (!(node instanceof HTMLElement))
return false
const text = node.innerText?.trim() ?? ''
if (!text)
return false
const style = window.getComputedStyle(node)
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number.parseFloat(style.opacity || '1') > 0.05
})
.map(node => node.innerText.trim())
}
const container = document.querySelector('.code-block-container')
const header = document.querySelector('.code-block-header')
const diffRoot = document.querySelector('.stream-monaco-diff-root')
const editorBackground = diffRoot?.querySelector('.monaco-editor .monaco-editor-background')
const themeSelect = document.querySelector('select[aria-label="Code block theme"]')
const settingsPanel = document.querySelector('.settings-toggle')?.parentElement
const containerStyle = container instanceof HTMLElement ? window.getComputedStyle(container) : null
const headerStyle = header instanceof HTMLElement ? window.getComputedStyle(header) : null
const editorStyle = editorBackground instanceof HTMLElement ? window.getComputedStyle(editorBackground) : null
return {
containerClass: container instanceof HTMLElement ? container.className : '',
rootClasses: diffRoot instanceof HTMLElement ? Array.from(diffRoot.classList) : [],
containerBg: containerStyle?.backgroundColor || '',
headerBg: headerStyle?.backgroundColor || '',
editorBg: editorStyle?.backgroundColor || '',
selectedTheme: themeSelect instanceof HTMLSelectElement ? themeSelect.value : '',
settingsOpen: settingsPanel instanceof HTMLElement
? settingsPanel.querySelector('select[aria-label="Code block theme"]') instanceof HTMLElement
: false,
visibleNativeCompactTexts: visibleTexts('.diff-hidden-lines-compact .text'),
visibleNativeCenterTexts: visibleTexts('.diff-hidden-lines .center'),
visibleHiddenLinesInBody: /hidden lines/i.test(document.body.innerText || ''),
}
})
}
async function openSettings(page) {
const toggle = page.locator('.settings-toggle').first()
await toggle.waitFor({ state: 'visible', timeout: 30000 })
const select = page.locator('select[aria-label="Code block theme"]')
if (await select.count()) {
const visible = await select.first().isVisible().catch(() => false)
if (visible)
return
}
await toggle.click()
await select.first().waitFor({ state: 'visible', timeout: 30000 })
}
async function toggleDarkMode(page) {
await openSettings(page)
const darkModeRow = page.locator('label', { hasText: 'Dark Mode' }).locator('..')
const button = darkModeRow.locator('button').first()
await button.waitFor({ state: 'visible', timeout: 30000 })
await button.click()
}
async function captureTransition(page) {
const frames = []
for (let i = 0; i < 72; i++) {
await page.waitForTimeout(16)
frames.push(await snapshot(page))
}
return frames
}
async function ensureSelectedTheme(page, value) {
await openSettings(page)
const select = page.locator('select[aria-label="Code block theme"]').first()
await select.selectOption(value)
await page.waitForTimeout(1200)
}
async function main() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
const vite = spawn(
'pnpm',
['-C', playgroundDir, 'dev', '--host', host, '--port', String(port), '--strictPort'],
{
cwd: repoRoot,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
PATH: `${node23Bin}:${process.env.PATH || ''}`,
},
},
)
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 page = await browser.newPage({
viewport: { width: 1600, height: 1200 },
colorScheme: 'light',
})
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (error) => {
pageErrors.push(String(error))
})
page.on('console', (message) => {
if (message.type() === 'error')
consoleErrors.push(message.text())
})
await page.addInitScript(() => {
localStorage.setItem('vmr-settings-selected-theme', 'vitesse-dark')
localStorage.setItem('vueuse-color-scheme', 'light')
})
await page.goto(`http://${host}:${port}/`, { waitUntil: 'networkidle' })
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
await page.waitForTimeout(1600)
await ensureSelectedTheme(page, 'vitesse-dark')
const initial = await snapshot(page)
await toggleDarkMode(page)
const toDarkFrames = await captureTransition(page)
const afterDark = toDarkFrames[toDarkFrames.length - 1] ?? await snapshot(page)
await toggleDarkMode(page)
const toLightFrames = await captureTransition(page)
const afterLight = toLightFrames[toLightFrames.length - 1] ?? await snapshot(page)
await browser.close()
const allFrames = [initial, ...toDarkFrames, ...toLightFrames]
const anyHiddenFlash = allFrames.some(frame =>
frame.visibleHiddenLinesInBody
|| frame.visibleNativeCompactTexts.length > 0
|| frame.visibleNativeCenterTexts.some(text => /hidden lines/i.test(text)),
)
const initialLum = colorLuminance(initial.containerBg)
const darkLum = colorLuminance(afterDark.containerBg)
const lightLum = colorLuminance(afterLight.containerBg)
const result = {
ok:
initial.selectedTheme === 'vitesse-dark'
&& initial.rootClasses.includes('stream-monaco-diff-appearance-light')
&& initialLum != null && initialLum > 170
&& afterDark.rootClasses.includes('stream-monaco-diff-appearance-dark')
&& darkLum != null && darkLum < 90
&& afterLight.rootClasses.includes('stream-monaco-diff-appearance-light')
&& lightLum != null && lightLum > 170
&& !anyHiddenFlash
&& pageErrors.length === 0
&& consoleErrors.length === 0,
initial,
afterDark,
afterLight,
anyHiddenFlash,
pageErrors,
consoleErrors,
}
console.log(JSON.stringify(result, null, 2))
if (!result.ok)
process.exitCode = 1
}
catch (error) {
const message = error instanceof Error ? error.stack || error.message : String(error)
console.error(message)
process.exitCode = 1
}
finally {
cleanup()
}
}
main()
@@ -0,0 +1,237 @@
#!/usr/bin/env node
// Measure Monaco load/render/stream timings using the streaming-demo.
import net from 'node:net'
import process from 'node:process'
import { spawn } from 'node:child_process'
import path from 'node:path'
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 = 5173, 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 {
if (child.pid)
process.kill(-child.pid, 'SIGTERM')
else
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (child.pid)
process.kill(-child.pid, 'SIGKILL')
else if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function waitForExit(child, ms = 5000) {
return new Promise((resolve) => {
if (!child)
return resolve(true)
if (child.exitCode != null)
return resolve(true)
const timer = setTimeout(() => resolve(false), ms)
child.once('exit', () => {
clearTimeout(timer)
resolve(true)
})
})
}
function spanFor(entries) {
if (!entries.length)
return null
const start = Math.min(...entries.map(e => e.startTime))
const end = Math.max(...entries.map(e => e.startTime + e.duration))
return { count: entries.length, start, end, duration: end - start }
}
function markMap(marks) {
const map = {}
for (const m of marks || [])
map[m.name] = m.t
return map
}
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', demoDir, 'dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env, detached: true },
)
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 url = `http://127.0.0.1:${port}/`
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
await page.goto(url, { waitUntil: 'networkidle' })
// Wait for key marks (best-effort)
await page.waitForFunction(() => {
const m = (window).__streamMonacoPerf?.marks || []
return m.some(x => x.name === 'createEditor_done')
}, null, { timeout: 20000 })
await page.waitForFunction(() => {
const m = (window).__streamMonacoPerf?.marks || []
return m.some(x => x.name === 'stream_done')
}, null, { timeout: 60000 })
const pageResult = await page.evaluate(() => {
const perf = (window).__streamMonacoPerf || { marks: [] }
const marks = perf.marks || []
const nav = performance.getEntriesByType('navigation')[0]
const resources = performance.getEntriesByType('resource')
const by = (re) => resources.filter(r => re.test(r.name))
const monaco = by(/monaco-editor/i)
const shiki = by(/shiki|@shikijs/i)
const workers = by(/worker\.js|workerMain\.js/i)
return {
url: location.href,
marks,
longTasks: perf.longTasks || 0,
nav: nav ? {
startTime: nav.startTime,
domContentLoaded: nav.domContentLoadedEventEnd,
loadEvent: nav.loadEventEnd,
} : null,
resourceSpans: {
monaco: monaco.length ? {
count: monaco.length,
start: Math.min(...monaco.map(e => e.startTime)),
end: Math.max(...monaco.map(e => e.startTime + e.duration)),
} : null,
shiki: shiki.length ? {
count: shiki.length,
start: Math.min(...shiki.map(e => e.startTime)),
end: Math.max(...shiki.map(e => e.startTime + e.duration)),
} : null,
workers: workers.length ? {
count: workers.length,
start: Math.min(...workers.map(e => e.startTime)),
end: Math.max(...workers.map(e => e.startTime + e.duration)),
} : null,
},
}
})
await browser.close()
const m = markMap(pageResult.marks)
const durations = {
to_monaco_dom: m.monaco_dom ?? null,
to_view_lines_text: m.view_lines_text ?? null,
to_view_lines_text_growth: m.view_lines_text_growth ?? null,
to_create_editor_done: m.createEditor_done ?? null,
create_editor_duration: (m.createEditor_done != null && m.createEditor_start != null)
? (m.createEditor_done - m.createEditor_start)
: null,
to_first_stream_update: m.stream_first_update ?? null,
to_stream_done: m.stream_done ?? null,
stream_duration: (m.stream_done != null && m.stream_first_update != null)
? (m.stream_done - m.stream_first_update)
: null,
}
const spans = pageResult.resourceSpans
const spansOut = {
monaco: spans.monaco
? { ...spans.monaco, duration: spans.monaco.end - spans.monaco.start }
: null,
shiki: spans.shiki
? { ...spans.shiki, duration: spans.shiki.end - spans.shiki.start }
: null,
workers: spans.workers
? { ...spans.workers, duration: spans.workers.end - spans.workers.start }
: null,
}
const out = {
ok: true,
url: pageResult.url,
longTasks: pageResult.longTasks,
nav: pageResult.nav,
durations,
resourceSpans: spansOut,
marks: pageResult.marks,
}
console.log(JSON.stringify(out, null, 2))
}
catch (err) {
console.error(err)
console.error('Recent Vite logs:\n', logs.slice(-40).join(''))
process.exitCode = 1
}
finally {
killProcessTree(vite)
await waitForExit(vite, 5000)
}
}
run().catch((err) => {
console.error(err)
process.exit(1)
})
@@ -0,0 +1,86 @@
// Simulate EditorManager append/flush logic to reproduce duplication bug
function createSim() {
let lastKnownCode = ''
let pendingUpdate = null
const appendBuffer = []
let appendBufferScheduled = false
function appendCode(s) {
appendBuffer.push(s)
if (!appendBufferScheduled) appendBufferScheduled = true
}
function flushAppendBuffer(model) {
if (!appendBufferScheduled) return
appendBufferScheduled = false
const text = appendBuffer.join('')
appendBuffer.length = 0
model.value += text
}
function flushPendingUpdate(model) {
if (!pendingUpdate) return
const newCode = pendingUpdate
pendingUpdate = null
const prevCode = lastKnownCode ?? model.value
if (prevCode === newCode) return
if (newCode.startsWith(prevCode) && prevCode.length < newCode.length) {
const suffix = newCode.slice(prevCode.length)
if (suffix) appendCode(suffix)
lastKnownCode = newCode
return
}
model.value = newCode
lastKnownCode = newCode
}
function updateCode(code) {
pendingUpdate = code
// emulate raf -> we won't auto flush here; test harness controls flush timing
}
return { updateCode, flushPendingUpdate, flushAppendBuffer, getState: () => ({ lastKnownCode, appendBuffer: appendBuffer.slice() }) }
}
const markdown = `
# Create Vue project
npm create vue@latest electron-vue-chat
# Navigate to project
cd electron-vue-chat
# Install dependencies
npm install
npm install electron electron-builder vue-router
# Install dev dependencies
npm install -D electron-dev-server concurrently wait-on
`
function run(simulateFrameEvery) {
const sim = createSim()
const model = { value: '' }
let contents = ''
let i = 0
const len = markdown.length
let frames = 0
while (i <= len) {
i++
contents = markdown.slice(0, i)
sim.updateCode(contents)
if (i % simulateFrameEvery === 0 || i === len) {
// flush pending and apply appendBuffer
sim.flushPendingUpdate(model)
sim.flushAppendBuffer(model)
frames++
}
}
return { final: model.value, frames }
}
console.log('simulate flush every 1 (per char):', run(1))
console.log('simulate flush every 5:', run(5))
console.log('simulate flush every 10:', run(10))
console.log('simulate flush every 50:', run(50))
console.log('simulate flush every 200:', run(200))
console.log('simulate flush every 1000:', run(1000))
@@ -0,0 +1,146 @@
import { performance } from 'node:perf_hooks'
function sleep(ms) {
return new Promise(r => setTimeout(r, ms))
}
function createMockEditor() {
let value = ''
return {
getModel() {
return {
getValue() { return value },
setValue(v) { value = v },
getLineCount() { return value.split('\n').length },
getLanguageId() { return 'javascript' },
getLineMaxColumn(line) { return (value.split('\n')[line - 1] || '').length + 1 },
}
},
getValue() { return value },
executeEdits(_, edits) {
for (const e of edits) {
value += e.text
}
},
getOption() { return false },
}
}
function createWrapper(opts = {}) {
const appendBuffer = []
let appendBufferScheduled = false
let pendingUpdate = null
let lastKnownCode = ''
const throttleMs = opts.updateThrottleMs ?? 0
let lastFlushTime = 0
let throttleTimer = null
const editorView = createMockEditor()
function flushPendingUpdate() {
lastFlushTime = Date.now()
if (!pendingUpdate)
return
const model = editorView.getModel()
const { code } = pendingUpdate
pendingUpdate = null
// Build prevCode including buffered (mirror recent EditorManager fix)
const buffered = appendBuffer.length > 0 ? appendBuffer.join('') : ''
const prevCode = (appendBuffer.length > 0) ? (editorView.getValue() + buffered) : (lastKnownCode || editorView.getValue())
if (prevCode === code)
return
if (code.startsWith(prevCode) && prevCode.length < code.length) {
const suffix = code.slice(prevCode.length)
if (suffix)
appendCode(suffix)
lastKnownCode = code
return
}
// fallback full replace
model.setValue(code)
lastKnownCode = code
}
function flushAppendBuffer() {
if (appendBuffer.length === 0)
return
const parts = appendBuffer.splice(0)
const text = parts.join('')
appendBuffer.length = 0
editorView.executeEdits('append', [{ range: null, text }])
// sync lastKnownCode
try { lastKnownCode = editorView.getModel().getValue() } catch {}
}
function scheduleAppendFlush() {
if (appendBufferScheduled)
return
appendBufferScheduled = true
// emulate raf frame
setTimeout(() => {
appendBufferScheduled = false
flushAppendBuffer()
}, 0)
}
function appendCode(appendText) {
if (!appendText) return
appendBuffer.push(appendText)
scheduleAppendFlush()
}
function updateCode(code) {
pendingUpdate = { code }
// emulate RAF-like immediate schedule
setImmediate(() => {
if (!throttleMs) { flushPendingUpdate(); return }
const now = Date.now()
const since = now - lastFlushTime
if (since >= throttleMs) { flushPendingUpdate(); return }
if (throttleTimer != null) return
const wait = throttleMs - since
throttleTimer = setTimeout(() => { throttleTimer = null; setImmediate(flushPendingUpdate) }, wait)
})
}
return { updateCode, appendCode, flushAppendBuffer, editorView }
}
async function run({ iterations = 200, blockSize = 50, intervalMs = 1, throttleMs = 0 }) {
const wrapper = createWrapper({ updateThrottleMs: throttleMs })
let expected = ''
const block = 'x'.repeat(blockSize)
const start = performance.now()
for (let i = 0; i < iterations; i++) {
expected += block
wrapper.updateCode(expected)
await sleep(intervalMs)
}
// wait for pending timers and flushes
await sleep(500 + throttleMs)
const end = performance.now()
const finalValue = wrapper.editorView.getModel().getValue()
const ok = finalValue === expected
console.log(JSON.stringify({ iterations, blockSize, intervalMs, throttleMs, ok, expectedLength: expected.length, finalLength: finalValue.length, elapsedMs: Math.round(end - start) }, null, 2))
if (!ok) {
// print small diff info
let i = 0
while (i < Math.min(finalValue.length, expected.length) && finalValue[i] === expected[i]) i++
console.log('first mismatch at index', i)
console.log('expected slice', expected.slice(Math.max(0, i - 40), i + 40))
console.log('actual slice', finalValue.slice(Math.max(0, i - 40), i + 40))
}
}
if (process.argv.includes('--help')) {
console.log('Usage: node scripts/stream-benchmark-aggressive.mjs [iterations] [blockSize] [intervalMs] [throttleMs]')
process.exit(0)
}
const iterations = Number(process.argv[2] || 200)
const blockSize = Number(process.argv[3] || 50)
const intervalMs = Number(process.argv[4] || 1)
const throttleMs = Number(process.argv[5] || 0)
run({ iterations, blockSize, intervalMs, throttleMs }).catch(err => { console.error(err); process.exit(1) })
@@ -0,0 +1,142 @@
// Lightweight benchmark for updateCode throttling and batching logic.
// This script mocks a minimal subset of Monaco editor API used by useMonaco
// and exercises updateCode at high frequency to compare CPU/time behavior.
import { performance } from 'node:perf_hooks'
// Load the compiled source if available, otherwise load TS source via ts-node is out of scope.
// We'll directly import the source file (ESM) assuming Node can load it; however the module
// depends on 'monaco-shim' and browser APIs. Instead, we'll directly benchmark the
// core flush/update logic by copying minimal parts into this script as a function.
// For simplicity, we simulate a minimal "editor wrapper" with similar logic to useMonaco
// focusing on updateCode/flushPendingUpdate/append buffering behavior.
function createMockEditor() {
let value = ''
return {
getModel() {
return {
getValue() { return value },
setValue(v) { value = v },
getLineCount() { return value.split('\n').length },
getLanguageId() { return 'javascript' },
getLineMaxColumn(line) { return (value.split('\n')[line - 1] || '').length + 1 },
}
},
getValue() { return value },
executeEdits(_, edits) {
for (const e of edits) {
// append-only benchmark: assume appends only
value += e.text
}
},
getOption() { return false },
}
}
// Minimal wrapper implementing only the paths used by the benchmark
function createWrapper(opts = {}) {
const appendBuffer = []
const appendBufferScheduled = false
let pendingUpdate = null
let lastKnownCode = ''
const throttleMs = opts.updateThrottleMs ?? 50
let lastFlushTime = 0
let throttleTimer = null
const editorView = createMockEditor()
function flushPendingUpdate() {
lastFlushTime = Date.now()
if (!pendingUpdate)
return
const model = editorView.getModel()
const { code } = pendingUpdate
pendingUpdate = null
const prev = lastKnownCode || model.getValue()
if (prev === code)
return
if (code.startsWith(prev)) {
const suffix = code.slice(prev.length)
if (suffix) {
appendBuffer.push(suffix)
}
lastKnownCode = code
return
}
// fallback full replace
model.setValue(code)
lastKnownCode = code
}
function flushAppendBuffer() {
if (appendBuffer.length === 0)
return
const text = appendBuffer.join('')
appendBuffer.length = 0
editorView.executeEdits('append', [{ range: null, text }])
}
function updateCode(code) {
pendingUpdate = { code }
// schedule via RAF-like (here immediate) then apply throttle
// emulate raf by setImmediate
setImmediate(() => {
if (!throttleMs) {
flushPendingUpdate(); return
}
const now = Date.now()
const since = now - lastFlushTime
if (since >= throttleMs) { flushPendingUpdate(); return }
if (throttleTimer != null)
return
const wait = throttleMs - since
throttleTimer = setTimeout(() => { throttleTimer = null; setImmediate(flushPendingUpdate) }, wait)
})
}
return { updateCode, flushAppendBuffer, editorView }
}
// Run benchmark: send N updates at freq Hz
async function run({ updates = 2000, freqHz = 200, throttleMs = 50 }) {
const wrapper = createWrapper({ updateThrottleMs: throttleMs })
const intervalMs = 1000 / freqHz
const sent = 0
const start = performance.now()
for (let i = 0; i < updates; i++) {
wrapper.updateCode(''.padEnd(i + 1, 'a'))
// busy wait to respect frequency
const t0 = performance.now()
while (performance.now() - t0 < intervalMs) { /* noop */ }
}
// allow pending timers to flush
await new Promise(r => setTimeout(r, throttleMs + 100))
const end = performance.now()
const finalValue = wrapper.editorView.getModel().getValue()
const elapsed = end - start
const avgMsPerUpdate = elapsed / updates
const out = {
updates,
freqHz,
throttleMs,
elapsedMs: Math.round(elapsed),
avgMsPerUpdate: avgMsPerUpdate.toFixed(3),
finalLength: finalValue.length,
}
console.log(JSON.stringify(out, null, 2))
}
if (process.argv.includes('--help')) {
console.log('Usage: node scripts/stream-benchmark.mjs [updates] [freqHz] [throttleMs]')
process.exit(0)
}
const updates = Number(process.argv[2] || 2000)
const freqHz = Number(process.argv[3] || 200)
const throttleMs = Number(process.argv[4] || 50)
run({ updates, freqHz, throttleMs }).catch((err) => { console.error(err); process.exit(1) })
@@ -0,0 +1,30 @@
// Invoked on the commit-msg git hook by simple-git-hooks.
import { readFileSync } from 'node:fs'
import process from 'node:process'
import colors from 'picocolors'
// get $1 from commit-msg script
const msgPath = process.argv[2]
const msg = readFileSync(msgPath, 'utf-8').trim()
const releaseRE = /^v\d/
const commitRE
= /^(?:revert: )?(?:feat|fix|docs|dx|refactor|perf|test|workflow|build|ci|chore|types|wip|release|deps)(?:\(.+\))?!?: .{1,50}/
if (!releaseRE.test(msg) && !commitRE.test(msg)) {
console.log()
console.error(
` ${colors.bgRed(colors.white(' ERROR '))} ${colors.red(
'invalid commit message format.',
)}\n\n${colors.red(
' Proper commit message format is required for automated changelog generation. Examples:\n\n',
)} ${colors.green('feat: add \'comments\' option')}\n`
+ ` ${colors.green(
'fix: handle events on blur (close #28)',
)}\n\n${colors.red(
' See .github/commit-convention.md for more details.\n',
)}`,
)
process.exit(1)
}