Files
ycode/scripts/table-ui-regression.cjs
T

135 lines
15 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.
const fs=require('node:fs/promises'),path=require('node:path'),assert=require('node:assert/strict')
const output=path.resolve('.tmp/table-ui-regression')
const experiment=process.env.YCODE_TABLE_DISABLE_LCD_EXPERIMENT==='1'
const layerCase=process.env.YCODE_TABLE_LAYER_CASE||'baseline'
assert.ok(['baseline','source-scroll-flow','source-scroll-no-container','source-clip-flow'].includes(layerCase))
const suffix=(layerCase==='baseline'?'':'-'+layerCase)+(experiment?'-lcd-disabled-experiment':'')
if(!process.versions.electron){
;(async()=>{
await fs.mkdir(output,{recursive:true})
const markdown='| 路径 | 持久化状态 | 内容 |\n| --- | --- | --- |\n| 取消 | `cancelled` ✓ | 0 字符 |\n| 正常 | `done` ✓ | 「收到」 |'
const sourceLayout=JSON.parse(await fs.readFile('.tmp/rea-assessment-initial/layout.json','utf8'))
assert.equal(sourceLayout.evidenceId,'ev_d6e6920ff4e47f73a182fac5312c5f02393366a035445d18c8a6733a8c8a11ed')
const contentNodes=sourceLayout.message_content.nodes
const paragraph=contentNodes.filter(node=>node.node_index>=1622&&node.node_index<1630&&node.node_name==='#text').map(node=>node.text).join('')
const sourceMarkdown='✅ **取消链路修复并双验证通过**:\n\n'+markdown+'\n\n'+paragraph
const originalFixture=`<div data-table-native-source-flow style={{position:'absolute',inset:0,transform:'translateY(11220px)'}}><div className="chat-pane cr-theme" data-cr-theme="light" style={{position:'absolute',left:324.28125,top:-10834.671875,width:436.703125,height:'auto',minHeight:0,display:'block',overflow:'visible'}}><MarkdownContent content={source} onQuote={text=>window.qa.quoted.push(text)}/></div></div>`
const sourceFixture=`<div className="chat-pane cr-theme" data-cr-theme="light" style={{position:'absolute',inset:0,display:'block',overflow:'visible'}}>
<div className="cr-message-list-viewport" data-source-node="772" style={{position:'absolute',left:284.28125,top:94,width:526.703125,height:639}}>
<div className="cr-message-list cr-message-list--virtual-anchored" data-source-scroll data-source-node="773" style={{overflow:${JSON.stringify(layerCase==='source-clip-flow'?'hidden':'auto')}}}>
<div data-source-node="774" className="cr-message-list__content cr-message-list__content--revealed" style={{height:15886.828125,paddingLeft:40,paddingRight:40,maxWidth:832,...(${JSON.stringify(layerCase)}==='source-clip-flow'?{position:'relative',top:-15247,width:516.703125}: {})}}>
<div className="cr-document" data-source-node="775" style={{height:15886.828125}}><div className="cr-document__virtual-spacer" data-source-node="776" style={{height:15886.828125}}>
<div className="cr-document__virtual-flow" data-source-node="777" style={{height:4666.828125,transform:'translateY(11220px)'}}><div style={{height:4666.828125}}><div style={{height:4666.828125}}>
<div style={{height:4189.328125}}/>
<div className="cr-document__virtual-item cr-document__virtual-item--flow" data-source-node="1545" style={{height:477.5}}>
<div className="cr-frame cr-frame--left" style={{height:477.5,paddingTop:16,paddingBottom:16}}><div className="cr-frame__content" style={{height:445.5}}><div className="cr-agent" style={{height:445.5}}>
<div style={{height:36.5}}/><div className="cr-agent__body-row" style={{height:409}}><div className="cr-agent__body" style={{height:409}}><div className="cr-agent__content" style={{height:293.75}}>
<div style={{height:26.25}}/><div className="cr-text-block cr-text-block--spaced-top" style={{marginTop:12}}><MarkdownContent content={sourceMarkdown} onQuote={text=>window.qa.quoted.push(text)}/></div>
</div></div></div>
</div></div></div>
</div>
</div></div></div>
</div></div>
</div>
</div>
</div>
</div>`
const entry=`import React from 'react';import {createRoot} from 'react-dom/client';
import MarkdownContent from './src/renderer/src/components/MarkdownContent';
import {ReactMarkdown} from './src/renderer/src/workbuddy/markdown/ReactMarkdown';import {ReactMarkdownProvider} from './src/renderer/src/workbuddy/markdown/context';import {createMarkdownParser} from './src/renderer/src/workbuddy/markdown/parser';
import './src/renderer/src/workbuddy/typography/tokens.css';import './src/renderer/src/styles/tokens.css';import './src/renderer/src/styles/app.css';
import './src/renderer/src/workbuddy/conversation/source-styles.css';import './src/renderer/src/workbuddy/conversation/source-adapters.css';
const source=${JSON.stringify(markdown)};
const sourceMarkdown=${JSON.stringify(sourceMarkdown)};
window.qa={quoted:[],opened:[]};const root=createRoot(document.getElementById('root'));
root.render(${layerCase==='baseline'?originalFixture:sourceFixture});
window.qa.prepareSourceLayers=()=>{const scroll=document.querySelector('[data-source-scroll]');if(scroll&&${JSON.stringify(layerCase)}!=='source-clip-flow')scroll.scrollTop=15247};
window.qa.mountPath=()=>root.render(<div className="chat-pane cr-theme" data-cr-theme="light" style={{position:'absolute',left:324,top:385,width:200,height:'auto',display:'block',overflow:'visible'}}><ReactMarkdownProvider value={{mdInstance:createMarkdownParser({loading:true}),requestId:'table-path-fixture',resolveCode:async()=> 'file',pathClickHandler:{onPathClick:(path)=>window.qa.opened.push(path)}}}><ReactMarkdown isFinished>{'| file |\\n| --- |\\n| '+String.fromCharCode(96)+'C:/fixture/very-long-demo-file-to-check-horizontal-scroll.ts'+String.fromCharCode(96)+' |'}</ReactMarkdown></ReactMarkdownProvider></div>);
`
await require('esbuild').build({stdin:{contents:entry,loader:'tsx',resolveDir:process.cwd()},outfile:path.join(output,'fixture.js'),bundle:true,platform:'browser',format:'iife',jsx:'automatic',logLevel:'silent',loader:{'.woff':'file','.woff2':'file','.ttf':'file'}})
if(layerCase!=='baseline'){
const css=await fs.readFile('.tmp/rea-css-recovered/src-CyXHA0IC.css','utf8'),rules=[]
assert.equal(require('node:crypto').createHash('sha256').update(css).digest('hex'),'a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe')
require('postcss').parse(css).walkRules(rule=>{if(!/^\.cr-document(?:\b|__virtual)|^\.cr-message-list/.test(rule.selector)||rule.selector.includes('__hydration'))return;const copy=rule.clone();if(layerCase==='source-scroll-no-container')copy.walkDecls('container-type',decl=>decl.remove());rules.push(copy.toString())})
await fs.appendFile(path.join(output,'fixture.css'),'\n/* Pinned REA ancestor rules, fixture only. */\n'+rules.join('\n'))
}
await fs.writeFile(path.join(output,'index.html'),'<!doctype html><html data-theme="light"><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>html,body,#root{width:1336px;height:901px;margin:0;background:white}</style><div id="root"></div><script src="fixture.js"></script></html>')
const env={...process.env};delete env.ELECTRON_RUN_AS_NODE
const result=require('node:child_process').spawnSync(require('electron'),[__filename],{env,windowsHide:true,stdio:'inherit',timeout:45000})
process.exitCode=result.status??1
})().catch(error=>{console.error(error);process.exitCode=1})
}else{
const {app,BrowserWindow,nativeImage}=require('electron')
app.setPath('userData',path.join(output,'user-data'))
app.disableHardwareAcceleration()
app.commandLine.appendSwitch('use-angle','swiftshader')
app.commandLine.appendSwitch('disable-gpu-compositing')
if(experiment)app.commandLine.appendSwitch('disable-lcd-text')
app.whenReady().then(async()=>{
const errors=[],win=new BrowserWindow({x:0,y:0,width:1336,height:901,useContentSize:true,frame:false,show:false,webPreferences:{contextIsolation:true,nodeIntegration:false,offscreen:false,backgroundThrottling:false}})
win.webContents.on('console-message',event=>{if(event.level==='error')errors.push(event.message)})
await win.loadFile(path.join(output,'index.html'))
await new Promise(resolve=>setTimeout(resolve,450))
await win.webContents.executeJavaScript('window.qa.prepareSourceLayers()')
await new Promise(resolve=>setTimeout(resolve,50))
const metrics=await win.webContents.executeJavaScript(`(()=>{const root=document.querySelector('.cr-table-block');const selectors=['.cr-table-block__inner','table','th','td','code'];const styles=['display','font-family','font-size','font-weight','line-height','color','background-color','border-radius','border-top-width','border-top-color','padding-top','padding-right','padding-bottom','padding-left'];return {nodes:Object.fromEntries(selectors.map(selector=>{const e=root.querySelector(selector),c=getComputedStyle(e);return [selector,{bounds:e.getBoundingClientRect().toJSON(),styles:Object.fromEntries(styles.map(key=>[key,c.getPropertyValue(key)]))}]})),rows:[...root.querySelectorAll('tr')].map(e=>e.getBoundingClientRect().height),cell:root.querySelector('tbody tr td:nth-child(2)').textContent}})()`)
assert.equal(metrics.nodes['.cr-table-block__inner'].styles['border-radius'],'12px')
assert.equal(metrics.nodes.th.styles['padding-top'],'8px')
assert.equal(metrics.nodes.th.styles['padding-right'],'12px')
assert.equal(metrics.nodes.code.styles['background-color'],'rgba(0, 0, 0, 0.05)')
assert.equal(metrics.cell,'cancelled ✓')
const source=JSON.parse(await fs.readFile('.tmp/rea-assessment-initial/layout.json','utf8'))
assert.equal(source.evidenceId,'ev_d6e6920ff4e47f73a182fac5312c5f02393366a035445d18c8a6733a8c8a11ed')
const sourceNodes=source.layout.documents[0].nodes
for(const [selector,index] of [['.cr-table-block__inner',1592],['table',1593],['th',1596],['td',1604],['code',1607]]){
const expected=sourceNodes.find(node=>node.node_index===index),actual=metrics.nodes[selector]
assert.deepEqual([actual.bounds.x,actual.bounds.y,actual.bounds.width,actual.bounds.height],expected.bounds,selector+' source bounds')
for(const [name,value] of Object.entries(actual.styles))assert.equal(value,expected.styles[name],selector+' source '+name)
}
const ancestorGeometry=layerCase==='baseline'?[]:await win.webContents.executeJavaScript("[...document.querySelectorAll('[data-source-node]')].map(e=>({index:Number(e.dataset.sourceNode),bounds:e.getBoundingClientRect().toJSON(),transform:getComputedStyle(e).transform,overflow:getComputedStyle(e).overflow,containerType:getComputedStyle(e).containerType,scrollTop:e.scrollTop}))")
for(const actual of ancestorGeometry){
const expected=sourceNodes.find(node=>node.node_index===actual.index)
assert.deepEqual([actual.bounds.x,actual.bounds.y,actual.bounds.width,actual.bounds.height],expected.bounds,'source ancestor '+actual.index+' bounds')
if(actual.index===777)assert.equal(actual.transform,expected.styles.transform)
}
// A native source-GPU screenshot, at exactly the original REA table coordinates and panel width.
win.showInactive()
await new Promise(resolve=>setTimeout(resolve,200))
win.webContents.debugger.attach('1.3')
const capture=await Promise.race([win.webContents.debugger.sendCommand('Page.captureScreenshot',{format:'png',fromSurface:true,captureBeyondViewport:false}),new Promise((_,reject)=>setTimeout(()=>reject(Error('Own table screenshot timeout')),8000))])
const image=Buffer.from(capture.data,'base64');await fs.writeFile(path.join(output,'screen'+suffix+'.png'),image)
const original=nativeImage.createFromPath(path.resolve('.tmp/rea-assessment-initial/workbuddy.png'))
const candidate=nativeImage.createFromBuffer(image),roi={x:324,y:385,width:438,height:134}
const before=original.crop(roi).toBitmap(),after=candidate.crop(roi).toBitmap()
const textBounds=sourceNodes.filter(node=>node.node_index>=1593&&node.node_index<=1620&&node.node_name==='#text').map(node=>node.bounds)
let changed=0,changedOutsideText=0,max=0,sum=0;const outsideTextSamples=[]
for(let i=0;i<before.length;i+=4){let different=false;for(let k=0;k<3;k++){const delta=Math.abs(before[i+k]-after[i+k]);max=Math.max(max,delta);sum+=delta;if(delta)different=true}if(different){changed++;const x=roi.x+(i/4)%roi.width,y=roi.y+Math.floor((i/4)/roi.width);if(!textBounds.some(b=>x>=Math.floor(b[0])-1&&x<Math.ceil(b[0]+b[2])+1&&y>=Math.floor(b[1])-1&&y<Math.ceil(b[1]+b[3])+1)){changedOutsideText++;if(outsideTextSamples.length<8)outsideTextSamples.push({x,y,reference:[...before.subarray(i,i+3)],candidate:[...after.subarray(i,i+3)]})}}}
await fs.writeFile(path.join(output,'reference-roi.png'),original.crop(roi).toPNG())
await fs.writeFile(path.join(output,'candidate-roi'+suffix+'.png'),candidate.crop(roi).toPNG())
win.webContents.sendInputEvent({type:'mouseMove',x:600,y:402})
await new Promise(resolve=>setTimeout(resolve,180))
const quote=await win.webContents.executeJavaScript("(()=>{const e=document.querySelector('.markdown-content__table-quote'),r=e.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2),opacity:getComputedStyle(e).opacity}})()")
assert.equal(quote.opacity,'1')
win.webContents.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,x:quote.x,y:quote.y})
win.webContents.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,x:quote.x,y:quote.y})
await new Promise(resolve=>setTimeout(resolve,50))
assert.equal(await win.webContents.executeJavaScript('window.qa.quoted.length'),1)
await win.webContents.executeJavaScript('window.qa.mountPath()')
await new Promise(resolve=>setTimeout(resolve,100))
const pathPoint=await win.webContents.executeJavaScript("(()=>{const e=document.querySelector('td .cr-clickable-path'),r=e.getBoundingClientRect();return {x:Math.floor(r.x+10),y:Math.floor(r.y+r.height/2)}})()")
win.webContents.sendInputEvent({type:'mouseMove',...pathPoint})
win.webContents.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,...pathPoint})
win.webContents.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,...pathPoint})
await new Promise(resolve=>setTimeout(resolve,60))
assert.deepEqual(await win.webContents.executeJavaScript('window.qa.opened'),['C:/fixture/very-long-demo-file-to-check-horizontal-scroll.ts'])
assert.equal(await win.webContents.executeJavaScript("(()=>{const e=document.querySelector('.cr-table-block__scroller');e.scrollLeft=0;return !e.dispatchEvent(new WheelEvent('wheel',{deltaX:-100,bubbles:true,cancelable:true}))})()"),true,'source horizontal overscroll guard remains active')
assert.equal(errors.length,0)
const pixel={roi,pixels:roi.width*roi.height,changed,changedOutsideText,outsideTextSamples,max,meanRgbDelta:sum/(roi.width*roi.height*3),fullPixelParity:changed===0}
const gpu=await app.getGPUInfo('complete')
const report={metrics,pixel,gpu,gpuFeatureStatus:app.getGPUFeatureStatus(),errors,layerCase,ancestorGeometry,experiment:experiment?'Own-renderer disable-lcd-text negative control; original startup flag not observed':null,sourceFlow:{originalTransform:'matrix(1, 0, 0, 1, 0, 11220)',fixtureTransform:'translateY(11220px)',note:'Original virtual-flow transform retained with the fixed fixture host offset; preserves transformed ancestor painting without reproducing list virtualization.'},referenceEvidence:'ev_d6e6920ff4e47f73a182fac5312c5f02393366a035445d18c8a6733a8c8a11ed'}
await fs.writeFile(path.join(output,'report'+suffix+'.json'),JSON.stringify(report,null,2))
console.log(JSON.stringify({rows:metrics.rows,pixel,errors}))
win.destroy();app.quit()
}).catch(error=>{console.error(error);app.exit(1)})
}