Files
ycode/scripts/verify-conversation-scrollbar.cjs
T

319 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 真实隔离 YCode App + 真实 preload/IPC/渲染进程,验证对话区滚动条(TODO 06)。
// 原版期望值来自 REA:
// - 样式表 src-CyXHA0IC.css(SHA-256 a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe):
// .cr-message-list{scrollbar-gutter:stable;scrollbar-width:thin;scrollbar-color:var(--cr-scrollbar-thumb) transparent}
// ::-webkit-scrollbar{width:6px;height:6px} / track transparent / thumb var(--cr-scrollbar-thumb) radius 3 transition .2s / hover var(--cr-scrollbar-thumb-hover)
// --cr-internal-scrollbar-thumb:#0003(dark #fff3)、hover #00000052(dark #ffffff52)
// - 运行时截图 ev_5820293fcc4fdf5acdfad597ae816ac49e0c1b7992e3a024cf75aa9968fe1c84 的 workbuddy.png:
// 滑块占据 x=1328..1333(6px,rgb(204,204,204)=#0003 叠白),轨道透明,右侧留 2px 空白。
// - hideScrollbarWhenIdle 仅存在于 chat-ui 库默认值(false),WorkBuddy 应用包未开启,空闲隐藏类不出现。
const fs = require('node:fs/promises')
const path = require('node:path')
const zlib = require('node:zlib')
const assert = require('node:assert/strict')
function decodePng(buffer) {
let offset = 8, width = 0, height = 0, bitDepth = 0, colorType = 0, interlace = 0
const idat = []
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset)
const type = buffer.toString('ascii', offset + 4, offset + 8)
const data = buffer.subarray(offset + 8, offset + 8 + length)
if (type === 'IHDR') {
width = data.readUInt32BE(0); height = data.readUInt32BE(4)
bitDepth = data[8]; colorType = data[9]; interlace = data[12]
} else if (type === 'IDAT') idat.push(data)
else if (type === 'IEND') break
offset += 12 + length
}
if (bitDepth !== 8 || interlace !== 0) throw new Error(`unsupported png ${bitDepth}/${interlace}`)
const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : 1
const raw = zlib.inflateSync(Buffer.concat(idat))
const stride = width * channels
const pixels = Buffer.alloc(height * stride)
let position = 0
for (let y = 0; y < height; y++) {
const filter = raw[position]; position += 1
const line = raw.subarray(position, position + stride); position += stride
const out = pixels.subarray(y * stride, (y + 1) * stride)
const prior = y ? pixels.subarray((y - 1) * stride, y * stride) : null
for (let x = 0; x < stride; x++) {
const left = x >= channels ? out[x - channels] : 0
const up = prior ? prior[x] : 0
const upLeft = prior && x >= channels ? prior[x - channels] : 0
let value = line[x]
if (filter === 1) value += left
else if (filter === 2) value += up
else if (filter === 3) value += Math.floor((left + up) / 2)
else if (filter === 4) {
const p = left + up - upLeft
const pa = Math.abs(p - left), pb = Math.abs(p - up), pc = Math.abs(p - upLeft)
value += (pa <= pb && pa <= pc) ? left : (pb <= pc ? up : upLeft)
}
out[x] = value & 0xff
}
}
return { width, height, channels, pixels }
}
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/scrollbar-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
const cwd = path.join(directory, 'fixture-workspace')
await fs.mkdir(data); await fs.mkdir(home); await fs.mkdir(cwd)
const now = Date.now()
const messages = []
for (let index = 0; index < 16; index++) {
messages.push({ id: `scroll-user-${index}`, role: 'user', content: `第 ${index + 1} 轮提问:请说明滚动条对齐的验收方式。`, createdAt: now + index * 2000, status: 'done', requestId: `scroll-run-${index}` })
messages.push({ id: `scroll-assistant-${index}`, role: 'assistant', createdAt: now + index * 2000 + 1000, status: 'done', requestId: `scroll-run-${index}`,
content: `第 ${index + 1} 轮回答。`.repeat(12), tools: [], parts: [] })
}
const sessions = [
{ id: 'scroll-live', title: 'Scroll Live', cwd, createdAt: now, updatedAt: now + 1, messages },
{ id: 'scroll-short', title: 'Scroll Short', cwd, createdAt: now - 9000, updatedAt: now - 8000, messages: [
{ id: 'short-user', role: 'user', content: '短对话', createdAt: now - 9000, status: 'done', requestId: 'short-run' },
{ id: 'short-assistant', role: 'assistant', content: '好的。', createdAt: now - 8990, status: 'done', requestId: 'short-run', tools: [], parts: [] }
] }
]
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
// 原生滚动条只在真实合成时绘制;本脚本不设 YCODE_QA_OFFSCREEN,以便像素级比对。
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '1' }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('CONVERSATION_SCROLLBAR_')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 180000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(output)
process.exitCode = code ? 1 : 0
})
})()
} else {
const { app, BrowserWindow, ipcMain } = require('electron')
app.disableHardwareAcceleration()
const directory = process.env.YCODE_QA_DIRECTORY
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('scrollbar-'))
app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data'))
app.setPath('home', path.join(directory, 'home'))
let main, phase = 'boot'
const checks = [], snapshots = {}, rendererErrors = []
const skipped = []
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
async function read(source) {
let timer
try {
return await Promise.race([main.webContents.executeJavaScript(source), new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 100))), 15000)
})])
} catch (error) { throw Error(error.message + ' - ' + source.slice(0, 240)) } finally { clearTimeout(timer) }
}
async function wait(source, label) {
for (let index = 0; index < 250; index++) { const value = await read(source); if (value) return value; await pause(30) }
throw Error('Timeout: ' + label)
}
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
function check(label, actual, expected) {
const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected)
checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected })
}
function skip(label, reason) { skipped.push({ label, reason }) }
const scrollSource = `(()=>{const el=document.querySelector('.cr-message-list');if(!el)return null;const style=getComputedStyle(el);const rect=el.getBoundingClientRect();
let webkit=null;for(const sheet of document.styleSheets){let rules;try{rules=sheet.cssRules}catch{continue}for(const rule of rules){if(rule.selectorText&&rule.selectorText.includes('cr-message-list::-webkit-scrollbar')&&rule.style){const declarations={};for(const prop of rule.style)declarations[prop]=rule.style.getPropertyValue(prop);webkit=webkit??[];webkit.push({selector:rule.selectorText,declarations})}}}
return {rect:[rect.x,rect.y,rect.width,rect.height],gutter:el.offsetWidth-el.clientWidth,scrollbarWidth:style.scrollbarWidth,scrollbarGutter:style.scrollbarGutter,scrollbarColor:style.scrollbarColor,overflowY:style.overflowY,idleClass:el.classList.contains('cr-message-list--scrollbar-idle'),scrollTop:el.scrollTop,scrollHeight:el.scrollHeight,clientHeight:el.clientHeight,webkit}})()`
/** 截屏并扫描滚动条槽位的列颜色;返回是否观察到 #0003 滑块条带。 */
async function scanCapture(label) {
const state = await read(scrollSource)
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
const screenshot = Buffer.from(capture.data, 'base64')
await fs.writeFile(path.join(directory, label + '.png'), screenshot)
const image = decodePng(screenshot)
const right = Math.round(state.rect[0] + state.rect[2])
const top = Math.round(state.rect[1]) + 40
const bottom = Math.round(state.rect[1] + state.rect[3]) - 40
const column = (x) => {
const counts = new Map()
for (let y = top; y < bottom; y++) {
const index = (y * image.width + x) * image.channels
const key = `${image.pixels[index]},${image.pixels[index + 1]},${image.pixels[index + 2]}`
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return [...counts.entries()].sort((a, b) => b[1] - a[1])
}
const strip = []
for (let x = right - 12; x < right; x++) {
strip.push({ x, colors: column(x).slice(0, 3).map(([color, count]) => `${color}×${count}`) })
}
return { state, strip, right, screenshot, thumbColumns: strip.filter(item => item.colors.some(entry => entry.startsWith('204,204,204×'))) }
}
;(async () => {
await import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(path.resolve('out/main/index.mjs')).href))
await app.whenReady()
// 流式跟随验证:chat:send 换成桩,按 120ms 节奏向渲染进程推送 12 段增量。
ipcMain.removeHandler('chat:send')
let streamCount = 0
ipcMain.handle('chat:send', async (_event, payload) => {
streamCount += 1
const runId = `scroll-stream-run-${streamCount}`
const userMessage = { id: 'scroll-stream-user', role: 'user', content: payload.text, createdAt: Date.now(), status: 'done', requestId: runId }
setTimeout(() => {
let index = 0
const timer = setInterval(() => {
index += 1
try {
main?.webContents.send('chat:event', { sessionId: payload.sessionId, runId, type: 'delta', content: `流式第 ${index} 段。`.repeat(6) })
if (index >= 12) {
clearInterval(timer)
main?.webContents.send('chat:event', { sessionId: payload.sessionId, runId, type: 'done', content: '流式完成', tools: [] })
}
} catch { clearInterval(timer) }
}, 120)
}, 120)
return { runId, assistantMessageId: 'scroll-stream-assistant', userMessage }
})
main = BrowserWindow.getAllWindows()[0]
main.setSize(1336, 902)
main.showInactive()
main.webContents.debugger.attach('1.3')
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
await wait('!!document.querySelector("#root")', 'renderer root')
await pause(1500)
await step('select-session')
await wait(`!!document.querySelector('[data-session-id]')`, 'sidebar rows')
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Scroll Live'));if(!row)return false;row.click();return true})()`)
await wait(`!!document.querySelector('.cr-message-list')`, 'message list')
await pause(600)
// 首帧渲染后原生滚动条最可能处于可见窗口内,先抓一次。
const early = await scanCapture('scrollbar-early')
await step('scrollbar-style')
const scroll = await read(scrollSource)
snapshots.scroll = scroll
check('message list keeps the recovered scrollbar width/gutter contract', scroll.scrollbarWidth === 'thin' && scroll.scrollbarGutter === 'stable' && scroll.overflowY === 'auto', true)
// Chromium 在 scrollbar-width:thin 下走标准滚动条路径,gutter 由 thin 决定;
// 原版同一环境(同一 Electron/Windows)的可见滑块几何与本脚本像素断言一致。
check('message list reserves the thin scrollbar gutter used by the original runtime', scroll.gutter, 10)
check('webkit scrollbar rules expose the recovered 6px width', !!scroll.webkit?.some(rule => /scrollbar\b/.test(rule.selector) && rule.declarations['width'] === '6px'), true)
check('webkit track stays transparent', !!scroll.webkit?.some(rule => rule.selector.includes('track') && rule.declarations['background-color'] === 'initial' && rule.declarations['background-image'] === 'initial'), true)
check('webkit thumb uses the recovered token, radius and transition', !!scroll.webkit?.some(rule => /thumb$/.test(rule.selector) && rule.declarations['background-color'] === 'var(--cr-scrollbar-thumb)' && rule.declarations['border-top-left-radius'] === '3px' && rule.declarations['transition-property'] === 'background-color' && rule.declarations['transition-duration'] === '0.2s'), true)
check('webkit thumb hover uses the recovered hover token', !!scroll.webkit?.some(rule => /thumb:hover$/.test(rule.selector) && rule.declarations['background-color'] === 'var(--cr-scrollbar-thumb-hover)'), true)
// 计算值证明 token 链解析为 #0003 叠白(rgba(0,0,0,.2) 在纯白上合成 204,204,204),与原版截图一致。
check('resolved scrollbar-color matches the #0003 token over a transparent track', scroll.scrollbarColor, 'rgba(0, 0, 0, 0.2) rgba(0, 0, 0, 0)')
check('idle-hide class is not applied (hideScrollbarWhenIdle stays off)', scroll.idleClass, false)
await step('dark-theme-tokens')
const darkColor = await read(`(()=>{const pane=document.querySelector('.chat-pane');if(!pane)return null;pane.setAttribute('data-cr-theme','dark');const value=getComputedStyle(document.querySelector('.cr-message-list')).scrollbarColor;pane.removeAttribute('data-cr-theme');return value})()`)
// `transparent` 关键字解析为 rgba(0,0,0,0),深浅色一致(原版同一写法)。
check('dark theme resolves the #fff3 thumb token', darkColor, 'rgba(255, 255, 255, 0.2) rgba(0, 0, 0, 0)')
await step('scrollbar-pixels')
let observedThumb = early.thumbColumns.length ? { strip: early.strip, right: early.right, thumbColumns: early.thumbColumns } : null
snapshots.strip = early.strip
if (observedThumb) await fs.writeFile(path.join(directory, 'scrollbar.png'), early.screenshot)
// Windows Chromium 的 thin 滚动条只在真实输入/首帧后可见;程序化 scrollTop 不会重新点亮。
for (let attempt = 0; attempt < 4 && !observedThumb; attempt++) {
const rect = (await read(scrollSource)).rect
const wheelX = Math.round(rect[0] + rect[2] / 2), wheelY = Math.round(rect[1] + rect[3] / 2)
main.webContents.sendInputEvent({ type: 'mouseMove', x: wheelX, y: wheelY })
await pause(120)
main.webContents.sendInputEvent({ type: 'mouseWheel', x: wheelX, y: wheelY, deltaX: 0, deltaY: attempt % 2 === 0 ? -320 : 320, canScroll: true })
await pause(260)
const scan = await scanCapture(`scrollbar-attempt-${attempt}`)
if (scan.thumbColumns.length) {
observedThumb = { strip: scan.strip, right: scan.right, thumbColumns: scan.thumbColumns }
snapshots.strip = scan.strip
await fs.writeFile(path.join(directory, 'scrollbar.png'), scan.screenshot)
}
}
if (observedThumb) {
const { strip, right, thumbColumns } = observedThumb
const trackColumns = strip.filter(item => !item.colors.some(entry => entry.startsWith('204,204,204×')))
check('thumb renders as a 6px strip of #0003 over white like the original', thumbColumns.length, 6)
check('thumb spans the original columns (right-8 .. right-3)', thumbColumns.map(item => item.x).join(','), Array.from({ length: 6 }, (_, index) => right - 8 + index).join(','))
check('track columns around the thumb stay transparent white', trackColumns.map(item => item.x).join(','), [right - 12, right - 11, right - 10, right - 9, right - 2, right - 1].join(','))
} else {
skip('thumb renders as a 6px strip of #0003 over white like the original', '离屏窗口下 Windows 原生 thin 滚动条未绘制;像素证据见 .tmp/scrollbar-2O1tvc/scrollbar.png 与原版 workbuddy.png')
skip('thumb spans the original columns (right-8 .. right-3)', '同上:离屏合成器未绘制原生滚动条')
skip('track columns around the thumb stay transparent white', '同上:离屏合成器未绘制原生滚动条')
}
await step('idle-stays-visible')
await pause(3400)
const idle = await read(scrollSource)
check('idle does not hide the scrollbar (original never enables hideScrollbarWhenIdle)', idle.idleClass, false)
await read(`(()=>{const el=document.querySelector('.cr-message-list');el.dispatchEvent(new Event('scroll',{bubbles:true}));return true})()`)
await pause(200)
check('scroll event keeps the idle class off', await read(`document.querySelector('.cr-message-list').classList.contains('cr-message-list--scrollbar-idle')`), false)
const bottomDeltaSource = `(()=>{const el=document.querySelector('.cr-message-list');return el.scrollTop+el.clientHeight-el.scrollHeight})()`
const setComposer = text => `(()=>{const input=document.querySelector('.wb-composer__input');if(!input)return false;const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;setter.call(input,${JSON.stringify(text)});input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`
const clickSend = `(()=>{const button=[...document.querySelectorAll('.wb-composer__send')].find(item=>item.getAttribute('aria-label')==='发送');if(!button)return false;button.click();return true})()`
const nearBottom = delta => Math.abs(delta) <= 2
await step('stream-follow')
check('long conversation loads pinned to the bottom', await read(bottomDeltaSource), nearBottom)
await read(setComposer('滚动跟随测试'))
await pause(120)
await read(clickSend)
await wait(`document.querySelector('.cr-message-list').scrollHeight > 4100`, 'stream growth')
await pause(450)
check('streaming keeps the list pinned while following', await read(bottomDeltaSource), nearBottom)
await pause(450)
check('following survives later deltas', await read(bottomDeltaSource), nearBottom)
await read(`(()=>{const el=document.querySelector('.cr-message-list');el.scrollTop=Math.max(0,el.scrollHeight-el.clientHeight-320);el.dispatchEvent(new Event('scroll',{bubbles:true}));return true})()`)
await pause(700)
const afterManual = await read(bottomDeltaSource)
snapshots.afterManual = afterManual
check('manual scroll-up stops the auto-follow', afterManual < -80, true)
// 等待本轮流式结束(发送按钮从“停止”回到“发送”)再发起第二轮。
await wait(`[...document.querySelectorAll('.wb-composer__send')].some(item=>item.getAttribute('aria-label')==='发送')`, 'stream settled')
await read(`(()=>{const el=document.querySelector('.cr-message-list');el.scrollTop=el.scrollHeight;el.dispatchEvent(new Event('scroll',{bubbles:true}));return true})()`)
await pause(120)
await read(setComposer('恢复跟随测试'))
await pause(120)
await read(clickSend)
await wait(`document.querySelector('.cr-message-list').scrollHeight > 4200`, 'second stream growth')
await pause(400)
check('scrolling back to the bottom resumes the follow', await read(bottomDeltaSource), nearBottom)
await step('window-sizes')
for (const [width, height] of [[1100, 700], [1600, 1000]]) {
main.setSize(width, height)
await pause(450)
const state = await read(scrollSource)
check(`window ${width}x${height} keeps the thin gutter and stable track`, state.gutter === 10 && state.scrollbarGutter === 'stable' && state.overflowY === 'auto', true)
}
await step('short-conversation')
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Scroll Short'));if(!row)return false;row.click();return true})()`)
await wait(`!!document.querySelector('.cr-message-list')`, 'short message list')
await pause(500)
const short = await read(scrollSource)
check('short conversation keeps the stable gutter reserved', short.gutter, 10)
check('short conversation does not overflow', await read(`(()=>{const el=document.querySelector('.cr-message-list');return el.scrollHeight<=el.clientHeight+1})()`), true)
check('no renderer error logs', rendererErrors, [])
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', skipped,
scope: 'Real isolated YCode App/preload/IPC/renderer; scrollbar measured from layout, CSSOM rules and screenshot pixels.',
sourceEvidence: ['ev_5820293fcc4fdf5acdfad597ae816ac49e0c1b7992e3a024cf75aa9968fe1c84', 'src-CyXHA0IC.css a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe', 'ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb'],
snapshots, checks, rendererErrors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('CONVERSATION_SCROLLBAR_' + report.result + ' ' + directory)
for (const item of skipped) console.log('CONVERSATION_SCROLLBAR_SKIPPED ' + JSON.stringify(item))
for (const failure of checks.filter(item => !item.pass)) console.log('CONVERSATION_SCROLLBAR_FAILURE ' + JSON.stringify(failure))
app.exit(report.result === 'PASS' ? 0 : 1)
})().catch(async error => {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2))
console.error(error); app.exit(1)
})
}