// 真实隔离 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) }) }