Files
ycode/scripts/b04-image-lifecycle-fixture.tsx
T

114 lines
11 KiB
TypeScript
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.
/** 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}