forked from boos/BoosAPI
feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,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)
|
||||
})
|
||||
+368
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user