diff --git a/package.json b/package.json index 7b00771..60beda7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:core": "npm run typecheck && npm run test:chat && npm run test:sessions && npm run test:persistence && npm run test:conversation", "test:alignment": "node scripts/run-alignment-regression.mjs", "test:alignment:dom": "node scripts/run-alignment-regression.mjs --require-dom", + "test:image-lifecycle": "node scripts/test-image-lifecycle.mjs", "test:permissions:loopback": "node scripts/build-full-access-loopback-kernel.mjs && node scripts/test-full-access-loopback.mjs --kernel", "test:reporting": "node --test scripts/regression-reporting.test.mjs", "test:capability-slash:ui": "node scripts/test-capability-slash-ui.mjs", diff --git a/scripts/README.md b/scripts/README.md index 5b3c25e..6ad9e79 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 掩盖 | diff --git a/scripts/b04-image-lifecycle-fixture.tsx b/scripts/b04-image-lifecycle-fixture.tsx new file mode 100644 index 0000000..198d8c4 --- /dev/null +++ b/scripts/b04-image-lifecycle-fixture.tsx @@ -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(resolve => setTimeout(resolve, 0)) +const settle = async () => { await pause(); await pause() } +function sample(label: string) { + const text = container.textContent || '' + const images = [...container.querySelectorAll('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({node})) + sample(label + ': sync-commit') +} +const img = () => container.querySelector('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 & {src: string}, label: string) => mount(, label) +function deferred() { let resolve!: (s: string | undefined) => void; let reject!: (e: Error) => void; const promise = new Promise((a,b) => {resolve=a;reject=b}); return {promise,resolve,reject} } +function bubble(session: string) { + return +} +async function frames(label: string, count = 8) { + sample(label + ': before-rAF') + for (let i = 0; i < count; i++) await new Promise(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('.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(empty,'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} diff --git a/scripts/image-renderer-regression.tsx b/scripts/image-renderer-regression.tsx index 3c1dfbd..bc87a44 100644 --- a/scripts/image-renderer-regression.tsx +++ b/scripts/image-renderer-regression.tsx @@ -103,7 +103,21 @@ function testMarkdownImageServerFallback(): void { const source = 'C:\\workspace\\source-only.png' const sourceFallback = renderToStaticMarkup() - 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() + assert.match(first, /]*>/g, '').includes('data:image')) + for (const invalid of ['javascript:alert(1)', 'data:text/html,' + 'A'.repeat(24000)]) { + const markup = renderToStaticMarkup() + assert.ok(markup.length < 500) + assert.ok(!markup.includes(invalid)) + assert.ok(!markup.includes('
Running synthetic own-component frame probe…
`) +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('
',{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()} diff --git a/scripts/verify-b04-image-native.cjs b/scripts/verify-b04-image-native.cjs new file mode 100644 index 0000000..ec8e7df --- /dev/null +++ b/scripts/verify-b04-image-native.cjs @@ -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 + * 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)) diff --git a/src/renderer/src/workbuddy/images/MarkdownImage.tsx b/src/renderer/src/workbuddy/images/MarkdownImage.tsx index 144ccd8..e85ca43 100644 --- a/src/renderer/src/workbuddy/images/MarkdownImage.tsx +++ b/src/renderer/src/workbuddy/images/MarkdownImage.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useState, type ReactElement } from 'react' +import { memo, useEffect, useMemo, useState, type ReactElement } from 'react' import './image-renderer.css' import { isSafeImageUrl, @@ -20,7 +20,13 @@ export interface MarkdownImageProps { } /** REA ev_717fa5e… src-DbE1_mgM.js [721564,722288); URL resolver is an explicit host adapter. */ -export const MarkdownImage = memo(function MarkdownImage({ +export const MarkdownImage = memo(function MarkdownImage(props: MarkdownImageProps): ReactElement { + // A changed source owns a fresh lifecycle and DOM image, including A → B → A. + // This also prevents the browser retaining the previous decoded image while loading. + return +}) + +function MarkdownImageSource({ src, alt = '', urlList, @@ -29,35 +35,40 @@ export const MarkdownImage = memo(function MarkdownImage({ preferHostResolution = false, onPreviewImage }: MarkdownImageProps): ReactElement { - const [resolvedUrl, setResolvedUrl] = useState() - const [failed, setFailed] = useState(false) + const request = useMemo(() => ({ src, imageUrlResolver, preferHostResolution, onResolve }), + [src, imageUrlResolver, preferHostResolution, onResolve]) + const [resolution, setResolution] = useState<{ + request: typeof request + url?: string + failed?: boolean + }>(() => ({ request })) + const directUrl = isSafeImageUrl(src) && !(preferHostResolution && requiresHostImageResolution(src)) + ? src : undefined + // Reset this component before committing children, not in a passive effect: + // an immediate image error must belong to the new request too. + if (resolution.request !== request) setResolution({ request }) + // Never display a previous resolver's result, even before effect cleanup runs. + const current = resolution.request === request ? resolution : undefined + const resolvedUrl = directUrl ?? current?.url + const failed = current?.failed === true useEffect(() => { let cancelled = false - setFailed(false) + if (directUrl) return - if (isSafeImageUrl(src) && !(preferHostResolution && requiresHostImageResolution(src))) { - setResolvedUrl(src) - return - } - - setResolvedUrl(undefined) + setResolution({ request }) resolveImageUrl(src, imageUrlResolver, preferHostResolution).then((resolved) => { if (cancelled) return - if (!resolved) { - setFailed(true) - return - } - setResolvedUrl(resolved) - if (resolved !== src) onResolve?.(src, resolved) + setResolution({ request, url: resolved, failed: !resolved }) + if (resolved && resolved !== src) onResolve?.(src, resolved) }).catch(() => { - if (!cancelled) setFailed(true) + if (!cancelled) setResolution({ request, failed: true }) }) return () => { cancelled = true } - }, [imageUrlResolver, onResolve, preferHostResolution, src]) + }, [directUrl, imageUrlResolver, onResolve, preferHostResolution, request, src]) if (!resolvedUrl || failed) { return ( @@ -66,7 +77,7 @@ export const MarkdownImage = memo(function MarkdownImage({ aria-busy={!failed} role="img" > - {alt || src} + {alt || (failed ? '图片加载失败' : '图片加载中…')} ) } @@ -83,9 +94,10 @@ export const MarkdownImage = memo(function MarkdownImage({ className="cr-image-block markdown-content__image" src={resolvedUrl} alt={alt} - onError={() => setFailed(true)} + onError={() => setResolution((previous) => previous.request === request + ? { request, failed: true } : previous)} onClick={openPreview} draggable={false} /> ) -}) +}