466 lines
41 KiB
JavaScript
466 lines
41 KiB
JavaScript
// 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)
|
||
})
|
||
}
|