fix(B04): prevent image URI flash across initial render and detail remounts

This commit is contained in:
Codex committed 2026-10-21 15:15:29 +09:00
1 parent 0b613e522d
commit 342820690f
7 files changed
+217 -24

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ npm test
| 对话/模型/权限/折叠 | `test:alignment`、`test:conversation`;交互变更加 `test:alignment:dom` | 普通 alignment 无 DOM 依赖时明确 SKIP;严格 DOM 入口缺依赖会失败。授权回归含 mock,非真实 SDK 证明 |
| 推荐技能/安装状态 | `test:recommended-skills`、`test:recommended-skills:dom` | 前者受控安装归档;真实发布包为单列可选检查,不能把 SKIP 算通过 |
| 能力选择/使用草稿 | `test:composer-capabilities` | 不启动真实模型;完整应用事件链另验 |
| 正文/代码/数学/媒体 | `test:markdown`、`test:recovered`、`test:media` | 根据实际变更选取,不代表像素一致 |
| 正文/代码/数学/媒体 | `test:markdown`、`test:recovered`、`test:media`;图片异步/折叠变更加 `test:image-lifecycle` | 后者需 `YCODE_TEST_DOM_MODULE`;根据实际变更选取,不代表像素一致 |
| 文件/会话持久化 | `test:resources`、`test:persistence`、`test:sessions` | 先核对脚本临时目录与宿主隔离 |
| 升级/发布 | `test:updates`、`test:release` | 不等于执行 `release` 或 `updates:deploy`;发布命令单独授权 |
| 设计 token/恢复闭包 | `check:tokens`、`test:fold-summary` 等 | 非零退出必须保留;已有失败不能刷新 baseline 掩盖 |
+113
View File
@@ -0,0 +1,113 @@
/** Own-component synthetic fixture. run() supports jsdom; probe() records real rAF geometry
* only when run in a real browser. Never reads history, host files, or a provider. */
import React, { useLayoutEffect, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { flushSync } from 'react-dom'
import { MarkdownImage, type MarkdownImageProps } from '../src/renderer/src/workbuddy/images/MarkdownImage'
import MessageBubble from '../src/renderer/src/components/MessageBubble'
import '../src/renderer/src/workbuddy/conversation/conversation.css'
// Valid synthetic 320×180 PNG (all chunk CRCs checked); padding remains after IEND.
const pixel = 'iVBORw0KGgoAAAANSUhEUgAAAUAAAAC0CAIAAABqhmJGAAACwUlEQVR4nO3OQQkAMQADsEqflEk5aeehnzIIREBy7gc8KvMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1zAdALfMBUMt8ANQyHwC1H3jo+KxCh14RAAAAAElFTkSuQmCC'
const source = 'data:image/png;base64,' + btoa(atob(pixel) + '\0'.repeat(18000))
const nextSource = 'data:image/png;base64,' + btoa(atob(pixel) + '\0'.repeat(18001))
const container = document.getElementById('root')!
const root = createRoot(container)
const checks: string[] = []
const samples: object[] = []
const errors: string[] = []
const observer = new MutationObserver(records => {
for (const record of records) for (const node of record.addedNodes) {
if (node.textContent?.includes('base64,')) errors.push('URI text inserted into DOM')
}
})
observer.observe(container,{subtree:true,childList:true,characterData:true})
window.addEventListener('error', event => errors.push(event.message))
window.addEventListener('unhandledrejection', event => errors.push(String(event.reason)))
const check = (value: unknown, label: string) => { if (!value) throw Error(label); checks.push(label) }
const pause = () => new Promise<void>(resolve => setTimeout(resolve, 0))
const settle = async () => { await pause(); await pause() }
function sample(label: string) {
const text = container.textContent || ''
const images = [...container.querySelectorAll<HTMLImageElement>('img.markdown-content__image')]
const region = container.querySelector('.cr-collapse__content') || container
check(!text.includes('data:') && !text.includes('base64,') && !text.includes('AAAA'.repeat(16)), label + ': no URI text')
const value = { label, textLength: text.length, imageCount: images.length, height: region.getBoundingClientRect().height, imageHeight: images[0]?.getBoundingClientRect().height ?? 0 }
samples.push(value)
return value
}
function CommitProbe({ children, label }: { children: ReactNode; label: string }) {
useLayoutEffect(() => { sample(label + ': layout-before-passive-effects') })
return <>{children}</>
}
function mount(node: ReactNode, label: string) {
flushSync(() => root.render(<CommitProbe label={label}>{node}</CommitProbe>))
sample(label + ': sync-commit')
}
const img = () => container.querySelector<HTMLImageElement>('img.markdown-content__image')
const concise = (label: string) => { check(!img(), label + ': no image'); check((container.textContent || '').length < 160, label + ': concise'); sample(label) }
const renderImage = (props: Partial<MarkdownImageProps> & {src: string}, label: string) => mount(<MarkdownImage alt="" urlList={[]} {...props} />, label)
function deferred() { let resolve!: (s: string | undefined) => void; let reject!: (e: Error) => void; const promise = new Promise<string | undefined>((a,b) => {resolve=a;reject=b}); return {promise,resolve,reject} }
function bubble(session: string) {
return <MessageBubble key={session} sessionId={session} message={{id:session + '-message', role:'assistant', status:'done', createdAt:1, turnDurationMs:2000, parts:[{type:'reasoning', id:'synthetic-reasoning',text:'Synthetic detail'}, {type:'text',text:`![](${source})`} ]}} />
}
async function frames(label: string, count = 8) {
sample(label + ': before-rAF')
for (let i = 0; i < count; i++) await new Promise<void>(resolve => requestAnimationFrame(() => {sample(label + ': frame-' + i);resolve()}))
}
async function folds(withFrames: boolean) {
for (const session of ['synthetic-A','synthetic-B','synthetic-A']) {
mount(bubble(session),session); await settle()
for (let cycle=0;cycle<3;cycle++) {
const before = img()
const button = container.querySelector<HTMLElement>('.cr-collapse--completed > .cr-collapse__header')
check(button, 'actual CompletedFold header exists')
sample(session + ': before-toggle-' + cycle)
flushSync(() => button!.click())
sample(session + ': toggle-' + cycle)
await settle()
check(img()?.getAttribute('src') === source, 'actual MessageBubble preserves image')
check(img() !== before, 'actual CompletedFold remounts image across toggle')
if (withFrames) await frames(session + ': toggle-' + cycle)
}
}
}
async function run() {
renderImage({src:source},'cold'); check(img()?.getAttribute('src') === source,'safe first commit image'); check((samples[0] as {imageCount:number}).imageCount===1,'layout probe sees image before passive effects'); await settle(); sample('stable')
img()!.dispatchEvent(new Event('error')); await settle(); concise('native image error')
renderImage({src:nextSource},'error recovery new src'); check(img()?.getAttribute('src') === nextSource,'new safe src resets prior error synchronously')
for (const src of ['javascript:alert(1)','data:text/html,'+'A'.repeat(24000),'/synthetic/no-host.png']) {
renderImage({src},'invalid/no-host'); concise('invalid/no-host initial'); await settle(); concise('invalid/no-host settled')
}
const sameSrc='/synthetic/same.png', firstResolver=deferred(), secondResolver=deferred()
renderImage({src:sameSrc,imageUrlResolver:()=>firstResolver.promise},'same source first resolver');await settle()
renderImage({src:sameSrc,imageUrlResolver:()=>secondResolver.promise},'same source second resolver');await settle()
firstResolver.resolve(source);await settle();concise('old resolver identity ignored')
secondResolver.resolve(nextSource);await settle();check(img()?.getAttribute('src')===nextSource,'new resolver identity wins')
const local='file:///synthetic/only.png', host=deferred(), hostResolver=()=>host.promise
renderImage({src:local,imageUrlResolver:hostResolver},'local direct');check(img()?.getAttribute('src')===local,'existing unpreferred local semantics')
renderImage({src:local,imageUrlResolver:hostResolver,preferHostResolution:true},'prefer host enabled');concise('host preference removes direct file before effects');await settle()
host.resolve(source);await settle();check(img()?.getAttribute('src')===source,'preferred host result')
renderImage({src:source,imageUrlResolver:async()=>undefined},'same direct first adapter');img()!.dispatchEvent(new Event('error'));await settle();concise('direct failed before new adapter')
renderImage({src:source,imageUrlResolver:async()=>undefined},'same direct new adapter');img()!.dispatchEvent(new Event('error'));await settle();concise('immediate error after adapter reset retained')
renderImage({src:nextSource},'A to B');renderImage({src:source},'B to A retry');check(img()?.getAttribute('src')===source,'A to B to A resets old failure')
const old = deferred(), latest = deferred(); const resolved: string[] = []
const resolver = (src: string) => src.endsWith('old.png') ? old.promise : latest.promise
const onResolve = (src: string) => {resolved.push(src)}
renderImage({src:'/synthetic/old.png',imageUrlResolver:resolver,onResolve},'pending old');await settle()
renderImage({src:'/synthetic/new.png',imageUrlResolver:resolver,onResolve},'pending new');concise('no old during new');await settle()
old.resolve(source);await settle();concise('stale success ignored');check(!resolved.length,'stale success has no onResolve')
latest.resolve(nextSource);await settle();check(img()?.getAttribute('src')===nextSource,'latest result wins');check(resolved.length===1&&resolved[0].endsWith('new.png'),'latest onResolve only')
const failure=deferred();const failResolver=()=>failure.promise
renderImage({src:'/synthetic/fail.png',imageUrlResolver:failResolver},'pending failure clears previous');concise('old resolved image removed synchronously');await settle()
renderImage({src:source},'safe switch while rejection pending');failure.reject(Error('synthetic failure'));await settle();check(img()?.getAttribute('src')===source,'stale rejection cannot fail new safe image')
renderImage({src:'/synthetic/reject.png',imageUrlResolver:async()=>{throw Error('synthetic')}},'active rejection');await settle();concise('active rejection');check(container.querySelector('[aria-busy="false"]'),'failure not busy')
renderImage({src:'/synthetic/unsafe.png',imageUrlResolver:async()=> 'javascript:alert(1)'},'unsafe resolver result');await settle();concise('unsafe resolver rejected')
const abandoned=deferred();let abandonedResolve=0
renderImage({src:'/synthetic/unmount.png',imageUrlResolver:()=>abandoned.promise,onResolve:()=>abandonedResolve++},'unmount pending');await settle();mount(<span>empty</span>,'unmounted');abandoned.resolve(source);await settle();check(abandonedResolve===0,'unmounted completion ignored')
let preview: unknown; renderImage({src:source,urlList:[source],onPreviewImage:r=>{preview=r}},'preview');img()!.click();check(JSON.stringify(preview)===JSON.stringify({urlList:[source],initialIndex:0}),'safe image preview click retained')
await folds(false);check(!errors.length,'no renderer errors')
return {checks,samples,errors,limits:'DOM lifecycle only; jsdom heights are not layout acceptance. Synthetic data only.'}
}
async function probe() { await folds(true);check(img()?.complete && img()?.naturalWidth === 320,'synthetic PNG decoded in real browser');check(samples.some(s => (s as {imageHeight:number}).imageHeight > 0),'native frames include nonzero image geometry');check(samples.every(s => (s as {height:number}).height < 2000),'native region has no thousands-pixel address spike');check(!errors.length,'no renderer errors');return {checks,samples,errors,limits:'Real geometry only if executed in a real browser with fixture CSS. No user data.'} }
;(window as any).__b04 = {run,probe}
+15 -1
View File
@@ -103,7 +103,21 @@ function testMarkdownImageServerFallback(): void {
const source = 'C:\\workspace\\source-only.png'
const sourceFallback = renderToStaticMarkup(<MarkdownImage src={source} urlList={[]} />)
assert.ok(sourceFallback.includes(source))
assert.ok(!sourceFallback.includes(source), 'unresolved source is never fallback text')
assert.ok(sourceFallback.length < 500)
const synthetic = 'data:image/png;base64,' + 'A'.repeat(24000)
const first = renderToStaticMarkup(<MarkdownImage src={synthetic} alt="" urlList={[synthetic]} />)
assert.match(first, /<img /)
assert.ok(first.includes(`src="${synthetic}"`))
assert.ok(!first.includes('cr-image-block-fallback'))
assert.ok(!first.replace(/<[^>]*>/g, '').includes('data:image'))
for (const invalid of ['javascript:alert(1)', 'data:text/html,' + 'A'.repeat(24000)]) {
const markup = renderToStaticMarkup(<MarkdownImage src={invalid} alt="" urlList={[]} />)
assert.ok(markup.length < 500)
assert.ok(!markup.includes(invalid))
assert.ok(!markup.includes('<img '))
}
}
function testPreviewDialogServerStructure(): void {
+23
View File
@@ -0,0 +1,23 @@
/** Offline B04 lifecycle regression. --build-only emits a standalone own-component fixture. */
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
fs.mkdirSync('.tmp',{recursive:true})
const output=fs.mkdtempSync(path.resolve('.tmp/b04-image-'))
await require('esbuild').build({entryPoints:['scripts/b04-image-lifecycle-fixture.tsx'],outfile:path.join(output,'fixture.js'),bundle:true,platform:'browser',format:'iife',jsx:'automatic',loader:{'.woff2':'dataurl','.woff':'dataurl','.ttf':'dataurl','.svg':'dataurl','.png':'dataurl'},logLevel:'error'})
fs.writeFileSync(path.join(output,'index.html'),`<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'"><link rel="stylesheet" href="fixture.css"><style>body{margin:20px;width:800px}.markdown-content__image{max-height:400px;max-width:100%}</style><div id="root"></div><pre id="report">Running synthetic own-component frame probe…</pre><script src="fixture.js"></script><script>window.__b04.probe().then(r=>{window.__b04Report=r;document.getElementById('report').textContent=JSON.stringify(r,null,2)}).catch(e=>{window.__b04Error=String(e);document.getElementById('report').textContent=String(e)})</script>`)
if(process.argv.includes('--build-only')){console.log(JSON.stringify({result:'BUILD_ONLY',output,limits:'Not executed in a native browser; open index.html in a permitted browser for synthetic consecutive-frame probe.'}));process.exit(0)}
const {JSDOM}=require(process.env.YCODE_TEST_DOM_MODULE||process.env.YCODE_JSDOM_MODULE||'jsdom')
const dom=new JSDOM('<!doctype html><div id="root"></div>',{url:'https://fixture.invalid',runScripts:'outside-only',pretendToBeVisual:true})
const w=dom.window
w.fetch=()=>{throw Error('Fixture forbids network')}
w.ResizeObserver=class {observe(){}unobserve(){}disconnect(){}}
w.matchMedia=()=>({matches:false,addEventListener(){},removeEventListener(){}})
let timer
try {
w.eval(fs.readFileSync(path.join(output,'fixture.js'),'utf8'))
const report=await Promise.race([w.__b04.run(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('B04 fixture timed out after 20s')),20000)})])
fs.writeFileSync(path.join(output,'report.json'),JSON.stringify(report,null,2))
console.log(JSON.stringify({result:'PASS',checks:report.checks.length,samples:report.samples.length,output,limits:report.limits}))
}finally{clearTimeout(timer);dom.window.close()}
+30
View File
@@ -0,0 +1,30 @@
/** Optional, isolated own-component Electron frame probe; never launches the user's app.
* Build first: node scripts/test-image-lifecycle.mjs --build-only
* Then: electron scripts/verify-b04-image-native.cjs <output-directory>
* Requires a permitted graphical environment. Never pass --no-sandbox. */
const {app,BrowserWindow,session}=require('electron')
const fs=require('node:fs'),path=require('node:path'),os=require('node:os')
const fixture=path.resolve(process.argv[2]||'')
if(!process.argv[2]||!fs.existsSync(path.join(fixture,'index.html')))throw Error('Supply build-only fixture directory')
const profile=fs.mkdtempSync(path.join(os.tmpdir(),'ycode-b04-native-'))
app.setPath('userData',profile)
let timer,win,finished=false
function finish(error,report){
if(finished)return;finished=true;clearTimeout(timer)
const result={result:error?'FAIL':'PASS',error:error?String(error):undefined,report,limits:'Synthetic own-component Electron fixture, not the installed application or original reference UI.'}
fs.writeFileSync(path.join(fixture,'native-report.json'),JSON.stringify(result,null,2))
console.log(JSON.stringify({result:result.result,error:result.error,output:path.join(fixture,'native-report.json')}))
if(win&&!win.isDestroyed())win.destroy()
app.exit(error?1:0)
}
app.whenReady().then(async()=>{
session.defaultSession.webRequest.onBeforeRequest((details,callback)=>callback({cancel:!['file:','data:','blob:','devtools:'].some(p=>details.url.startsWith(p))}))
win=new BrowserWindow({width:1000,height:800,show:true,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true}})
win.webContents.on('render-process-gone',(_,details)=>finish(Error('Renderer gone: '+details.reason)))
timer=setTimeout(()=>finish(Error('Bounded native probe timed out after 20s')),20000)
await win.loadFile(path.join(fixture,'index.html'))
const report=await win.webContents.executeJavaScript(`new Promise((resolve,reject)=>{const check=()=>{if(window.__b04Error)return reject(Error(window.__b04Error));if(window.__b04Report)return resolve(window.__b04Report);setTimeout(check,25)};check()})`)
if(!report.samples.some(s=>s.height>0))throw Error('No nonzero native layout measured')
if(report.samples.some(s=>s.textLength>1000||s.height>2000))throw Error('Unexpected address-sized text/height spike')
finish(null,report)
}).catch(error=>finish(error))