Files
ycode/scripts/verify-source-code-preview.cjs

106 lines
14 KiB
JavaScript

// Actual production YCode file-tree clicks, full file IPC, source Monaco, isolated native App.
const fs = require('node:fs/promises'), path = require('node:path'), assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/source-code-preview-')), crypto = require('node:crypto'), now = Date.now()
await Promise.all(['data', 'home', 'space-a', 'space-b'].map(name => fs.mkdir(path.join(directory, name))))
const sessions = ['A', 'B'].map((name, index) => ({ id: crypto.randomUUID(), title: 'Code ' + name, cwd: path.join(directory, 'space-' + name.toLowerCase()), createdAt: now, updatedAt: now - index,
messages: [{ id: crypto.randomUUID(), role: 'user', content: 'Persisted code-preview fixture.', createdAt: now, status: 'done' }] }))
await fs.writeFile(path.join(directory, 'data', 'sessions.json'), JSON.stringify({ sessions }))
await fs.writeFile(path.join(sessions[0].cwd, 'fixture.ts'), Array.from({ length: 500 }, (_, index) => `const SPACE_A_${index + 1}: number = ${index + 1}; // 中文 AlphaNeedle`).join('\n'))
await fs.writeFile(path.join(sessions[1].cwd, 'fixture.ts'), 'const SPACE_B: boolean = true;\n// Independent native workspace')
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_CODE_SESSIONS: JSON.stringify(sessions), YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''; child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('CODE_PREVIEW_')) process.stdout.write(bytes) }); child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 90000)
child.once('exit', async code => { clearTimeout(timer); await fs.writeFile(path.join(directory, 'process.log'), output); console.log(JSON.stringify({ directory, exitCode: code })); if (code) console.error(output.slice(-7000)); process.exitCode = code ?? 1 })
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow, session } = require('electron'); app.disableHardwareAcceleration()
const { pathToFileURL } = require('node:url'), directory = process.env.YCODE_QA_DIRECTORY, [a, b] = JSON.parse(process.env.YCODE_QA_CODE_SESSIONS)
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('source-code-preview-'))
app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data')); app.setPath('home', path.join(directory, 'home'))
let main, settings, phase = 'boot'; const checks = [], errors = [], network = [], snapshots = {}
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
async function read(source, win = main) { let timer; try { return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('Renderer timeout')), 15000) })]) } catch(error) { throw Error(error.message + ' — ' + source.slice(0, 160)) } finally { clearTimeout(timer) } }
async function wait(source, label, win = main) { for (let i = 0; i < 400; i++) { const value = await read(source, win); if (value) return value; await pause(25) } throw Error('Timeout: ' + label) }
function check(label, actual, expected) { const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected); checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected }) }
async function click(selector, win = main) { await read(`(()=>{const n=document.querySelector(${JSON.stringify(selector)});if(!n)throw Error('Missing click target');n.click()})()`, win); await pause(70) }
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
async function openFile() {
if (!(await read('!!document.querySelector(".yc-workbench")'))) await click('.source-chat-header [title="打开右侧栏"]')
if (!(await read('!!document.querySelector("[data-entry-key=fileTree]")'))) await click('[aria-label="新标签页"]')
await click('[data-entry-key="fileTree"]'); await wait('[...document.querySelectorAll(".yc-workbench-tree-node")].some(n=>n.textContent.includes("fixture.ts"))', 'native tree loaded')
await read('[...document.querySelectorAll(".yc-workbench-tree-node")].find(n=>n.textContent.includes("fixture.ts")).click()')
await wait('!!document.querySelector(".source-code-preview[data-model-lines]")', 'actual source Monaco editor mounted')
}
async function snapshot(name) { return snapshots[name] = await read(`(()=>{const n=document.querySelector('.source-code-preview'),m=n?.querySelector('.monaco-editor'),r=m?.getBoundingClientRect();return {state:n?.dataset.sourceCodeState,language:n?.dataset.language,lines:Number(n?.dataset.modelLines),options:JSON.parse(n?.dataset.sourceOptions||'{}'),owner:n?.dataset.sessionId,text:[...document.querySelectorAll('.view-line')].map(n=>n.textContent).join('\\n'),syntaxClasses:[...new Set([...document.querySelectorAll('.view-line span')].map(n=>n.className))],search:!!document.querySelector('[class*=_search-widget_xh5da]'),theme:m?.className,bg:m?getComputedStyle(m).backgroundColor:null,bounds:r?[r.x,r.y,r.width,r.height]:null,range:!!document.querySelector('.source-code-preview-range')}})()`) }
async function shot(name) { const image = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false }); await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(image.data, 'base64')) }
;(async () => {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href); await app.whenReady()
session.defaultSession.webRequest.onBeforeRequest((details, callback) => { if (/^https?:/.test(details.url)) network.push(details.url); callback({ cancel: /^https?:/.test(details.url) }) })
for (let i = 0; i < 200; i++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); if (main && !main.webContents.isLoading()) break; await pause(25) }
assert.ok(main); main.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) })
await wait('!!document.querySelector(".source-chat-header")', 'actual App'); main.webContents.debugger.attach('1.3')
await main.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', { width: 1336, height: 901, deviceScaleFactor: 1, mobile: false })
await step('native-file-tree-and-full-source-editor'); await openFile(); await pause(250)
let state = await snapshot('space-a')
check('real file-tree click mounts original Monaco engine', state.state, 'ready')
check('original source language mapping uses TypeScript', state.language, 'typescript')
check('full native file becomes model, beyond400line preview window', state.lines, 500)
check('source readonly/13px/20px/wrapon/minimapfalse', [state.options.readOnly, state.options.fontSize, state.options.lineHeight, state.options.wordWrap, state.options.minimap?.enabled], [true, 13, 20, 'on', false])
check('source line numbers and scrollbar dimensions', [state.options.lineNumbers, state.options.scrollbar?.verticalScrollbarSize, state.options.scrollbar?.horizontalScrollbarSize], ['on', 10, 10])
check('source tab size4', state.options.tabSize, 4)
await wait('[...document.querySelectorAll(".view-line span")].some(n=>/^mtk[2-9][0-9]*$/.test(n.className))', 'actual colored Monarch token beyond default mtk1')
state = await snapshot('space-a-colored')
check('Monarch tokens produce actual distinct syntax classes', state.syntaxClasses.filter(value => /^mtk\d+$/.test(value)).length, value => value > 1)
await shot('code-light')
await step('source-editor-search-and-range')
await read('document.querySelector(".monaco-editor textarea").focus()')
main.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'F', modifiers: ['control'] }); main.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'F', modifiers: ['control'] })
await wait('!!document.querySelector("[class*=_search-widget__input_xh5da] input")', 'source CtrlF widget')
await read(`(()=>{const n=document.querySelector('[class*=_search-widget__input_xh5da] input');Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(n,'AlphaNeedle');n.dispatchEvent(new Event('input',{bubbles:true}))})()`); await pause(150)
check('source search has500 matches over full model', await read('document.querySelector("[class*=_search-widget__match-count_xh5da]")?.textContent'), value => value?.includes('500'))
await shot('code-search')
await read(`document.querySelector('[class*=_search-widget__input_xh5da] input').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`)
await wait('!document.querySelector("[class*=_search-widget_xh5da]")', 'source search Escape closes')
const firstModel = await read('document.querySelector(".source-code-preview").dataset.modelUri')
await read(`window.dispatchEvent(new CustomEvent('ycode:preview-resource',{detail:{path:${JSON.stringify(path.join(a.cwd, 'fixture.ts'))},range:{start:450,end:452}}}))`)
await wait('!!document.querySelector(".source-code-preview-range")', 'host requested line range decoration'); await pause(200)
state = await snapshot('line-range'); check('line450 range scrolls full model', state.text.includes('SPACE_A_450'), true)
check('range preview still has500 model lines', state.lines, 500)
check('range450 reuses original500line model identity', await read('document.querySelector(".source-code-preview").dataset.modelUri'), firstModel)
await read(`window.dispatchEvent(new CustomEvent('ycode:preview-resource',{detail:{path:${JSON.stringify(path.join(a.cwd, 'fixture.ts'))},range:{start:20,end:21}}}))`)
await wait('document.querySelector(".source-code-preview")?.dataset.rangeStart==="20"', 'same model moves to line20')
await wait('[...document.querySelectorAll(".view-line")].some(n=>n.textContent.includes("SPACE_A_20:"))', 'line20 range painted')
check('consecutive450→20 has one actual model range decoration', await read('[document.querySelector(".source-code-preview").dataset.modelUri,document.querySelector(".source-code-preview").dataset.rangeCount]'), [firstModel, '1'])
await read(`window.dispatchEvent(new CustomEvent('ycode:preview-resource',{detail:{path:${JSON.stringify(path.join(a.cwd, 'fixture.ts'))}}}))`)
await wait('document.querySelector(".source-code-preview")?.dataset.rangeCount==="0"&&!document.querySelector(".source-code-preview-range")', 'no-range clears prior model decoration')
check('clearing range keeps same500line model', await read('[document.querySelector(".source-code-preview").dataset.modelUri,document.querySelector(".source-code-preview").dataset.modelLines]'), [firstModel, '500'])
await step('real-appearance-theme-and-session-ownership')
await read('window.ycode.app.openSettings("appearance")')
settings = await (async () => { for (let i = 0; i < 200; i++) { const win = BrowserWindow.getAllWindows().find(win => win !== main); if (win && !win.webContents.isLoading()) return win; await pause(25) } throw Error('Settings missing') })()
await wait('!!document.querySelector(".segmented")', 'native appearance UI', settings)
await read('[...document.querySelectorAll(".segmented__item")].find(n=>n.textContent==="深色").click()', settings)
await wait('document.documentElement.dataset.theme==="dark"', 'shared dark theme'); await pause(200)
state = await snapshot('dark'); check('actual source Monaco receives dark theme', state.theme, value => value?.includes('vs-dark'))
await shot('code-dark'); await click('.settings-window-close', settings)
await click(`[data-session-id="${b.id}"]`); await pause(300); await openFile(); await wait('[...document.querySelectorAll(".view-line")].some(n=>n.textContent.includes("SPACE_B"))', 'second native model painted'); state = await snapshot('space-b')
check('second session editor owner is correct', state.owner, b.id)
check('same filename uses independent second workspace complete model', state.lines, 2)
check('second workspace contains its content only', [state.text.includes('SPACE_B'), state.text.includes('SPACE_A')], [true, false])
await click(`[data-session-id="${a.id}"]`); await wait('document.querySelector(".source-code-preview")?.dataset.modelLines==="500"', 'return to first session model')
check('session return restores source model and clears oldsearch', (await snapshot('returned-a')).search, false)
await step('real-failure-fallback-and-network-check')
await click('.sidebar-next-collapse-toggle'); await pause(350); await fs.unlink(path.join(a.cwd, 'fixture.ts'))
await click('.source-chat-header [title="打开右侧栏"]'); await wait('document.querySelector(".source-code-preview")?.dataset.sourceCodeState==="error"', 'real file removal fails honest full read')
check('real read failure shows cached original text fallback', await read('document.querySelector(".source-code-preview")?.textContent.includes("原始文本")&&!!document.querySelector(".yc-workbench-text pre")'), true)
check('no WorkBuddy/CDN/HTTP requests from loader or workers', network, [])
check('no renderer errors', errors, [])
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', scope: 'Production App/native file tree/preload/IPC/exact shipped Monaco. Temp conversations/files only; no original live access; source readonly preview, not disk editor or full pixel acceptance.', checks, snapshots, network, errors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)); console.log('CODE_PREVIEW_INTEGRATION_' + report.result + ' ' + directory)
for (const item of checks.filter(check => !check.pass)) console.log('CODE_PREVIEW_FAILURE ' + JSON.stringify(item)); app.exit(report.result === 'PASS' ? 0 : 1)
})().catch(async error => { await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, network, errors }, null, 2)); console.error(error); if (main?.webContents.debugger.isAttached()) await shot('failure').catch(() => undefined); app.exit(1) })
}