Files
ycode/scripts/verify-archived-tasks.cjs

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)
})
}