Files
ycode/scripts/verify-composer-context-info.cjs
T

466 lines
41 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.
// TODO 21 验收:会话输入区的 Git 分支标签 + 上下文用量环。
// 真实隔离 YCode(真实 preload/IPC/渲染进程)+ 真实 OMP + 受控本地 OpenAI 兼容上游 + 真实 Git 仓库。
//
// 原版依据(固定 REA 归档):
// - 运行时几何/结构:ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a
// (WorkBuddy 5.8.3 会话输入区,视口 1336×902):
// 左组 `.cr-add-menu`(431,32) → `._branchLabel_jdvlk_244`(471,82×32) → `.cr-permission-setting`(561)
// 右组 `.cr-context-usage`(903,32×32) → `.cr-model-selector`(939) → 语音(1117) → 发送(1157)
// 分支标签:radius 999 / padding 5px 8px / gap 8 / max-width 188 / 字体 14/400/22;
// 图标按钮 16×16,默认分支字形,悬停换成 copy(opacity 互换);hover 背景 `--wb-bg-secondary`。
// 用量环:32×32 / radius 16;内部 16×16 SVG,track circle r=7 stroke-width 2、
// progress circle 带 `stroke-dashoffset .3s` 过渡;hover 背景 `--cr-context-usage-hover-bg,#0000000d`。
// 浮层:280 宽 / padding 16 / radius 16 / 标题「上下文用量」/ 关闭按钮 / 百分比 20/700/28 /
// 「已使用」12/20 / 进度条 8 高 / 五类图例。
// - 回收样式表:`contexts-BkjwYSZT.css`(`._branchLabel_*`)、`src-CyXHA0IC.css`(`.cr-context-usage*`)。
// - 文案:已回收 i18n 表 `chatInput.contextUsage.*`(title 上下文用量 / used 已使用 /
// tooltip "{percentage}% · {used} / {total} 上下文已使用" / needMessage / requesting / unavailable)。
// - REA 保留的 `ContextUsageDisplay` 源码进一步确认:百分比固定一位小数,token 达 1K 后
// 固定一位小数并显示为 K(例如 `20.0% · 12.8K / 64.0K`)。
//
// YCode 侧数据真实性:分支名由主进程真实执行 git;用量只用上游真实 usage(经 OMP 归一化)+
// 该回合冻结的真实上下文窗口。历史样本没有分母时保持未知,不套当前模型配置。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const { execFileSync } = require('node:child_process')
const QA_BRANCH = 'qa-branch'
/** 受控上游返回的真实用量(provider prompt_tokens 含缓存,本例无缓存)。 */
const QA_PROMPT_TOKENS = 12800
const QA_COMPLETION_TOKENS = 40
const QA_CONTEXT_WINDOW = 64000
const QA_EXPECTED_PERCENT = 20
const QA_EXPECTED_PERCENT_TEXT = `${QA_EXPECTED_PERCENT.toFixed(1)}%`
const QA_EXPECTED_USED_TEXT = '12.8K'
const QA_EXPECTED_TOTAL_TEXT = '64.0K'
const RING_CIRCUMFERENCE = 2 * Math.PI * 7
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/composer-context-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
const repo = path.join(directory, 'fixture-repo')
await fs.mkdir(data, { recursive: true })
await fs.mkdir(home, { recursive: true })
await fs.mkdir(repo, { recursive: true })
// 真实 Git 仓库:分支名固定,供分支标签读取。
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { stdio: 'pipe' })
git('init', '-b', QA_BRANCH)
git('config', 'user.email', 'qa@example.com')
git('config', 'user.name', 'QA')
await fs.writeFile(path.join(repo, 'README.md'), '# qa\n')
git('add', '.')
git('-c', 'commit.gpgsign=false', 'commit', '-m', 'init')
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').toString().trim()
assert.equal(branch, QA_BRANCH)
const now = Date.now()
const sessions = [{ id: 'context-info-session', title: 'Context Info', cwd: repo, modelId: 'local-usage', createdAt: now, updatedAt: now + 1, messages: [
{ id: 'context-user', role: 'user', content: '上下文信息验收', createdAt: now, status: 'done', requestId: 'context-run' },
{ id: 'context-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'context-run', tools: [], parts: [],
contextUsage: { usage: { inputTokens: 8000, outputTokens: 40, totalTokens: 8040 } } }
] }]
const nativePath=path.join(data,'omp-sessions','session-native-context-session.jsonl')
await fs.mkdir(path.dirname(nativePath),{recursive:true})
await fs.writeFile(nativePath,[
{id:'native-user',type:'message',message:{role:'user',content:[{type:'text',text:'old'}]}},
{id:'native-reply',parentId:'native-user',type:'message',message:{role:'assistant',model:'old-model',usage:{input:4009,cacheRead:1152,output:125,totalTokens:5286},stopReason:'stop',timestamp:now-1000}}
].map(value=>JSON.stringify(value)).join('\n'))
sessions.push({id:'native-context-session',title:'Native Usage Recovery',cwd:repo,modelId:'local-usage',createdAt:now-1000,updatedAt:now-1000,messages:[
{id:'legacy-user',role:'user',content:'old',createdAt:now-1000,status:'done',requestId:'legacy-native',nativeEntryId:'native-user',nativeSessionPath:nativePath},
{id:'legacy-reply',role:'assistant',content:'old reply',createdAt:now-900,status:'done',requestId:'legacy-native',modelId:'local-usage'}
]})
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'),
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('COMPOSER_CONTEXT_')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 300000)
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.slice(-8000))
process.exitCode = code ? 1 : 0
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow, clipboard } = require('electron')
const { createServer } = require('node:http')
const { pathToFileURL } = require('node:url')
const directory = process.env.YCODE_QA_DIRECTORY
// GPU 关闭由主进程 bundle 依据 YCODE_DISABLE_GPU 处理;这里不能重复调用(Electron 会抛错)。
process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP
app.setPath('home', path.join(directory, 'home'))
const checks = [], snapshots = {}, rendererErrors = []
let main, server, phase = 'boot'
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
async function read(source, win = main) {
let timer
try {
return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 20000)
})])
} finally { clearTimeout(timer) }
}
async function wait(source, label, win = main) {
for (let index = 0; index < 400; index++) { const value = await read(source, win); if (value) return value; await pause(40) }
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 })
}
async function hover(selector, win = main) {
const point = await read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)return null;const box=node.getBoundingClientRect();return [Math.round(box.x+box.width/2),Math.round(box.y+box.height/2)]})()`, win)
assert.ok(point, 'hover target: ' + selector)
if (!win.webContents.debugger.isAttached()) win.webContents.debugger.attach('1.3')
await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point[0], y: point[1], button: 'none' })
await pause(260)
}
const branchSource = `(()=>{const left=document.querySelector('.cr-input-toolbar__left');const pill=left&&left.querySelector('._branchLabel_jdvlk_244');
if(!left||!pill)return null;const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
const style=getComputedStyle(pill);const button=pill.querySelector('._branchLabelIconButton_jdvlk_279');
const def=pill.querySelector('._branchLabelIconDefault_jdvlk_267');const copy=pill.querySelector('._branchLabelIconCopy_jdvlk_270');
const text=pill.querySelector('._branchLabelText_jdvlk_316');const tstyle=getComputedStyle(text);
const addMenu=left.querySelector('.cr-add-menu');const permission=left.querySelector('.cr-permission-setting');
const svgDefault=def.querySelector('svg');const svgCopy=copy.querySelector('svg');
return {pill:{box:box(pill),radius:style.borderRadius,padding:style.padding,gap:style.gap,maxWidth:style.maxWidth,height:style.height,
font:style.fontSize+'/'+style.fontWeight+'/'+style.lineHeight,color:style.color,display:style.display,
background:style.backgroundColor,transition:style.transitionProperty+' '+style.transitionDuration},
text:{value:text.textContent.trim(),overflow:tstyle.overflow,textOverflow:tstyle.textOverflow,whiteSpace:tstyle.whiteSpace,font:tstyle.fontSize+'/'+tstyle.fontWeight+'/'+tstyle.lineHeight},
button:button?{box:box(button),tag:button.tagName}:null,
icons:{defaultOpacity:getComputedStyle(def).opacity,copyOpacity:getComputedStyle(copy).opacity,
defaultSvg:svgDefault?{box:box(svgDefault),viewBox:svgDefault.getAttribute('viewBox'),className:svgDefault.getAttribute('class'),paths:svgDefault.querySelectorAll('path').length}:null,
copySvg:svgCopy?{box:box(svgCopy),viewBox:svgCopy.getAttribute('viewBox'),className:svgCopy.getAttribute('class'),strokeWidth:svgCopy.getAttribute('stroke-width'),rects:svgCopy.querySelectorAll('rect').length,paths:svgCopy.querySelectorAll('path').length}:null},
order:{addMenu:addMenu?box(addMenu):null,permission:permission?box(permission):null}}})()`
const ringSource = `(()=>{const right=document.querySelector('.cr-input-toolbar__right');const ring=right&&right.querySelector('.cr-context-usage');
if(!right||!ring)return null;const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
const style=getComputedStyle(ring);const svg=ring.querySelector('svg.cr-context-usage__ring');
const track=ring.querySelector('circle:not(.cr-context-usage__ring-progress)');const progress=ring.querySelector('.cr-context-usage__ring-progress');
const wrapper=right.querySelector('.cr-context-usage-tooltip-trigger');const model=right.querySelector('.cr-model-selector');
return {ring:{box:box(ring),radius:style.borderRadius,width:style.width,height:style.height,transition:style.transitionProperty+' '+style.transitionDuration,
display:style.display,role:ring.getAttribute('role'),background:style.backgroundColor,ariaLabel:ring.getAttribute('aria-label')},
wrapper:wrapper?box(wrapper):null,
svg:svg?{box:box(svg),viewBox:svg.getAttribute('viewBox'),className:svg.getAttribute('class')}:null,
track:track?{r:track.getAttribute('r'),strokeWidth:track.getAttribute('stroke-width'),fill:track.getAttribute('fill'),stroke:getComputedStyle(track).stroke}:null,
progress:progress?{r:progress.getAttribute('r'),strokeWidth:progress.getAttribute('stroke-width'),strokeLinecap:progress.getAttribute('stroke-linecap'),
transform:progress.getAttribute('transform'),dashArray:progress.getAttribute('stroke-dasharray'),dashOffset:Number(progress.getAttribute('stroke-dashoffset')),
transition:getComputedStyle(progress).transitionProperty+' '+getComputedStyle(progress).transitionDuration,stroke:getComputedStyle(progress).stroke}:null,
firstChild:right.firstElementChild?right.firstElementChild.className:null,
modelLeft:model?box(model)[0]:null}})()`
const popoverSource = `(()=>{const popover=document.querySelector('.cr-context-usage-popover');if(!popover)return null;
const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(popover);
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';popover.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
const title=popover.querySelector('.cr-context-usage-popover__title');const close=popover.querySelector('.cr-context-usage-popover__close');
const percent=popover.querySelector('.cr-context-usage-popover__percent');const usedValue=popover.querySelector('.cr-context-usage-popover__used-value');
const usedLabel=popover.querySelector('.cr-context-usage-popover__used-label');const bar=popover.querySelector('.cr-context-usage-popover__bar');
const rows=[...popover.querySelectorAll('.cr-context-usage-popover__row')].map(row=>({name:row.querySelector('.cr-context-usage-popover__row-name')?.textContent,
value:row.querySelector('.cr-context-usage-popover__row-value')?.textContent,swatch:box(row.querySelector('.cr-context-usage-popover__swatch')).slice(2)}));
return {box:box(popover),width:style.width,padding:style.padding,borderRadius:style.borderRadius,font:style.fontSize+'/'+style.lineHeight,
background:style.backgroundColor,zIndex:style.zIndex,
title:title?{text:title.textContent,font:getComputedStyle(title).fontSize+'/'+getComputedStyle(title).fontWeight+'/'+getComputedStyle(title).lineHeight}:null,
close:close?box(close):null,
percent:percent?{text:percent.textContent,font:getComputedStyle(percent).fontSize+'/'+getComputedStyle(percent).fontWeight+'/'+getComputedStyle(percent).lineHeight}:null,
usedLabel:usedLabel?{text:usedLabel.textContent,color:getComputedStyle(usedLabel).color}:null,
usedValue:usedValue?usedValue.textContent:null,
bar:bar?{box:box(bar),height:getComputedStyle(bar).height,radius:getComputedStyle(bar).borderRadius,fillWidth:popover.querySelector('.cr-context-usage-popover__bar-fill')?.style.width}:null,
rows,bodyText:popover.textContent,tokenTertiary:probe('color','--cr-text-tertiary')}})()`
const requestingTipSource = `(()=>{const tip=document.querySelector('.cr-context-usage-requesting-tip');if(!tip)return null;
const style=getComputedStyle(tip);const text=tip.querySelector('.cr-context-usage-requesting-tip__text');
return {role:tip.getAttribute('role'),text:text?.textContent,background:style.backgroundColor,border:style.border,
borderRadius:style.borderRadius,padding:style.padding,font:style.fontSize+'/'+style.lineHeight,
popoverPresent:!!document.querySelector('.cr-context-usage-popover')}})()`
;(async () => {
// 受控上游:最后一次 chunk 带真实 usage(provider 语义,含总 prompt tokens)。
let omitUsage=false
server = createServer(async (req, res) => {
let raw = ''
for await (const part of req) raw += part
const body = JSON.parse(raw || '{}')
if (!body.stream) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'USAGE_REPLY' } }] })); return }
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
const chunk = payload => `data: ${JSON.stringify({ id: 'local-usage', object: 'chat.completion.chunk', created: 1, model: body.model, ...payload })}\n\n`
res.write(chunk({ choices: [{ index: 0, delta: { role: 'assistant', content: 'USAGE_REPLY' }, finish_reason: null }] }))
res.write(chunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
...(omitUsage?{}:{usage: { prompt_tokens: QA_PROMPT_TOKENS, completion_tokens: QA_COMPLETION_TOKENS, total_tokens: QA_PROMPT_TOKENS + QA_COMPLETION_TOKENS }}) }))
res.write('data: [DONE]\n\n')
res.end()
})
// 注意:导入主进程 bundle 之前不能 await 任何东西,否则 Electron 会先进入 ready,
// bundle 顶层的 app.disableHardwareAcceleration() 就会抛错。这里全部用同步准备。
const sync = require('node:fs')
server.listen(0, '127.0.0.1')
sync.mkdirSync(process.env.YCODE_USER_DATA_DIR, { recursive: true })
app.setPath('userData', process.env.YCODE_USER_DATA_DIR)
app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR)
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
await new Promise(resolve => server.listening ? resolve() : server.once('listening', resolve))
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`
for (let index = 0; index < 200; index++) {
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
if (main && !main.webContents.isLoading()) break
await pause(40)
}
assert.ok(main, 'main window')
main.setSize(1336, 902)
main.showInactive()
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer')
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-usage', name: 'Local Usage', providerId: 'custom',
model: 'model-usage', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true,
maxInputTokens: QA_CONTEXT_WINDOW, maxOutputTokens: 4096 })})`)
await read('window.ycode.models.select("local-usage")')
await step('conversation-with-git-workspace')
await wait(`!!document.querySelector('.cr-input-toolbar__left')`, 'composer toolbar')
await wait(`!!document.querySelector('._branchLabel_jdvlk_244')`, 'branch label')
const branchInfo = await read(branchSource)
snapshots.branch = branchInfo
check('branch label shows the real repository branch', branchInfo.text.value, QA_BRANCH)
// 原版证据里是 6 字符的 master(82 宽);宽度由分支名长度决定,这里断言固定契约 + 上限。
check('branch pill keeps the recovered geometry', [Math.round(branchInfo.pill.box[3]), branchInfo.pill.padding, branchInfo.pill.radius, branchInfo.pill.gap, branchInfo.pill.maxWidth],
[32, '5px 8px', '999px', '8px', '188px'])
check('branch pill width stays within the recovered cap', branchInfo.pill.box[2] <= 188 && branchInfo.pill.box[2] > 32, true)
check('branch text keeps 14/400/22 with ellipsis', [branchInfo.text.font, branchInfo.text.overflow, branchInfo.text.textOverflow, branchInfo.text.whiteSpace],
['14px/400/22px', 'hidden', 'ellipsis', 'nowrap'])
check('branch icon button is a 16x16 button', [branchInfo.button.tag, branchInfo.button.box[2], branchInfo.button.box[3]], ['BUTTON', 16, 16])
check('default glyph is the recovered 16x16 branch icon', [branchInfo.icons.defaultSvg.viewBox, branchInfo.icons.defaultSvg.className, branchInfo.icons.defaultSvg.paths, branchInfo.icons.defaultSvg.box[2]],
['0 0 16 16', 'wb-icon', 1, 16])
check('hover glyph is the recovered lucide copy icon', [branchInfo.icons.copySvg.viewBox, branchInfo.icons.copySvg.className, branchInfo.icons.copySvg.strokeWidth, branchInfo.icons.copySvg.rects, branchInfo.icons.copySvg.paths],
['0 0 24 24', 'lucide lucide-copy wb-icon', '1.5', 1, 1])
check('default glyph visible and copy glyph hidden before hover', [branchInfo.icons.defaultOpacity, branchInfo.icons.copyOpacity], ['1', '0'])
check('branch label sits 8px after the add menu', Math.round(branchInfo.pill.box[0] - (branchInfo.order.addMenu[0] + branchInfo.order.addMenu[2])), 8)
check('permission setting sits 8px after the branch label', Math.round(branchInfo.order.permission[0] - (branchInfo.pill.box[0] + branchInfo.pill.box[2])), 8)
await step('branch-hover-and-copy')
await hover('._branchLabel_jdvlk_244')
snapshots.branchHover = await read(`(()=>{const pill=document.querySelector('._branchLabel_jdvlk_244');
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';pill.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
return {background:getComputedStyle(pill).backgroundColor,hover:pill.matches(':hover'),
defaultOpacity:getComputedStyle(pill.querySelector('._branchLabelIconDefault_jdvlk_267')).opacity,
copyOpacity:getComputedStyle(pill.querySelector('._branchLabelIconCopy_jdvlk_270')).opacity,
secondary:getComputedStyle(pill).getPropertyValue('--wb-bg-secondary').trim(),
secondaryResolved:probe('backgroundColor','--wb-bg-secondary')}})()`)
check('hovering swaps the glyph to copy', [snapshots.branchHover.defaultOpacity, snapshots.branchHover.copyOpacity], ['0', '1'])
check('hovering paints the pill with the resolved --wb-bg-secondary', snapshots.branchHover.background, snapshots.branchHover.secondaryResolved)
check('branch pill hover state is real', snapshots.branchHover.hover, true)
clipboard.writeText('')
await read(`(()=>{document.querySelector('._branchLabelIconButton_jdvlk_279').click();return true})()`)
await pause(160)
check('clicking the glyph copies the real branch name', clipboard.readText(), QA_BRANCH)
await step('context-usage-ring')
const ring = await read(ringSource)
snapshots.ring = ring
check('usage ring keeps the recovered 32x32 / radius 16 box', [Math.round(ring.ring.box[2]), Math.round(ring.ring.box[3]), ring.ring.radius, ring.ring.role], [32, 32, '16px', 'button'])
check('usage ring is the first element of the right group', ring.firstChild.includes('cr-context-usage-tooltip-trigger'), true)
check('usage ring sits 4px before the model selector', Math.round(ring.modelLeft - (ring.ring.box[0] + ring.ring.box[2])), 4)
check('ring svg is the recovered 16x16 ring', [ring.svg.viewBox, ring.svg.className, ring.svg.box[2], ring.svg.box[3]], ['0 0 16 16', 'cr-context-usage__ring', 16, 16])
check('ring track matches the recovered circle', [ring.track.r, ring.track.strokeWidth, ring.track.fill], ['7', '2', 'none'])
check('ring progress circle matches the recovered geometry', [ring.progress.r, ring.progress.strokeWidth, ring.progress.strokeLinecap, ring.progress.transform], ['7', '2', 'round', 'rotate(-90 8 8)'])
check('ring progress keeps the recovered dashoffset transition', ring.progress.transition, 'stroke-dashoffset 0.3s')
check('legacy usage without a frozen denominator keeps the ring empty',
Math.round(ring.progress.dashOffset), Math.round(RING_CIRCUMFERENCE))
await step('usage-requesting-tip-without-denominator')
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
await wait('!!document.querySelector(".cr-context-usage-popover")', 'known usage with unknown denominator')
const legacyPopover = await read(popoverSource)
snapshots.legacyPopover = legacyPopover
check('known token count is visible even without its historical window',legacyPopover.usedValue,'8.0K/ 未知')
check('legacy unknown denominator is not replaced by the current model configuration',legacyPopover.percent.text,'—')
check('legacy classifications remain unknown',legacyPopover.rows.map(row=>row.value),['—','—','—','—','—'])
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
await wait('!document.querySelector(".cr-context-usage-popover")', 'legacy popover closed')
check('clicking the ring dismisses the usage panel', await read('!document.querySelector(".cr-context-usage-popover")'), true)
await step('real-turn-usage')
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
if(!input)throw new Error('Missing composer input');
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'用量验收');
input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
await read(`(()=>{document.querySelector('.cr-input-toolbar__send').click();return true})()`)
await wait(`document.body.textContent.includes('USAGE_REPLY')`, 'controlled upstream reply')
await wait(`(()=>{const progress=document.querySelector('.cr-context-usage__ring-progress');return progress&&Number(progress.getAttribute('stroke-dashoffset'))<${RING_CIRCUMFERENCE - 1}})()`, 'ring reflects real usage')
await pause(400)
const usedRing = await read(ringSource)
snapshots.usedRing = usedRing
const expectedOffset = RING_CIRCUMFERENCE * (1 - QA_EXPECTED_PERCENT / 100)
check('ring dashoffset follows the real provider usage', Math.abs(usedRing.progress.dashOffset - expectedOffset) < 0.6, true)
check('ring stroke uses the recovered progress colour', usedRing.progress.stroke, 'rgb(156, 163, 175)')
check('ring track uses the recovered track colour', usedRing.track.stroke, 'rgba(128, 128, 128, 0.2)')
check('ring accessible label uses the same recovered compact usage sentence', usedRing.ring.ariaLabel,
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
await step('usage-tooltip')
await hover('.cr-context-usage')
const tooltip = await read(`(()=>{const node=document.querySelector('.cr-context-usage-tooltip');if(!node)return null;
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';node.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
const style=getComputedStyle(node);return {text:node.querySelector('.wb-tooltip__content')?.textContent,role:node.getAttribute('role'),
background:style.backgroundColor,color:style.color,borderRadius:style.borderRadius,padding:style.padding,font:style.fontSize+'/'+style.lineHeight,
arrow:!!node.querySelector('.wb-tooltip__arrow'),tokenBg:probe('backgroundColor','--wb-tooltip-bg'),tokenFg:probe('color','--wb-tooltip-text-color')}})()`)
snapshots.tooltip = tooltip
check('hover tooltip uses the recovered one-decimal and compact-token formatting', tooltip?.text,
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
check('tooltip keeps the recovered base surface', [tooltip.background, tooltip.color, tooltip.borderRadius, tooltip.padding, tooltip.font, tooltip.arrow],
[tooltip.tokenBg || 'rgba(0, 0, 0, 0.75)', tooltip.tokenFg || 'rgb(255, 255, 255)', '8px', '4px 8px', '12px/20px', true])
await step('usage-popover-with-real-numbers')
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
await wait('!!document.querySelector(".cr-context-usage-popover")', 'popover with usage')
await pause(200)
const usedPopover = await read(popoverSource)
snapshots.usedPopover = usedPopover
check('popover keeps the recovered one-decimal percentage', usedPopover.percent.text, QA_EXPECTED_PERCENT_TEXT)
check('popover percent keeps 20/700/28', usedPopover.percent.font, '20px/700/28px')
check('popover keeps the recovered compact used/total pair', usedPopover.usedValue,
`${QA_EXPECTED_USED_TEXT}/ ${QA_EXPECTED_TOTAL_TEXT}`)
check('popover used label keeps the recovered copy', [usedPopover.usedLabel.text, usedPopover.usedLabel.color], ['已使用', usedPopover.tokenTertiary])
check('popover bar keeps the recovered 8px track', [Math.round(usedPopover.bar.box[3]), usedPopover.bar.height, usedPopover.bar.radius], [8, '8px', '3px'])
const prepared = await read(`window.ycode.session.messages('context-info-session').then(messages=>{const m=messages.filter(item=>item.role==='assistant').at(-1);return {breakdown:m?.contextBreakdown,requestId:m?.requestId}})`)
snapshots.preparedInput=prepared
check('real OMP provider hook records this request, not an unrelated historical sample',
[prepared.breakdown?.scope,prepared.breakdown?.requestId,prepared.breakdown?.method],['provider-request',prepared.requestId,'utf8-estimate'])
check('classification contains actual system, tool definitions, dialogue and skill catalogue',
['system','tools','conversation','skills'].every(key=>prepared.breakdown?.counts[key]>0),true)
check('category sum is independent from controlled upstream count',prepared.breakdown?.inputTokens!==QA_PROMPT_TOKENS,true)
check('classification bar uses its own independent local estimate',Math.abs(parseFloat(usedPopover.bar.fillWidth)-Math.min(100,prepared.breakdown.inputTokens/QA_CONTEXT_WINDOW*100))<0.01,true)
check('popover legend keeps the five recovered categories', usedPopover.rows.map(row => row.name),
['系统提示词', '工具及子智能体', '对话消息', '连接器及MCP', '技能'])
check('legend swatches keep the recovered 8px dots', usedPopover.rows.map(row => row.swatch), [[8, 8], [8, 8], [8, 8], [8, 8], [8, 8]])
check('legend values are explicitly labeled local estimates',usedPopover.rows.every(row=>row.value.startsWith('≈')),true)
await fs.writeFile(path.join(directory,'context-breakdown-light.png'),(await main.webContents.capturePage()).toPNG())
await step('frozen-window-survives-model-config-change')
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-usage', previousId: 'local-usage', name: 'Local Usage', providerId: 'custom',
model: 'model-usage', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true,
maxInputTokens: QA_CONTEXT_WINDOW * 2, maxOutputTokens: 4096 })})`)
await pause(250)
const frozenRing = await read(ringSource)
snapshots.frozenWindow = frozenRing
check('historical usage keeps its own frozen denominator after model config changes',
frozenRing.ring.ariaLabel,
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
check('historical usage does not silently inherit the newly configured model window',
Math.abs(frozenRing.progress.dashOffset - expectedOffset) < 0.6, true)
await step('dark-theme-via-native-settings')
// 真实路径:设置窗口切成深色,聊天区 `data-cr-theme` 随之变化,输入区 token 跟着切。
const lightTokens = await read(`(()=>{const popover=document.querySelector('.cr-context-usage-popover');const pill=document.querySelector('._branchLabel_jdvlk_244');
return {popoverBackground:getComputedStyle(popover).backgroundColor,bar:getComputedStyle(popover.querySelector('.cr-context-usage-popover__bar')).backgroundColor,
pillColor:getComputedStyle(pill).color,scope:document.querySelector('.chat-pane')?.dataset.crTheme ?? null}})()`)
await read(`window.ycode.app.openSettings('appearance')`)
let settings
for (let index = 0; index < 200; index++) {
settings = BrowserWindow.getAllWindows().find(win => win !== main)
if (settings && !settings.webContents.isLoading()) break
await pause(25)
}
assert.ok(settings, 'settings window')
await pause(600)
snapshots.settingsWindow = await read(`({url:location.href,search:location.search,bodyClass:document.body.className,rootHtml:(document.querySelector('#root')?.innerHTML||'').slice(0,240)})`, settings)
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
await read(`(()=>{const tab=document.querySelector('[data-settings-tab="appearance"]');if(tab)tab.click();return true})()`, settings)
await wait(`!!document.querySelector('.appearance-card')`, 'appearance panel', settings)
await read(`(()=>{const node=document.querySelector('.appearance-card[aria-label="深色"]');if(!node)throw new Error('missing dark option');node.click();return true})()`, settings)
await wait(`document.documentElement.dataset.theme==='dark'`, 'main window dark theme')
await wait(`document.querySelector('.chat-pane')?.dataset.crTheme==='dark'`, 'composer dark scope')
await pause(250)
const darkPopover = await read(`(()=>{const popover=document.querySelector('.cr-context-usage-popover');const pill=document.querySelector('._branchLabel_jdvlk_244');
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';popover.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
return {popoverBackground:getComputedStyle(popover).backgroundColor,pillColor:getComputedStyle(pill).color,
bar:getComputedStyle(popover.querySelector('.cr-context-usage-popover__bar')).backgroundColor,
tokenElevated:probe('backgroundColor','--cr-bg-elevated'),tokenActive:probe('backgroundColor','--cr-bg-active'),
tokenTextPrimary:probe('color','--wb-color-text-primary'),scope:document.querySelector('.chat-pane')?.dataset.crTheme ?? null}})()`)
snapshots.dark = { lightTokens, darkPopover }
await fs.writeFile(path.join(directory,'context-breakdown-dark.png'),(await main.webContents.capturePage()).toPNG())
check('native settings switches the composer to the dark scope', [lightTokens.scope, darkPopover.scope], ['light', 'dark'])
check('dark popover uses the recovered dark elevated surface', darkPopover.popoverBackground, 'rgb(36, 38, 41)')
check('dark popover bar uses the recovered dark active fill', darkPopover.bar, 'rgba(255, 255, 255, 0.1)')
check('dark pill switches to the dark primary text colour', [darkPopover.pillColor, lightTokens.pillColor, darkPopover.pillColor === darkPopover.tokenTextPrimary], ['rgb(255, 255, 255)', 'rgb(0, 0, 0)', true])
await read(`(()=>{const node=document.querySelector('.appearance-card[aria-label="浅色"]');if(!node)throw new Error('missing light option');node.click();return true})()`, settings)
await wait(`document.documentElement.dataset.theme==='light'`, 'main window light theme')
await wait(`document.querySelector('.chat-pane')?.dataset.crTheme==='light'`, 'composer light scope')
await read(`document.querySelector('.settings-window-close').click()`, settings)
for (let index = 0; index < 200 && !settings.isDestroyed(); index++) await pause(20)
check('light theme restored through the same settings window', await read(`[document.documentElement.dataset.theme,document.querySelector('.chat-pane').dataset.crTheme]`), ['light', 'light'])
await step('persisted-usage-after-renderer-reload')
await wait(`window.ycode.chat.activeRun('context-info-session').then(run=>!run)`, 'real turn persistence barrier')
const persistedUsage = await read(`window.ycode.session.messages('context-info-session').then(messages=>messages.filter(message=>message.role==='assistant').at(-1)?.contextUsage)`)
snapshots.persistedUsage = persistedUsage
check('actual turn stores its upstream input sample and original model window',
[persistedUsage?.usage?.inputTokens, persistedUsage?.contextWindow], [QA_PROMPT_TOKENS, QA_CONTEXT_WINDOW])
const reloaded = new Promise(resolve => main.webContents.once('did-finish-load', resolve))
main.webContents.reload()
await reloaded
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')===${JSON.stringify(`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)}`,
'restored usage with frozen denominator')
snapshots.reloadedUsage = await read(ringSource)
check('renderer reload recovers usage without adopting the newly doubled model window',
snapshots.reloadedUsage.ring.ariaLabel,
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
await fs.writeFile(path.join(directory, 'context-usage-restored.png'), (await main.webContents.capturePage()).toPNG())
await step('new-request-without-provider-usage')
omitUsage=true
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'NO_USAGE_NEW_REQUEST');input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
await read(`document.querySelector('.cr-input-toolbar__send').click()`)
await wait(`window.ycode.session.messages('context-info-session').then(messages=>messages.filter(m=>m.role==='user').at(-1)?.content==='NO_USAGE_NEW_REQUEST')`,'new round created')
await wait(`window.ycode.chat.activeRun('context-info-session').then(run=>!run)`,'no-usage round complete')
const noUsage=await read(`window.ycode.session.messages('context-info-session').then(messages=>messages.filter(m=>m.role==='assistant').at(-1))`)
snapshots.noUsage={contextUsage:noUsage.contextUsage,contextBreakdown:noUsage.contextBreakdown,requestId:noUsage.requestId}
check('a new no-usage request cannot inherit the previous provider total',noUsage.contextUsage??null,null)
check('the new request still retains its independently prepared classification',noUsage.contextBreakdown?.requestId,noUsage.requestId)
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')?.startsWith('≈')`,'local estimate clearly identified')
const noUsageReload=new Promise(resolve=>main.webContents.once('did-finish-load',resolve));main.webContents.reload();await noUsageReload
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')?.startsWith('≈')`,'estimate restored without reviving old provider sample')
check('reopening a no-usage round does not pick an older assistant sample',
await read(`window.ycode.session.contextUsage('context-info-session').then(info=>[!!info.sample,info.breakdown?.requestId])`),[false,noUsage.requestId])
await step('native-history-usage-recovery')
const nativeUpdatedBefore=await read(`window.ycode.session.list().then(sessions=>sessions.find(session=>session.id==='native-context-session').updatedAt)`)
await wait(`!!document.querySelector('[data-session-id="native-context-session"]')`,'legacy sidebar item')
await read(`document.querySelector('[data-session-id="native-context-session"]').click()`)
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')?.includes('5.2K / 未知')`,'native sample recovered without a fabricated denominator')
await read(`document.querySelector('.cr-context-usage').click()`)
await wait(`document.querySelector('.yc-context-source')?.textContent.includes('原生记录恢复')`,'source of recovered sample visible')
const recovered=await read(`window.ycode.session.messages('native-context-session').then(messages=>messages.at(-1).contextUsage)`)
snapshots.nativeRecovered=recovered
check('real IPC saves the recovered native sample with its provenance',
[recovered.usage.inputTokens,recovered.contextWindow??null,recovered.source,recovered.nativeEntryId],[5161,null,'native-history','native-reply'])
check('restoring usage metadata does not reorder the sidebar task',await read(`window.ycode.session.list().then(sessions=>sessions.find(session=>session.id==='native-context-session').updatedAt)`),nativeUpdatedBefore)
await fs.writeFile(path.join(directory,'context-native-recovered.png'),(await main.webContents.capturePage()).toPNG())
check('querying old records does not start a native agent',await fs.readdir(path.join(process.env.YCODE_USER_DATA_DIR,'context-usage')).then(files=>files.some(name=>name.includes('native-context-session'))),false)
check('no renderer error logs', rendererErrors, [])
server.close()
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode App/preload/IPC/renderer + real OMP kernel + controlled local OpenAI-compatible upstream + real git repository; measured branch pill, usage ring, tooltip and popover in the live window after a real turn.',
sourceEvidence: ['ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a', 'contexts-BkjwYSZT.css', 'src-CyXHA0IC.css', 'chatInput.contextUsage.*'],
snapshots, checks, rendererErrors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('COMPOSER_CONTEXT_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) console.log('COMPOSER_CONTEXT_FAILURE ' + JSON.stringify(failure))
app.exit(report.result === 'PASS' ? 0 : 1)
})().catch(async error => {
if (server) server.close()
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)
})
}