Files
ycode/scripts/verify-ui.mjs
T
xiaohan.yuan 98cf32b6e2 test(ui): restore offline renderer verification bridge
Add the startup API stubs required by the current renderer tree so home, conversation fixtures, and settings panels mount in the offline harness.
2026-10-18 18:36:43 +08:00

175 lines
12 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.
/**
* 无人值守 UI 验证:用本地静态服务器托管已构建的渲染产物,
* 注入 window.ycode 假桥(真实主进程不可用时的替身),供浏览器读取布局。
*
* 用法:node scripts/verify-ui.mjs [port]
* 打开 http://127.0.0.1:<port>/ 后用 CDP/浏览器读取 .app-shell 网格列宽。
*/
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { extname, join, normalize } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chatParityFixture } from './fixtures/chat-parity.mjs'
import { sourceRecoveryFixture } from './fixtures/source-recovery.mjs'
import { dialogueMediaFixture } from './fixtures/dialogue-media.mjs'
import { typographyFixture, typographyProbes } from './fixtures/typography.mjs'
import { conversationCompleteFixture, conversationCompleteBridge } from './fixtures/conversation-complete.mjs'
import { artifactSourceFixture } from './fixtures/artifact-source.mjs'
import { pierreDiffFixture } from './fixtures/pierre-diff.mjs'
import { conversationStatesFixture, conversationStatesBridge } from './fixtures/conversation-states.mjs'
const root = fileURLToPath(new URL('../out/renderer', import.meta.url))
const port = Number(process.argv[2] ?? 8788)
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf'
}
/**
* 假桥:只提供渲染进程首屏挂载需要的调用,其余返回空数组/空对象/undefined。
*
* 形状对齐 src/preload/index.ts 暴露的 window.ycode;主窗口(index.html)与设置窗口
* (settings.html / index.html?window=settings)共用同一份假桥。命名空间清单来自启动路径:
* App.tsx、stores/chat.ts、stores/settings.ts、ChatPane.tsx、Sidebar.tsx、
* SettingsDialog.tsx、settings.tsx、TitleBar.tsx、GeneralSettingsPanel.tsx、
* useComposerConnectors.ts、ComposerContextInfo.tsx。
*/
const STUB = `<script>
(function () {
function generalView() { return { autoLaunch: false, preventSleep: true, preventSleepActive: false, dataDir: '', dataBytes: 0, dataUsagePartial: false, disk: { totalBytes: 0, freeBytes: 0 }, workspaceRoot: '' } }
function personalizationView() { return { userCallName: '', aiName: '', tone: 'default', loadingTips: true, showFileChanges: false, customPrompt: '', persona: '', longTermMemory: '' } }
function memoryView() { return { conversationMemory: true, localMemorySkills: true, content: '' } }
function modelRegistry() { return { models: [], selectedModelId: null } }
function workspaceRegistry() { return { workspaces: [], selectedWorkspaceId: null } }
function sessionRecord(id) { return { id: id || 'offline-session', title: '新对话', createdAt: Date.now(), updatedAt: Date.now() } }
function fixtureValue(key, fallback) { var value = window.__YCODE_FIXTURE__ ? window.__YCODE_FIXTURE__[key] : undefined; return value === undefined ? fallback : value }
window.ycode = {
window: { minimize(){}, toggleMaximize(){}, close(){}, getState: async()=>({maximized:false,fullscreen:false}), onState:()=>()=>{}, onFocus:()=>()=>{} },
edit: { action: async()=>{} },
app: {
info: async()=>({name:'YCode',version:'0.1.0',electron:'37',chromium:'',node:'',configDir:'',omp:{available:false,exePath:null,profile:'',model:null}}),
openConfigDir: async()=>{},
// 原版由主进程另开设置窗口(settings.html?window=settings);离线替身在同一标签内切换。
openSettings: async(section,action)=>{ try { const url=new URL('settings.html?window=settings',location.href); if(section) url.searchParams.set('section',String(section)); if(action) url.searchParams.set('action',String(action)); location.assign(url.href) } catch {} },
settingsReady: async()=>{},
settingsRendered: async()=>{},
onSettingsNavigate: ()=>()=>{},
onSettingsClosed: ()=>()=>{},
onSettingsChanged: ()=>()=>{}
},
config: { get: async()=>({baseUrl:'',model:'',hasApiKey:false,keyStorage:'plain',maxToolRounds:8}), update: async(p)=>({...p,hasApiKey:false,keyStorage:'plain'}) },
general: { get: async()=>generalView(), update: async()=>generalView(), changeWorkspaceRoot: async()=>generalView(), openFolder: async()=>{}, openSystemNotificationSettings: async()=>{} },
personalization: { get: async()=>personalizationView(), update: async()=>personalizationView() },
agents: { get: async()=>({teamManagement:true}), update: async()=>({teamManagement:true}) },
memory: { get: async()=>memoryView(), update: async()=>memoryView(), import: async()=>memoryView() },
models: {
list: async()=>modelRegistry(), save: async()=>modelRegistry(), remove: async()=>modelRegistry(), select: async()=>modelRegistry(),
fetch: async()=>({models:[]}), test: async()=>({ok:true}), metadata: async()=>({}), metadataStatus: async()=>null, refreshMetadata: async()=>null
},
workspace: {
get: async()=>({root:'C:\\\\Users\\\\Administrator\\\\code\\\\YCode'}),
choose: async()=>({root:''}),
list: async()=>workspaceRegistry(), generate: async()=>workspaceRegistry(), attach: async()=>({attachedWorkspaceId:undefined,...workspaceRegistry()}),
select: async()=>workspaceRegistry(), rename: async()=>workspaceRegistry(), remove: async()=>workspaceRegistry(), open: async()=>{}
},
session: {
list: async()=>structuredClone(fixtureValue('sessions',[])),
listArchived: async()=>structuredClone(fixtureValue('archivedSessions',[])),
create: async()=>sessionRecord('offline-session'),
updateContext: async(id)=>sessionRecord(id),
messages: async(id)=>structuredClone(fixtureValue('messages',{})[id] ?? []),
contextUsage: async()=>({}),
rename: async()=>{}, remove: async()=>{}, setPinned: async(id)=>sessionRecord(id), setArchived: async(id)=>sessionRecord(id),
openFolder: async()=>{}, openWindow: async()=>{}
},
chat: {
send: async(payload)=>({runId:'offline-run',assistantMessageId:'offline-assistant',modelId:'',modelName:'离线假桥',userMessage:{id:'offline-user-message',role:'user',content:payload&&payload.text,createdAt:Date.now(),status:'done'}}),
cancel: async()=>{}, cancelAgent: async()=>{}, onEvent:()=>()=>{}, onStarted:()=>()=>{},
activeRun: async()=>null, inspectEdit: async()=>({editable:false,reason:'离线假桥不支持就地编辑'}),
revert: async()=>({messages:[]}), respond: async()=>({accepted:false}), feedback: async()=>{}
},
experts: { list: async()=>[], read: async()=>({}), activate: async()=>({}), remove: async()=>{}, openPath: async()=>{} },
skills: { list: async()=>[], importPath: async()=>{}, read: async()=>({}), setEnabled: async()=>({}), remove: async()=>{}, export: async()=>{} },
mcp: { list: async()=>[], save: async()=>({}), trust: async()=>({}), connect: async()=>({}), disconnect: async()=>({}), setEnabled: async()=>({}), setToolEnabled: async()=>({}), remove: async()=>{} },
projects: { list: async()=>[], create: async()=>({projectId:'offline-project',name:'离线项目',instructions:'',createdAt:0,updatedAt:0}), update: async()=>({projectId:'offline-project',name:'离线项目',instructions:'',createdAt:0,updatedAt:0}) },
automation: { list: async()=>[], listRuns: async()=>[], create: async()=>({}), update: async()=>({}), setEnabled: async()=>({}), remove: async()=>{}, runNow: async()=>({}), onChanged:()=>()=>{} },
library: { getSnapshot: async()=>({artifacts:[],generatedAt:Date.now()}), setFavorite: async()=>({version:1,favoriteArtifactIds:[],recent:[]}), recordRecent: async()=>({version:1,favoriteArtifactIds:[],recent:[]}) },
attachments: { pick: async()=>[], stage: async(input)=>({id:'offline-attachment',name:(input&&input.name)||'',mimeType:(input&&input.mimeType)||'',size:0}), preview: async()=>undefined, remove: async()=>{} },
speech: { transcribe: async()=>({text:''}) },
git: { branch: async()=>({repo:false,branch:'',unavailable:true}) },
browser: {
create: async()=>({id:'offline-browser',title:'离线浏览器',url:'about:blank'}),
get: async(id)=>({id:id,title:'',url:'about:blank',canGoBack:false,canGoForward:false,loading:false}),
navigate: async()=>({}), back: async()=>({}), forward: async()=>({}), reload: async()=>({}), bounds: async()=>{}, close: async()=>{}, rebind: async()=>{}
},
terminal: {
create: async()=>({id:'offline-terminal',title:'终端'}),
get: async(id)=>({id:id,sequence:0,exited:false}),
write: async()=>{}, resize: async()=>{}, clear: async()=>{}, close: async()=>{}, rebind: async()=>{}, onEvent:()=>()=>{}
},
resources: {
list: async()=>[],
openExternal: async(url)=>{document.documentElement.dataset.fixtureExternal=url},
classify: async(path)=>path==='src'?'directory':window.__YCODE_FIXTURE__?.files?.[path]!==undefined?'file':'unknown',
resolveImage: async(source)=>window.__YCODE_FIXTURE__?.images?.[source] ?? (/^(https?:|data:|blob:)/.test(source)?source:undefined),
read: async(path,range)=>{
if(path==='src') return {kind:'directory',path,name:'src',entries:[{name:'example.ts',path:'src/example.ts',type:'file'}],truncated:false};
const content=window.__YCODE_FIXTURE__?.files?.[path];
if(content===undefined) throw new Error('Offline fixture: file not found');
return {kind:'text',path,name:path.split('/').pop(),content,firstLine:1,totalLines:content.split('\\n').length,range,truncated:false};
},
readText: async(path)=>{
document.documentElement.dataset.fixtureReadText=path;
const content=window.__YCODE_FIXTURE__?.files?.[path];
const delay=window.__YCODE_FIXTURE__?.delays?.[path];
if(delay) await new Promise(resolve=>setTimeout(resolve,delay));
if(content===undefined) throw new Error('Offline fixture: file not found');
return content;
},
download: async(payload)=>{document.documentElement.dataset.fixtureDownload=JSON.stringify({filename:payload.filename,mimeType:payload.mimeType,encoding:payload.encoding,length:payload.data.length,prefix:payload.data.slice(0,24)});return {saved:true,path:'offline-fixture/'+payload.filename}},
downloadImage: async(source)=>{document.documentElement.dataset.fixtureImageDownload=source.slice(0,48);return {saved:true,path:'offline-fixture/image'}}
}
};
})();
</script>`
const server = createServer(async (req, res) => {
try {
const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0])
const rel = urlPath === '/' ? '/index.html' : urlPath
const filePath = normalize(join(root, rel))
if (!filePath.startsWith(root)) {
res.writeHead(403).end('forbidden')
return
}
let body = await readFile(filePath)
if (rel.endsWith('.html')) {
const fixture = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('fixture')
const selectedFixture = fixture === 'conversation-states' ? conversationStatesFixture : fixture === 'pierre-diff' ? pierreDiffFixture : fixture === 'artifact-source' ? artifactSourceFixture : fixture === 'conversation-complete' ? conversationCompleteFixture : fixture === 'chat-parity' ? chatParityFixture : fixture === 'source-recovery' ? sourceRecoveryFixture : fixture === 'dialogue-media' ? dialogueMediaFixture : fixture === 'typography' ? typographyFixture : null
const fixtureScript = selectedFixture
? `<script>window.__YCODE_FIXTURE__=${JSON.stringify(selectedFixture).replace(/</g, '\\u003c')};Object.defineProperty(navigator,'clipboard',{value:{writeText:async(text)=>{window.__fixtureCopied=text}},configurable:true});</script>`
: ''
// 在首个 module script 之前注入假桥
body = Buffer.from(body.toString('utf8').replace('<script type="module"', () => `${fixtureScript}${STUB}${fixture === 'conversation-states' ? conversationStatesBridge : fixture === 'conversation-complete' ? conversationCompleteBridge : ''}<script type="module"`))
if (fixture === 'typography') body = Buffer.from(body.toString('utf8').replace('</body>', () => `${typographyProbes}</body>`))
}
res.writeHead(200, { 'content-type': MIME[extname(filePath)] ?? 'application/octet-stream' })
res.end(body)
} catch {
res.writeHead(404).end('not found')
}
})
server.listen(port, '127.0.0.1', () => {
console.log(`verify-ui listening on http://127.0.0.1:${server.address().port}/`)
})