152 lines
11 KiB
JavaScript
152 lines
11 KiB
JavaScript
// Production app + real GitHub package, isolated from the user's skill registry.
|
|
const fs = require('node:fs/promises')
|
|
const path = require('node:path')
|
|
const assert = require('node:assert/strict')
|
|
|
|
if (!process.versions.electron) {
|
|
;(async () => {
|
|
const directory = await fs.mkdtemp(path.resolve('.tmp/recommended-app-'))
|
|
await Promise.all(['data', 'home', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
|
|
const now = Date.now()
|
|
await fs.writeFile(path.join(directory, 'data', 'sessions.json'), JSON.stringify({ sessions: [{
|
|
id: 'skill-existing', title: 'Skill existing draft', cwd: path.join(directory, 'workspace'),
|
|
createdAt: now, updatedAt: now, messages: []
|
|
}] }))
|
|
const env = { ...process.env, YCODE_QA_DIRECTORY: directory,
|
|
YCODE_USER_DATA_DIR: path.join(directory, 'data'), HOME: path.join(directory, 'home'),
|
|
USERPROFILE: path.join(directory, 'home'), YCODE_QA_OFFSCREEN: '1',
|
|
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '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', value => { output += value; if (String(value).includes('RECOMMENDED_APP_')) process.stdout.write(value) })
|
|
child.stderr.on('data', value => { output += value })
|
|
const timer = setTimeout(() => child.kill(), 200000)
|
|
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 !== 0) console.error(output.slice(-7000))
|
|
process.exitCode = code === 0 ? 0 : 1
|
|
})
|
|
})().catch(error => { console.error(error); process.exitCode = 1 })
|
|
} else {
|
|
const { app, BrowserWindow } = require('electron')
|
|
const { pathToFileURL } = require('node:url')
|
|
const directory = path.resolve(process.env.YCODE_QA_DIRECTORY)
|
|
const rel = path.relative(path.resolve('.tmp'), directory)
|
|
assert.ok(rel.startsWith('recommended-app-') && !rel.includes('..'))
|
|
const checks = [], errors = []
|
|
let stage = 'boot', win
|
|
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
const read = source => win.webContents.executeJavaScript(source)
|
|
const check = (label, value) => { assert.ok(value, label); checks.push(label) }
|
|
async function click(selector) {
|
|
const point = await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing target');e.scrollIntoView({block:'nearest'});const r=e.getBoundingClientRect();const x=Math.round(r.x+r.width/2),y=Math.round(r.y+r.height/2);const hit=document.elementFromPoint(x,y);if(!e.contains(hit))throw Error('Target is covered: '+${JSON.stringify(selector)});return{x,y}})()`)
|
|
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
|
|
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
|
|
await pause(100)
|
|
}
|
|
async function wait(source, label, timeout = 15000) {
|
|
const end = Date.now() + timeout
|
|
while (Date.now() < end) { const value = await read(source); if (value) return value; await pause(80) }
|
|
throw Error('Timeout: ' + label)
|
|
}
|
|
async function shot(name) {
|
|
win.webContents.invalidate()
|
|
await win.webContents.capturePage()
|
|
await pause(250)
|
|
await fs.writeFile(path.join(directory, name + '.png'), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
app.on('browser-window-created', (_event, window) => {
|
|
window.setSkipTaskbar(true)
|
|
window.on('show', () => window.hide())
|
|
window.webContents.on('console-message', (event) => { if (event.level === 'error') errors.push(event.message) })
|
|
})
|
|
;(async () => {
|
|
try {
|
|
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
|
await app.whenReady()
|
|
for (let index = 0; index < 200; index++) {
|
|
win = BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode')
|
|
if (win && !win.webContents.isLoading()) break
|
|
await pause(30)
|
|
}
|
|
check('production window loaded', !!win)
|
|
await wait('!!window.ycode?.skills?.recommendations', 'real preload')
|
|
await wait('!!document.querySelector("[data-session-id=skill-existing]")', 'original session')
|
|
await click('[data-session-id=skill-existing]')
|
|
await wait('!!document.querySelector(".chat-pane .wb-composer textarea")', 'initial composer')
|
|
await read('document.querySelector(".chat-pane .wb-composer textarea").focus()')
|
|
await win.webContents.insertText('KEEP_ORIGINAL_DRAFT')
|
|
const catalog = await read('window.ycode.skills.recommendations()')
|
|
check('fixed Archify recommendation is exposed by real IPC', catalog.some(item => item.id === 'archify' && item.commit === '25cf37080fc8b1dbe758a4efc3935b55f3327083'))
|
|
await read(`(()=>{window.__installEvents=[];window.ycode.skills.onInstallProgress(event=>window.__installEvents.push(event));window.__skillChanges=0;window.ycode.skills.onChanged(()=>window.__skillChanges++);window.dispatchEvent(new CustomEvent('ycode:open-capabilities',{detail:{tab:'skills'}}));return true})()`)
|
|
await wait('!!document.querySelector("[data-recommendation-id=archify]")', 'recommendation card')
|
|
await shot('recommendation')
|
|
stage = 'install'
|
|
// Use the real card and confirmation; no direct import and no picker stub.
|
|
await click('[data-recommendation-id=archify] [aria-label="安装 archify"]')
|
|
await wait('!!document.querySelector("[aria-label=确认安装推荐技能]")', 'install confirmation')
|
|
check('the modal host escapes the isolated capability center', await read('document.querySelector(".yc-skill-modal-host")?.parentElement===document.body'))
|
|
check('the background root is inert while the skill modal is open', await read('document.getElementById("root").inert'))
|
|
check('sidebar sticky headers are behind the global mask', await read(`(()=>{const e=document.querySelector('.conversation-section-label');if(!e)return false;const r=e.getBoundingClientRect();return !!document.elementFromPoint(r.left+5,r.top+5)?.closest('.yc-skill-modal-host')})()`))
|
|
await read('window.__sameDetail=document.querySelector(".yc-skill-detail-shell")')
|
|
await shot('confirm-full-window')
|
|
await read('document.documentElement.dataset.theme="dark"')
|
|
await pause(350); await shot('confirm-dark')
|
|
const originalSize = win.getSize()
|
|
win.setSize(720, 900); await pause(350)
|
|
check('the full-window skill detail fits a narrow viewport', await read('(()=>{const r=document.querySelector(".yc-skill-detail-shell").getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight})()'))
|
|
await shot('confirm-narrow')
|
|
win.setSize(...originalSize); await read('document.documentElement.dataset.theme="light"'); await pause(300)
|
|
await click('[aria-label=确认安装推荐技能]')
|
|
await wait(`window.__installEvents.some(event=>['installed','failed','cancelled'].includes(event.phase))`, 'native installation', 150000)
|
|
const progress = await read('window.__installEvents')
|
|
check('actual download, validation and commit phases are observed', ['downloading', 'validating', 'installing', 'installed'].every(phase => progress.some(event => event.phase === phase)))
|
|
const installed = await read('window.ycode.skills.list()')
|
|
const skill = installed.find(item => item.name === 'archify')
|
|
check('installed skill is enabled, intact and has source provenance', skill?.enabled && !skill.userModified && skill.origin?.commit === catalog[0].commit)
|
|
const document = await read(`window.ycode.skills.read(${JSON.stringify(skill.id)})`)
|
|
check('skill body and packaged CLI resources are present', document.content.includes('Archify') && document.files.includes('bin/archify.mjs') && document.files.length > 100)
|
|
check('committed mutation broadcasts a skill refresh', await read('window.__skillChanges>0'))
|
|
await wait(`document.querySelector('[data-recommendation-id=archify]')?.textContent.includes('已安装')`, 'installed card refresh')
|
|
check('installation keeps the same detail DOM mounted', await read('window.__sameDetail===document.querySelector(".yc-skill-detail-shell")'))
|
|
await wait('document.querySelector("[role=dialog] .sm-skill-detail-action-row")?.textContent.includes("去使用")', 'post-install action')
|
|
check('the avatar is a packaged offline image', await read('(()=>{const i=document.querySelector("[role=dialog] .sm-skill-detail-icon-wrap img");return i?.complete&&i.naturalWidth>0&&!i.src.startsWith("http")})()'))
|
|
await shot('installed')
|
|
stage = 'idempotency'
|
|
const again = await read(`window.ycode.skills.installRecommended('archify',{operationId:crypto.randomUUID()})`)
|
|
check('same unchanged version returns existing install', again.id === skill.id && again.origin.installedAt === skill.origin.installedAt)
|
|
await read(`window.ycode.skills.setEnabled(${JSON.stringify(skill.id)},false)`)
|
|
const disabled = await read(`window.ycode.skills.installRecommended('archify',{operationId:crypto.randomUUID()})`)
|
|
check('idempotent install does not silently re-enable disabled skill', disabled.enabled === false)
|
|
await read(`window.ycode.skills.setEnabled(${JSON.stringify(skill.id)},true)`)
|
|
stage = 'use-skill'
|
|
await wait(`!!document.querySelector('[role=dialog] [aria-label="使用推荐技能 archify"]')`, 'use installed recommendation')
|
|
await click('[role=dialog] [aria-label="使用推荐技能 archify"]')
|
|
await wait(`document.querySelector('.chat-pane .wb-composer')?.textContent.includes('archify')`, 'skill reference in the real composer')
|
|
check('using the installed recommendation fills the real composer', true)
|
|
check('use opens a clean new draft instead of overwriting the old draft', await read('!document.querySelector(".chat-pane .wb-composer").textContent.includes("KEEP_ORIGINAL_DRAFT")'))
|
|
check('the background is no longer inert after use', await read('!document.getElementById("root").inert'))
|
|
await shot('use-skill')
|
|
check('browsing, installation and use do not create persisted chat sessions', (await read('window.ycode.session.list()')).length === 1)
|
|
await click('[data-session-id=skill-existing]')
|
|
await wait('document.querySelector(".chat-pane .wb-composer textarea")?.value.includes("KEEP_ORIGINAL_DRAFT")', 'original session draft restored')
|
|
check('the original session draft remains intact after using the skill', true)
|
|
check('no renderer errors', errors.length === 0)
|
|
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, progress, skill, files: document.files,
|
|
errors, limits: 'Real fixed GitHub package and production IPC; no skill commands or model requests executed.' }, null, 2))
|
|
console.log('RECOMMENDED_APP_PASS ' + directory)
|
|
app.quit()
|
|
} catch (error) {
|
|
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ stage, error: String(error.stack ?? error), checks, errors }, null, 2))
|
|
if (win) await shot('failure').catch(() => {})
|
|
console.error('RECOMMENDED_APP_FAIL ' + String(error))
|
|
app.exit(1)
|
|
}
|
|
})()
|
|
}
|