481 lines
21 KiB
JavaScript
481 lines
21 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
// Real-task completion battery — task #86 owner: passthrough-verify.
|
||
|
|
//
|
||
|
|
// Runs a ≥10-task suite against the running Electron on CDP :9299 with
|
||
|
|
// profile=stdio-deepseek (real DEEPSEEK_API_KEY). For each task:
|
||
|
|
// 1. New session, send prompt (real DeepSeek turn).
|
||
|
|
// 2. Poll turn/end; capture wire event count, tokens, chunks.
|
||
|
|
// 3. Objective completion judge (per task: file exists / content match /
|
||
|
|
// wire signal / etc).
|
||
|
|
// 4. Render assertions on the DOM: tool card presence, trace footer
|
||
|
|
// values, reasoning drawer availability, absence of "—" placeholders
|
||
|
|
// in the Tracing summary row for this session.
|
||
|
|
// 5. Collect captured errs from the pre-installed __battery hooks.
|
||
|
|
//
|
||
|
|
// LLM budget: ≤40 real calls. Sandbox workdir /tmp/dsh-task-battery/T??.
|
||
|
|
// Do not touch user's Electron (pid 91655) — this driver is pinned to
|
||
|
|
// CDP_PORT=9299.
|
||
|
|
|
||
|
|
import http from 'node:http'
|
||
|
|
import fs from 'node:fs'
|
||
|
|
import path from 'node:path'
|
||
|
|
import os from 'node:os'
|
||
|
|
|
||
|
|
const PORT = 9299
|
||
|
|
const SANDBOX_ROOT = '/tmp/dsh-task-battery'
|
||
|
|
|
||
|
|
function nowIso() { return new Date().toISOString() }
|
||
|
|
function log(...a) { console.log('[battery]', nowIso(), ...a) }
|
||
|
|
|
||
|
|
function listTargets() {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
http.get(`http://localhost:${PORT}/json/list`, (res) => {
|
||
|
|
let b = ''
|
||
|
|
res.on('data', (c) => (b += c))
|
||
|
|
res.on('end', () => { try { resolve(JSON.parse(b)) } catch (e) { reject(e) } })
|
||
|
|
}).on('error', reject)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
async function connect() {
|
||
|
|
const t = await listTargets()
|
||
|
|
const p = t.find((x) => x.title === 'DSH Desktop') || t.find((x) => x.type === 'page')
|
||
|
|
if (!p) throw new Error('no page')
|
||
|
|
const ws = new WebSocket(p.webSocketDebuggerUrl)
|
||
|
|
await new Promise((r, j) => { ws.onopen = () => r(); ws.onerror = (e) => j(e.message || 'ws err') })
|
||
|
|
let seq = 0
|
||
|
|
const pending = new Map()
|
||
|
|
ws.onmessage = (ev) => {
|
||
|
|
let msg; try { msg = JSON.parse(ev.data) } catch { return }
|
||
|
|
if (msg.id != null && pending.has(msg.id)) {
|
||
|
|
const { resolve, reject } = pending.get(msg.id)
|
||
|
|
pending.delete(msg.id)
|
||
|
|
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
|
||
|
|
else resolve(msg.result)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function send(method, params) {
|
||
|
|
const id = ++seq
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
pending.set(id, { resolve, reject })
|
||
|
|
ws.send(JSON.stringify({ id, method, params: params || {} }))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
async function evalExpr(expr, awaitProm = true, timeoutMs = 60000) {
|
||
|
|
const r = await send('Runtime.evaluate', {
|
||
|
|
expression: expr, returnByValue: true, awaitPromise: awaitProm, timeout: timeoutMs,
|
||
|
|
})
|
||
|
|
if (r.exceptionDetails) {
|
||
|
|
const desc = r.exceptionDetails.exception?.description || r.exceptionDetails.text
|
||
|
|
throw new Error('eval-error: ' + desc)
|
||
|
|
}
|
||
|
|
return r.result && r.result.value
|
||
|
|
}
|
||
|
|
async function screenshot(pth) {
|
||
|
|
const r = await send('Page.captureScreenshot', { format: 'png' })
|
||
|
|
fs.writeFileSync(pth, Buffer.from(r.data, 'base64'))
|
||
|
|
return pth
|
||
|
|
}
|
||
|
|
return { ws, evalExpr, screenshot, close: () => ws.close() }
|
||
|
|
}
|
||
|
|
|
||
|
|
// Prompt DeepSeek to write files into an absolute workdir; the model can
|
||
|
|
// use its `bash` tool. We assert on real filesystem outcomes.
|
||
|
|
const TASKS = [
|
||
|
|
{
|
||
|
|
id: 'T01', title: 'single-file create (fizzbuzz)',
|
||
|
|
prompt: (wd) => `Use the bash tool to create a file at exactly ${wd}/fizzbuzz.py containing a Python fizzbuzz that prints 1..15. Then confirm.`,
|
||
|
|
judge: (wd) => {
|
||
|
|
const p = path.join(wd, 'fizzbuzz.py')
|
||
|
|
if (!fs.existsSync(p)) return { pass: false, why: 'file not created' }
|
||
|
|
const s = fs.readFileSync(p, 'utf8')
|
||
|
|
const looksLikeFizzbuzz = /fizz/i.test(s) && /buzz/i.test(s)
|
||
|
|
return { pass: looksLikeFizzbuzz, why: looksLikeFizzbuzz ? 'file has fizz/buzz' : 'file lacks fizz/buzz' }
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'T02', title: 'read file + answer',
|
||
|
|
setup: (wd) => fs.writeFileSync(path.join(wd, 'note.txt'), 'The secret color is turquoise. Do not forget.\n'),
|
||
|
|
prompt: (wd) => `Read the file at exactly ${wd}/note.txt using bash (cat), then tell me the secret color.`,
|
||
|
|
judge: (_wd, text) => {
|
||
|
|
const found = /turquoise/i.test(text || '')
|
||
|
|
return { pass: found, why: found ? 'answer contains turquoise' : 'answer lacks turquoise' }
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'T03', title: 'bash execute + summarize',
|
||
|
|
prompt: (wd) => `Run the bash command \`ls -la ${wd}\` and summarize what you see in one sentence.`,
|
||
|
|
judge: (_wd, text) => {
|
||
|
|
const mentions = /file|dir|empty|contents|entries|nothing/i.test(text || '')
|
||
|
|
return { pass: mentions, why: mentions ? 'summary reads like a listing summary' : 'summary does not describe a listing' }
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'T04', title: 'multi-step: three files + index',
|
||
|
|
prompt: (wd) => `Create three files with bash: ${wd}/a.txt (contents "alpha"), ${wd}/b.txt (contents "beta"), ${wd}/c.txt (contents "gamma"). Then create ${wd}/index.txt whose contents are the concatenation "alpha beta gamma".`,
|
||
|
|
judge: (wd) => {
|
||
|
|
const ok = ['a.txt', 'b.txt', 'c.txt', 'index.txt'].every((f) => fs.existsSync(path.join(wd, f)))
|
||
|
|
if (!ok) return { pass: false, why: 'missing file(s)' }
|
||
|
|
const idx = fs.readFileSync(path.join(wd, 'index.txt'), 'utf8')
|
||
|
|
const hasAll = /alpha/.test(idx) && /beta/.test(idx) && /gamma/.test(idx)
|
||
|
|
return { pass: hasAll, why: hasAll ? 'index has alpha/beta/gamma' : 'index missing tokens' }
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'T05', title: 'file edit (append line)',
|
||
|
|
setup: (wd) => fs.writeFileSync(path.join(wd, 'log.txt'), 'first line\n'),
|
||
|
|
prompt: (wd) => `Append the line "second line" to ${wd}/log.txt using bash. Keep the original content.`,
|
||
|
|
judge: (wd) => {
|
||
|
|
const s = fs.readFileSync(path.join(wd, 'log.txt'), 'utf8')
|
||
|
|
const both = /first line/.test(s) && /second line/.test(s)
|
||
|
|
return { pass: both, why: both ? 'both lines present' : `missing lines, got: ${JSON.stringify(s)}` }
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'T06', title: 'error recovery (nonexistent path)',
|
||
|
|
prompt: (wd) => `Try to \`cat /nonexistent/path-${Date.now()}\` using bash. When it fails, tell me in one sentence why it failed.`,
|
||
|
|
judge: (_wd, text) => {
|
||
|
|
const explains = /(no such|not found|does not exist|missing|nonexistent|doesn.t exist)/i.test(text || '')
|
||
|
|
return { pass: explains, why: explains ? 'model explained the failure' : 'model did not explain' }
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'T07', title: 'long output task',
|
||
|
|
prompt: (_wd) => `Write a Python one-liner in a code fence that prints 200 lines of increasing integers. Do not execute it; just show the code.`,
|
||
|
|
judge: (_wd, text) => {
|
||
|
|
const hasCode = /(```|for|range|print)/i.test(text || '')
|
||
|
|
return { pass: hasCode && (text || '').length > 60, why: hasCode ? 'response contains code' : 'no code visible' }
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'T08', title: 'cancel mid-turn',
|
||
|
|
// Long-running prompt we will cancel around 900ms in.
|
||
|
|
prompt: (_wd) => `Explain in extreme detail, at least 800 words, how the TCP three-way handshake works, including diagrams in ASCII.`,
|
||
|
|
cancelAfterMs: 900,
|
||
|
|
judge: (_wd, _text, meta) => {
|
||
|
|
const cancelled = meta.cancelResult && meta.cancelResult.cancelled === true
|
||
|
|
return { pass: cancelled, why: cancelled ? 'server acknowledged cancel' : 'cancel returned ' + JSON.stringify(meta.cancelResult) }
|
||
|
|
},
|
||
|
|
// No render assertions for the cancelled turn beyond "turn present in stream".
|
||
|
|
skipRenderChecks: false,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'T09', title: 'fork replay from seq 1',
|
||
|
|
prompt: (wd) => `Say only the word "spark".`,
|
||
|
|
// After the turn, driver will fork from seq 1 and confirm shape.
|
||
|
|
fork: true,
|
||
|
|
judge: (_wd, text, meta) => {
|
||
|
|
const said = /spark/i.test(text || '')
|
||
|
|
const fk = meta.forkResult
|
||
|
|
const forked = fk && fk.childSessionId && (fk.mocked === false)
|
||
|
|
return { pass: said && forked, why: `saidSpark=${said} forked=${forked} mocked=${fk && fk.mocked}` }
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'T10', title: 'multi-turn follow-up',
|
||
|
|
prompt: (_wd) => `Remember the number 42 for later. Reply with just: OK.`,
|
||
|
|
followUp: {
|
||
|
|
prompt: 'What number did I ask you to remember?',
|
||
|
|
judge: (text) => ({ pass: /42/.test(text || ''), why: /42/.test(text || '') ? 'follow-up said 42' : 'follow-up missed 42' }),
|
||
|
|
},
|
||
|
|
judge: (_wd, text) => {
|
||
|
|
return { pass: /ok/i.test(text || ''), why: /ok/i.test(text || '') ? 'first turn said OK' : 'first turn did not say OK' }
|
||
|
|
},
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
async function ensureSandbox() {
|
||
|
|
fs.mkdirSync(SANDBOX_ROOT, { recursive: true })
|
||
|
|
}
|
||
|
|
|
||
|
|
async function newSessionWithCwd(c, _cwd) {
|
||
|
|
// newSession() ignores cwd (main.js:206 uses process.cwd); the sandbox is
|
||
|
|
// enforced by the prompt using absolute paths under SANDBOX_ROOT.
|
||
|
|
return c.evalExpr(`
|
||
|
|
(async () => {
|
||
|
|
const r = await window.dsh.newSession();
|
||
|
|
if (window.__dshRenderer && window.__dshRenderer.selectSession) {
|
||
|
|
window.__dshRenderer.selectSession(r.id);
|
||
|
|
}
|
||
|
|
return r;
|
||
|
|
})()
|
||
|
|
`)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function sendPromptAndCollect(c, sid, prompt, { cancelAfterMs, budget = 60000 } = {}) {
|
||
|
|
// Fire send (do not await here — cancel test needs mid-flight control).
|
||
|
|
await c.evalExpr(`
|
||
|
|
(() => {
|
||
|
|
window.__task = { sendResult: null, sendErr: null };
|
||
|
|
window.__task.sendPromise = window.dsh.sendPrompt(${JSON.stringify(sid)}, ${JSON.stringify(prompt)})
|
||
|
|
.then(r => { window.__task.sendResult = r; return r; })
|
||
|
|
.catch(e => { window.__task.sendErr = String(e && e.message || e); });
|
||
|
|
return true;
|
||
|
|
})()
|
||
|
|
`, true, 10000)
|
||
|
|
let cancelResult = null
|
||
|
|
const start = Date.now()
|
||
|
|
if (cancelAfterMs) {
|
||
|
|
await new Promise((r) => setTimeout(r, cancelAfterMs))
|
||
|
|
cancelResult = await c.evalExpr(`
|
||
|
|
(async () => await window.dsh.cancelPrompt(${JSON.stringify(sid)}, 'battery cancel'))()
|
||
|
|
`, true, 15000)
|
||
|
|
}
|
||
|
|
// Poll for turn/end in the renderer's cachedEvents.
|
||
|
|
const eventsExpr = (sid) => `
|
||
|
|
(() => {
|
||
|
|
const st = window.__dshRenderer && window.__dshRenderer.snapshotState && window.__dshRenderer.snapshotState();
|
||
|
|
const meta = st && st.sessions && st.sessions.get ? st.sessions.get(${JSON.stringify(sid)}) : null;
|
||
|
|
const evs = (meta && meta.cachedEvents) || [];
|
||
|
|
const types = evs.map(e => e.type);
|
||
|
|
const done = types.includes('turn/end');
|
||
|
|
let text = '';
|
||
|
|
for (const e of evs) {
|
||
|
|
if (e.type === 'assistant/chunk' && e.content) {
|
||
|
|
const c = e.content;
|
||
|
|
if (typeof c === 'string') text += c;
|
||
|
|
else if (c.text) text += c.text;
|
||
|
|
else if (Array.isArray(c)) for (const b of c) if (b && b.type === 'text' && b.text) text += b.text;
|
||
|
|
}
|
||
|
|
if (e.type === 'assistant/message' && e.content) {
|
||
|
|
const c = e.content;
|
||
|
|
if (Array.isArray(c)) for (const b of c) if (b && b.type === 'text' && b.text) text += b.text;
|
||
|
|
else if (typeof c === 'string') text += c;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { done, text, eventCount: evs.length, types };
|
||
|
|
})()
|
||
|
|
`
|
||
|
|
let lastSnap = { done: false, text: '', eventCount: 0, types: [] }
|
||
|
|
while (Date.now() - start < budget) {
|
||
|
|
lastSnap = await c.evalExpr(eventsExpr(sid))
|
||
|
|
if (lastSnap.done) break
|
||
|
|
if (cancelResult) {
|
||
|
|
const settled = await c.evalExpr('window.__task && (window.__task.sendErr || (window.__task.sendResult && window.__task.sendResult.cancelled))')
|
||
|
|
if (settled && lastSnap.eventCount > 0) {
|
||
|
|
// Give a beat for a final assistant/message before bailing.
|
||
|
|
await new Promise((r) => setTimeout(r, 800))
|
||
|
|
lastSnap = await c.evalExpr(eventsExpr(sid))
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
await new Promise((r) => setTimeout(r, 500))
|
||
|
|
}
|
||
|
|
const sendState = await c.evalExpr('({ sendResult: window.__task && window.__task.sendResult, sendErr: window.__task && window.__task.sendErr })')
|
||
|
|
const snapshot = {
|
||
|
|
text: lastSnap.text || '',
|
||
|
|
eventCount: lastSnap.eventCount || 0,
|
||
|
|
done: !!lastSnap.done,
|
||
|
|
types: lastSnap.types || [],
|
||
|
|
sendResult: sendState.sendResult || null,
|
||
|
|
sendErr: sendState.sendErr || null,
|
||
|
|
}
|
||
|
|
return { snapshot, cancelResult }
|
||
|
|
}
|
||
|
|
|
||
|
|
async function renderAssertions(c, sid, taskId) {
|
||
|
|
// Ask the renderer for tool-card / trace-footer / drawer state for this session.
|
||
|
|
const scoped = await c.evalExpr(`
|
||
|
|
(() => {
|
||
|
|
const s = document.getElementById('stream');
|
||
|
|
const html = s ? s.innerHTML : '';
|
||
|
|
// Placeholder scanner: 1969 timestamps, literal "—" chains, "[object Object]".
|
||
|
|
const has1969 = /1969-|Wed Dec 31 1969/.test(html);
|
||
|
|
const hasObjObj = /\\[object Object\\]/.test(html);
|
||
|
|
// Tool cards render as .tool-card (or generic .card with role=tool).
|
||
|
|
const toolCards = s ? s.querySelectorAll('.tool-card, [data-kind="tool"]').length : 0;
|
||
|
|
// Trace footer / drawer glyph presence.
|
||
|
|
const footerHasTokens = s ? !!s.querySelector('.trace-footer, .turn-footer, [data-role="trace-footer"]') : false;
|
||
|
|
// reasoning drawer / button presence.
|
||
|
|
const reasoningBtn = s ? !!s.querySelector('[data-tab="reasoning"], .reasoning-toggle') : false;
|
||
|
|
return { htmlLen: html.length, has1969, hasObjObj, toolCards, footerHasTokens, reasoningBtn };
|
||
|
|
})()
|
||
|
|
`)
|
||
|
|
// Tracing page row for this session (non-—).
|
||
|
|
const tracingRow = await c.evalExpr(`
|
||
|
|
(async () => {
|
||
|
|
// Switch to the tracing tab briefly to trigger render, then read.
|
||
|
|
if (window.__dshTabs && window.__dshTabs.switchTo) {
|
||
|
|
try { window.__dshTabs.switchTo('tracing'); } catch (_) {}
|
||
|
|
}
|
||
|
|
await new Promise(r => setTimeout(r, 800));
|
||
|
|
const table = document.querySelector('.tracing-table, [data-view="tracing"] table, table.tracing');
|
||
|
|
const rowCount = table ? table.querySelectorAll('tbody tr').length : 0;
|
||
|
|
// find row containing our session id
|
||
|
|
let ourRowText = null;
|
||
|
|
if (table) {
|
||
|
|
for (const tr of table.querySelectorAll('tbody tr')) {
|
||
|
|
if (tr.textContent && tr.textContent.indexOf(${JSON.stringify(sid.slice(0, 8))}) >= 0) {
|
||
|
|
ourRowText = tr.textContent.replace(/\\s+/g, ' ').trim();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// switch back to chat
|
||
|
|
if (window.__dshTabs && window.__dshTabs.switchTo) {
|
||
|
|
try { window.__dshTabs.switchTo('chat'); } catch (_) {}
|
||
|
|
}
|
||
|
|
return { rowCount, ourRowText };
|
||
|
|
})()
|
||
|
|
`, true, 20000)
|
||
|
|
return { ...scoped, tracing: tracingRow, taskId }
|
||
|
|
}
|
||
|
|
|
||
|
|
async function collectErrors(c) {
|
||
|
|
return c.evalExpr('({ errs: (window.__battery && window.__battery.errs) || [], apiCalls: (window.__battery && window.__battery.apiCalls) || 0 })')
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runTask(c, task, apiBudget) {
|
||
|
|
const wd = path.join(SANDBOX_ROOT, task.id)
|
||
|
|
fs.mkdirSync(wd, { recursive: true })
|
||
|
|
if (task.setup) task.setup(wd)
|
||
|
|
const sess = await newSessionWithCwd(c, wd)
|
||
|
|
const sid = sess.id
|
||
|
|
log(task.id, task.title, '→ sid', sid, 'wd', wd)
|
||
|
|
const prompt = task.prompt(wd)
|
||
|
|
const { snapshot, cancelResult } = await sendPromptAndCollect(c, sid, prompt, {
|
||
|
|
cancelAfterMs: task.cancelAfterMs,
|
||
|
|
budget: task.cancelAfterMs ? 20000 : 90000,
|
||
|
|
})
|
||
|
|
const meta = { cancelResult, forkResult: null }
|
||
|
|
if (task.fork) {
|
||
|
|
try {
|
||
|
|
meta.forkResult = await c.evalExpr(`
|
||
|
|
(async () => await window.dsh.forkSession({ sessionId: ${JSON.stringify(sid)}, boundary: { fromSeq: 1 } }))()
|
||
|
|
`, true, 15000)
|
||
|
|
} catch (e) { meta.forkResult = { error: String(e.message) } }
|
||
|
|
}
|
||
|
|
// Follow-up second turn (T10).
|
||
|
|
let followUpText = null
|
||
|
|
if (task.followUp) {
|
||
|
|
const fu = await sendPromptAndCollect(c, sid, task.followUp.prompt, { budget: 90000 })
|
||
|
|
followUpText = fu.snapshot.text
|
||
|
|
}
|
||
|
|
const verdict = task.judge(wd, snapshot.text, meta)
|
||
|
|
const followUpVerdict = task.followUp ? task.followUp.judge(followUpText) : null
|
||
|
|
const render = await renderAssertions(c, sid, task.id)
|
||
|
|
return {
|
||
|
|
id: task.id, title: task.title, sid, wd,
|
||
|
|
prompt, replyText: snapshot.text.slice(0, 400) + (snapshot.text.length > 400 ? '…' : ''),
|
||
|
|
replyLen: snapshot.text.length,
|
||
|
|
eventCount: snapshot.eventCount,
|
||
|
|
done: snapshot.done,
|
||
|
|
sendErr: snapshot.sendErr,
|
||
|
|
meta,
|
||
|
|
verdict,
|
||
|
|
followUpText: followUpText ? followUpText.slice(0, 300) : null,
|
||
|
|
followUpVerdict,
|
||
|
|
render,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function buttonScan(c, someSid) {
|
||
|
|
// Select a real-data session and try clicking a set of common interactive elements.
|
||
|
|
await c.evalExpr(`window.__dshRenderer && window.__dshRenderer.selectSession && window.__dshRenderer.selectSession(${JSON.stringify(someSid)})`)
|
||
|
|
await new Promise((r) => setTimeout(r, 500))
|
||
|
|
const scan = await c.evalExpr(`
|
||
|
|
(() => {
|
||
|
|
const dead = [];
|
||
|
|
const clicked = [];
|
||
|
|
const s = document.getElementById('stream');
|
||
|
|
if (!s) return { dead: ['no-stream'], clicked: [] };
|
||
|
|
// 1. tool-card expand triggers
|
||
|
|
const expandables = Array.from(s.querySelectorAll('summary, [data-toggle], .expandable, .tool-card summary, details > summary'));
|
||
|
|
for (const el of expandables.slice(0, 10)) {
|
||
|
|
const before = el.closest('details') ? el.closest('details').open : null;
|
||
|
|
try {
|
||
|
|
el.click();
|
||
|
|
clicked.push({sel:'expandable', tag:el.tagName, before, after: el.closest('details') ? el.closest('details').open : null});
|
||
|
|
} catch (e) { dead.push({sel:'expandable-click', err: String(e.message)}); }
|
||
|
|
}
|
||
|
|
// 2. { } drawer buttons
|
||
|
|
const jsonBtns = Array.from(document.querySelectorAll('.copy-json, .open-json, [data-action="open-json"], [aria-label*="JSON" i]'));
|
||
|
|
for (const el of jsonBtns.slice(0, 5)) {
|
||
|
|
try { el.click(); clicked.push({sel:'json-btn', tag:el.tagName}); }
|
||
|
|
catch(e){ dead.push({sel:'json-btn', err: String(e.message)}); }
|
||
|
|
}
|
||
|
|
// 3. tab buttons in detail panes (Feedback/Input/Output/Attributes/Error)
|
||
|
|
const tabs = Array.from(document.querySelectorAll('.tab-button, [role="tab"]'));
|
||
|
|
for (const el of tabs.slice(0, 8)) {
|
||
|
|
try { el.click(); clicked.push({sel:'tab', tag:el.tagName, label:(el.textContent||'').trim().slice(0,20)}); }
|
||
|
|
catch(e){ dead.push({sel:'tab', err: String(e.message)}); }
|
||
|
|
}
|
||
|
|
// 4. copy buttons
|
||
|
|
const copyBtns = Array.from(document.querySelectorAll('.copy-btn, [data-action="copy"]'));
|
||
|
|
for (const el of copyBtns.slice(0, 5)) {
|
||
|
|
try { el.click(); clicked.push({sel:'copy', tag:el.tagName}); }
|
||
|
|
catch(e){ dead.push({sel:'copy', err: String(e.message)}); }
|
||
|
|
}
|
||
|
|
return { dead, clicked, counts: {
|
||
|
|
expandables: expandables.length, jsonBtns: jsonBtns.length, tabs: tabs.length, copyBtns: copyBtns.length,
|
||
|
|
} };
|
||
|
|
})()
|
||
|
|
`)
|
||
|
|
return scan
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
await ensureSandbox()
|
||
|
|
const c = await connect()
|
||
|
|
try {
|
||
|
|
log('runtimeStatus:')
|
||
|
|
const rs = await c.evalExpr('(async()=>await window.dsh.runtimeStatus())()')
|
||
|
|
console.log(JSON.stringify(rs, null, 2))
|
||
|
|
if (rs.profile !== 'stdio-deepseek') {
|
||
|
|
log('WARN: expected stdio-deepseek, got', rs.profile, '— switching')
|
||
|
|
await c.evalExpr('(async()=>await window.dsh.startRuntime("stdio-deepseek"))()')
|
||
|
|
// wait
|
||
|
|
for (let i = 0; i < 15; i++) {
|
||
|
|
await new Promise((r) => setTimeout(r, 3000))
|
||
|
|
const s = await c.evalExpr('(async()=>{const x=await window.dsh.runtimeStatus();return {status:x.status,profile:x.profile}})()')
|
||
|
|
log('poll', s)
|
||
|
|
if (s.status === 'running' && s.profile === 'stdio-deepseek') break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const results = []
|
||
|
|
for (const t of TASKS) {
|
||
|
|
try {
|
||
|
|
const r = await runTask(c, t, 40)
|
||
|
|
results.push(r)
|
||
|
|
log(t.id, 'verdict:', r.verdict.pass ? 'PASS' : 'FAIL', '—', r.verdict.why)
|
||
|
|
} catch (e) {
|
||
|
|
log(t.id, 'EXCEPTION', e.message)
|
||
|
|
results.push({ id: t.id, title: t.title, error: String(e.message), verdict: { pass: false, why: 'exception: ' + e.message } })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Button scan on last session that had real data (prefer T01).
|
||
|
|
let scan = null
|
||
|
|
try {
|
||
|
|
const anySid = results.find((r) => r.sid && r.verdict.pass)?.sid || results[0]?.sid
|
||
|
|
if (anySid) scan = await buttonScan(c, anySid)
|
||
|
|
} catch (e) {
|
||
|
|
scan = { error: String(e.message) }
|
||
|
|
}
|
||
|
|
|
||
|
|
const errCollect = await collectErrors(c)
|
||
|
|
const report = {
|
||
|
|
timestamp: nowIso(),
|
||
|
|
profile: rs.profile,
|
||
|
|
model: rs.model,
|
||
|
|
results,
|
||
|
|
buttonScan: scan,
|
||
|
|
capturedErrors: errCollect.errs,
|
||
|
|
apiCallsCounted: errCollect.apiCalls,
|
||
|
|
passCount: results.filter((r) => r.verdict.pass).length,
|
||
|
|
totalTasks: results.length,
|
||
|
|
followUpPassCount: results.filter((r) => r.followUpVerdict && r.followUpVerdict.pass).length,
|
||
|
|
followUpTotal: results.filter((r) => r.followUpVerdict).length,
|
||
|
|
}
|
||
|
|
const outPath = path.join(SANDBOX_ROOT, 'battery-report.json')
|
||
|
|
fs.writeFileSync(outPath, JSON.stringify(report, null, 2))
|
||
|
|
log('wrote', outPath)
|
||
|
|
log('SUMMARY', report.passCount + '/' + report.totalTasks, 'PASS followUps', report.followUpPassCount + '/' + report.followUpTotal, ' apiCalls', report.apiCallsCounted, 'errs', report.capturedErrors.length)
|
||
|
|
} finally {
|
||
|
|
c.close()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((e) => { console.error(e.stack || e.message); process.exit(1) })
|