255 lines
15 KiB
JavaScript
255 lines
15 KiB
JavaScript
// Real isolated YCode App + preload/IPC/filesystem verification for the archived-task UI.
|
|
// No WorkBuddy runtime or original evidence is accessed by this script.
|
|
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/archived-tasks-'))
|
|
const data = path.join(directory, 'data')
|
|
const home = path.join(directory, 'home')
|
|
const independentRoot = path.join(directory, 'independent-root')
|
|
const workspaceRoot = path.join(directory, 'workspace-root')
|
|
await Promise.all([fs.mkdir(data), fs.mkdir(home), fs.mkdir(independentRoot), fs.mkdir(workspaceRoot)])
|
|
|
|
const now = Date.now()
|
|
const crypto = require('node:crypto')
|
|
const workspaceId = 'archived-space'
|
|
const sessions = [
|
|
{
|
|
id: 'archived-active',
|
|
title: 'Archive Active',
|
|
cwd: independentRoot,
|
|
createdAt: now - 30_000,
|
|
updatedAt: now - 30_000,
|
|
messages: [
|
|
{ id: crypto.randomUUID(), role: 'user', content: 'active task stays visible', createdAt: now - 30_000, status: 'done' }
|
|
]
|
|
},
|
|
{
|
|
id: 'archived-independent',
|
|
title: 'Archive Independent',
|
|
cwd: independentRoot,
|
|
createdAt: now - 20_000,
|
|
updatedAt: now - 20_000,
|
|
archivedAt: now - 1_000,
|
|
messages: [
|
|
{ id: crypto.randomUUID(), role: 'user', content: 'independent message preserved', createdAt: now - 20_000, status: 'done' },
|
|
{ id: crypto.randomUUID(), role: 'assistant', content: 'independent reply preserved', createdAt: now - 19_000, status: 'done', tools: [], parts: [] }
|
|
]
|
|
},
|
|
{
|
|
id: 'archived-workspace',
|
|
title: 'Archive Workspace',
|
|
workspaceId,
|
|
cwd: workspaceRoot,
|
|
createdAt: now - 10_000,
|
|
updatedAt: now - 10_000,
|
|
archivedAt: now,
|
|
messages: [
|
|
{ id: crypto.randomUUID(), role: 'user', content: 'workspace message preserved', createdAt: now - 10_000, status: 'done' },
|
|
{ id: crypto.randomUUID(), role: 'assistant', content: 'workspace reply preserved', createdAt: now - 9_000, status: 'done', tools: [], parts: [] }
|
|
]
|
|
}
|
|
]
|
|
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
|
await fs.writeFile(path.join(data, 'workspaces.json'), JSON.stringify({
|
|
workspaces: [{ id: workspaceId, name: 'Archive Space', root: workspaceRoot, createdAt: now }],
|
|
selectedWorkspaceId: workspaceId
|
|
}))
|
|
|
|
const env = {
|
|
...process.env,
|
|
YCODE_QA_DIRECTORY: directory,
|
|
YCODE_USER_DATA_DIR: data,
|
|
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('ARCHIVED_TASKS_')) process.stdout.write(bytes)
|
|
})
|
|
child.stderr.on('data', bytes => { output += bytes })
|
|
const timer = setTimeout(() => child.kill(), 150000)
|
|
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)
|
|
process.exitCode = code ? 1 : 0
|
|
})
|
|
})()
|
|
} else {
|
|
const { app, BrowserWindow, ipcMain } = require('electron')
|
|
app.disableHardwareAcceleration()
|
|
const directory = process.env.YCODE_QA_DIRECTORY
|
|
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('archived-tasks-'))
|
|
app.setPath('userData', path.join(directory, 'data'))
|
|
app.setPath('sessionData', path.join(directory, 'data'))
|
|
app.setPath('home', path.join(directory, 'home'))
|
|
|
|
let main
|
|
let phase = 'boot'
|
|
const checks = []
|
|
const snapshots = {}
|
|
const rendererErrors = []
|
|
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
|
|
async function read(source) {
|
|
let timer
|
|
try {
|
|
return await Promise.race([
|
|
main.webContents.executeJavaScript(source),
|
|
new Promise((_, reject) => {
|
|
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 100))), 15000)
|
|
})
|
|
])
|
|
} catch (error) {
|
|
throw Error(error.message + ' - ' + source.slice(0, 240))
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
async function wait(source, label, attempts = 250) {
|
|
for (let index = 0; index < attempts; index++) {
|
|
const value = await read(source)
|
|
if (value) return value
|
|
await pause(30)
|
|
}
|
|
throw Error('Timeout: ' + label)
|
|
}
|
|
|
|
async function step(label) {
|
|
phase = label
|
|
await fs.writeFile(path.join(directory, 'phase.txt'), 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 shot(name) {
|
|
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
|
|
await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64'))
|
|
}
|
|
|
|
const clickArchiveEntry = `(()=>{const button=document.querySelector('.conversation-section-archived-btn');if(!button)return false;button.click();return true})()`
|
|
const clickRowAction = (id, selector) => `(()=>{const row=document.querySelector('[data-archived-task-id=${JSON.stringify(id)}]');const button=row?.querySelector(${JSON.stringify(selector)});if(!button)return false;button.click();button.click();return true})()`
|
|
|
|
;(async () => {
|
|
const { pathToFileURL } = require('node:url')
|
|
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
|
await app.whenReady()
|
|
main = BrowserWindow.getAllWindows()[0]
|
|
main.setSize(1336, 902)
|
|
main.showInactive()
|
|
main.webContents.debugger.attach('1.3')
|
|
main.webContents.on('console-message', (_event, level, message) => {
|
|
if (level >= 2) rendererErrors.push(message)
|
|
})
|
|
await wait('!!document.querySelector("#root")', 'renderer root')
|
|
await pause(1500)
|
|
await wait('!!document.querySelector("[data-session-id=archived-active]")', 'active sidebar task')
|
|
|
|
await step('entry-and-loading')
|
|
check('archive entry sits in the task section header', await read(`(()=>{const header=document.querySelector('.conversation-section-label-tasks');const button=header?.querySelector('.conversation-section-archived-btn');return !!button&&button.textContent.includes('已归档')})()`), true)
|
|
const initial = await read(`(async()=>{${clickArchiveEntry};await new Promise(requestAnimationFrame);return {dialog:!!document.querySelector('.archived-tasks-dialog'),loading:!!document.querySelector('.archived-tasks-dialog__loading'),count:document.querySelectorAll('.archived-tasks-dialog').length}})()`)
|
|
check('archive entry opens exactly one dialog', initial.count, 1)
|
|
check('dialog exposes a paint-visible loading state', initial.loading || (await read(`!!document.querySelector('.archived-tasks-dialog__loading')`)), true)
|
|
await wait(`document.querySelectorAll('.archived-tasks-dialog__row').length===2`, 'two archived tasks')
|
|
check('loading state is replaced by the real list', await read(`!!document.querySelector('.archived-tasks-dialog__loading')`), false)
|
|
|
|
await step('list-filter-and-kind')
|
|
const rows = await read(`[...document.querySelectorAll('.archived-tasks-dialog__row')].map(row=>({id:row.getAttribute('data-archived-task-id'),workspaceId:row.getAttribute('data-archived-workspace-id'),title:row.querySelector('.archived-tasks-dialog__title')?.textContent,meta:row.querySelector('.archived-tasks-dialog__meta')?.textContent,actions:[...row.querySelectorAll('.archived-tasks-dialog__actions button')].map(button=>button.textContent)}))`)
|
|
snapshots.rows = rows
|
|
check('only archived sessions are listed', rows.map(row => row.id).sort(), ['archived-independent', 'archived-workspace'])
|
|
check('active task is excluded from the archive dialog', rows.some(row => row.id === 'archived-active'), false)
|
|
check('independent task is labelled independently', rows.find(row => row.id === 'archived-independent')?.meta.includes('独立任务'), true)
|
|
check('workspace task keeps its workspace identity', rows.find(row => row.id === 'archived-workspace')?.meta.includes('空间 · Archive Space'), true)
|
|
check('every archived row exposes restore and view actions', rows.every(row => row.actions.join('|') === '恢复|查看'), true)
|
|
check('dialog does not mention removed data management', await read(`document.querySelector('.archived-tasks-dialog').textContent.includes('数据管理')`), false)
|
|
await shot('archive-list')
|
|
|
|
await step('restore-independent')
|
|
const rapidRestore = await read(clickRowAction('archived-independent', '.archived-tasks-dialog__restore'))
|
|
check('rapid duplicate restore clicks are accepted only once', rapidRestore, true)
|
|
await wait(`!document.querySelector('[data-archived-task-id=archived-independent]')`, 'independent row removed')
|
|
await wait(`document.querySelectorAll('[data-session-id=archived-independent]').length===1`, 'independent restored once')
|
|
check('restored independent task appears exactly once in the sidebar', await read(`document.querySelectorAll('[data-session-id=archived-independent]').length`), 1)
|
|
check('restored independent task is no longer archived', await read(`window.ycode.session.listArchived().then(list=>list.filter(item=>item.archivedAt!==undefined).some(item=>item.id==='archived-independent'))`), false)
|
|
await read(`(()=>{const row=document.querySelector('[data-session-id=archived-independent]');row.click();return true})()`)
|
|
await wait(`document.body.textContent.includes('independent message preserved')`, 'independent history restored')
|
|
check('independent task messages survive restore and open', await read(`document.body.textContent.includes('independent message preserved')&&document.body.textContent.includes('independent reply preserved')`), true)
|
|
|
|
await step('view-workspace-task')
|
|
check('view action accepts rapid duplicate clicks', await read(clickRowAction('archived-workspace', '.archived-tasks-dialog__view')), true)
|
|
await wait(`!document.querySelector('.archived-tasks-dialog')`, 'view closes archive dialog')
|
|
await wait(`document.querySelector('[data-session-id=archived-workspace].selected')`, 'workspace task selected')
|
|
check('workspace task returns to its original space group', await read(`document.querySelector('[data-session-id=archived-workspace]')?.closest('[data-workspace-id]')?.getAttribute('data-workspace-id')`), 'archived-space')
|
|
check('view restores the workspace task without duplicate rows', await read(`document.querySelectorAll('[data-session-id=archived-workspace]').length`), 1)
|
|
await wait(`document.body.textContent.includes('workspace message preserved')`, 'workspace history restored')
|
|
check('workspace task messages survive restore and view', await read(`document.body.textContent.includes('workspace message preserved')&&document.body.textContent.includes('workspace reply preserved')`), true)
|
|
check('all archived tasks are absent after restore/view', await read(`window.ycode.session.listArchived().then(list=>list.filter(item=>item.archivedAt!==undefined).length)`), 0)
|
|
|
|
await step('empty-state')
|
|
await read(clickArchiveEntry)
|
|
await wait(`!!document.querySelector('.archived-tasks-dialog__empty')`, 'archive empty state')
|
|
check('empty state explains that no archived tasks exist', await read(`document.querySelector('.archived-tasks-dialog__empty').textContent.includes('暂无已归档任务')`), true)
|
|
await read(`document.querySelector('.archived-tasks-dialog__close').click()`)
|
|
await wait(`!document.querySelector('.archived-tasks-dialog')`, 'empty dialog close')
|
|
|
|
await step('load-failure')
|
|
ipcMain.removeHandler('session:list-archived')
|
|
ipcMain.handle('session:list-archived', () => { throw new Error('forced archive list failure') })
|
|
await read(clickArchiveEntry)
|
|
await wait(`!!document.querySelector('.archived-tasks-dialog__load-error')`, 'archive load failure')
|
|
check('load failure keeps a retry action', await read(`!!document.querySelector('.archived-tasks-dialog__retry')`), true)
|
|
check('load failure reports the real IPC error', await read(`document.querySelector('.archived-tasks-dialog__load-error').textContent.includes('forced archive list failure')`), true)
|
|
await shot('archive-failure')
|
|
ipcMain.removeHandler('session:list-archived')
|
|
ipcMain.handle('session:list-archived', () => [])
|
|
await read(`document.querySelector('.archived-tasks-dialog__retry').click()`)
|
|
await wait(`!!document.querySelector('.archived-tasks-dialog__empty')`, 'archive retry recovers')
|
|
check('retry reloads the archive and replaces the error state', await read(`!document.querySelector('.archived-tasks-dialog__load-error')&&!!document.querySelector('.archived-tasks-dialog__empty')`), true)
|
|
await read(`document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`)
|
|
await wait(`!document.querySelector('.archived-tasks-dialog')`, 'escape closes archive dialog')
|
|
check('escape closes the archive dialog when idle', await read(`!document.querySelector('.archived-tasks-dialog')`), true)
|
|
|
|
await step('cleanup')
|
|
check('no renderer error logs', rendererErrors, [])
|
|
const report = {
|
|
result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
|
scope: 'Real isolated YCode main/preload/IPC/renderer/filesystem. Archive list success and failure use IPC. Temporary data only.',
|
|
checks,
|
|
snapshots,
|
|
rendererErrors
|
|
}
|
|
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
|
console.log('ARCHIVED_TASKS_' + report.result + ' ' + directory)
|
|
for (const failure of checks.filter(item => !item.pass)) console.log('ARCHIVED_TASKS_FAILURE ' + JSON.stringify(failure))
|
|
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,
|
|
rendererErrors
|
|
}, null, 2))
|
|
console.error(error)
|
|
if (main) await shot('failure').catch(() => undefined)
|
|
app.exit(1)
|
|
})
|
|
}
|