Files
deepseek-harness/missions/scripts/verify-session-real.mjs
T

133 lines
9.1 KiB
JavaScript
Raw Normal View History

2026-07-20 18:50:24 +08:00
// Real-host spot check (condensed acceptance + connection stability): real sessions in the list,
// history renders on open, real prompt streams back. The stability assertions guard against
// fixture masking: fake streams never touch real SSE, so bridge-layer bugs (e.g. the req 'close'
// misdetection) only surface against a real host.
import { chromium } from 'playwright'
const BASE = process.env.VERIFY_BASE ?? 'http://127.0.0.1:3080'
let failures = 0
const report = (n, p, d = '') => { failures += p ? 0 : 1; console.log(`${p ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`) }
const browser = await chromium.launch()
try {
const page = await browser.newPage()
page.on('pageerror', (e) => console.log('[pageerror]', String(e).slice(0, 300)))
const apiRequests = []
let apiFailed = 0
page.on('request', (r) => { if (r.url().includes('/api/')) apiRequests.push(r.url()) })
page.on('requestfailed', (r) => { if (r.url().includes('/api/')) apiFailed++ })
await page.goto(`${BASE}/`, { waitUntil: 'load' })
// E2-0 connection stability: within a 12s window /api requests must be one-time setup cost
// (two streams + describe + list <= 10), zero aborts. The 300ms reconnect storm
// (the bridge bug fixed 2026-07-20) shows up here instantly.
await page.waitForTimeout(12000)
report('E2-0a 12s 内 /api 请求 ≤10(无重连风暴)', apiRequests.length <= 10, `count=${apiRequests.length}`)
report('E2-0b 无 requestfailed(SSE 不被 client abort)', apiFailed === 0, `failed=${apiFailed}`)
// E2-0c cold-session merge: list must include persisted sessions from previous host runs,
// not just in-memory attached ones (guards the R4 regression: first screen empty after
// restart). Requires at least one prior run's session on disk — every run of this script
// leaves some behind, so only a truly virgin .sessions root skips the assertion.
const listRes = await page.evaluate(async (base) => {
const res = await fetch(`${base}/api/session.list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId: 'verify-cold-list', method: 'session.list', payload: {} }),
})
return res.json()
}, BASE)
const coldItems = listRes?.result?.ok ? listRes.result.value.items : []
const sorted = coldItems.every((it, i) => i === 0 || coldItems[i - 1].updatedAt >= it.updatedAt)
if (coldItems.length > 0) {
report('E2-0c 冷 session 进 list 且 updatedAt 倒序', sorted, `count=${coldItems.length}`)
const coldRows = await page.locator('aside button[class*="item"]').count()
report('E2-0d 首屏列表渲染冷 session(非空)', coldRows >= 1, `rows=${coldRows}`)
// Legacy no-cwd logs are not served (pre-release stance: no compatibility) —
// every listed session must carry its project cwd.
const noCwd = coldItems.filter((it) => typeof it.cwd !== 'string' || it.cwd.length === 0)
report('E2-0c2 无 cwd 存量不可见(全部条目携带 project cwd)', noCwd.length === 0, `noCwd=${noCwd.length}`)
} else {
console.log('SKIP E2-0c/E2-0c2/E2-0d 冷 session 断言(.sessions 为空的全新 host)')
}
// E2-0e error-channel fidelity: an unknown id must come back as session-not-found,
// never disguised as internal (and vice versa — guards the R3 regression).
const nf = await page.evaluate(async (base) => {
const res = await fetch(`${base}/api/session.history`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId: 'verify-not-found', method: 'session.history', payload: { sessionId: 'session-00000000-dead-beef-0000-000000000000' } }),
})
return res.json()
}, BASE)
report('E2-0e 未知 id 回 session-not-found(不伪装 internal)', nf?.result?.ok === false && nf.result.error.code === 'session-not-found', `code=${nf?.result?.error?.code}`)
// E2-1 create a real session into the list via '+' (covers the create path).
await page.locator('aside button[title="新建 session"]').click()
await page.waitForSelector('aside button[class*="item"]', { timeout: 8000 })
const n = await page.locator('aside button[class*="item"]').count()
report('E2-1 新建真 session 入列表', n >= 1, `count=${n}`)
// E2-1b default-project injection: a create without an explicit cwd must still get one
// (the host default — its process working directory), so the session lands in a project
// bucket instead of _no-cwd (guards the B-decision regression).
const afterCreate = await page.evaluate(async (base) => {
const res = await fetch(`${base}/api/session.list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId: 'verify-default-cwd', method: 'session.list', payload: {} }),
})
return res.json()
}, BASE)
const newest = afterCreate?.result?.ok ? afterCreate.result.value.items[0] : undefined
report('E2-1b 新建 session 携带默认 cwd(host 进程目录注入)', typeof newest?.cwd === 'string' && newest.cwd.length > 0, `cwd=${newest?.cwd ?? '(absent)'}`)
// E2-2 open the first row: openState reaches open (input enabled)
await page.locator('aside button[class*="item"]').first().click()
await page.waitForSelector('main textarea:not([disabled])', { timeout: 8000 })
report('E2-2 打开真 session(history 通、输入可用)', true)
// E2-3 real prompt: user bubble lands + partial pulse (real model streaming).
// Ask for a ~100-char reply: too-short replies finish inside waitForSelector's polling gap,
// making the pulse assertion race into a false failure.
await page.locator('main textarea').fill('用大约100字介绍事件溯源,最后一句以「介绍完毕」结尾')
await page.locator('main button[class*="primary"]').click()
await page.waitForSelector('main div[class*="bubble"]', { timeout: 5000 })
report('E2-3a user 气泡入流', true)
const sawPulse = await page.waitForSelector('main span[class*="pulse"]', { timeout: 30000 }).then(() => true).catch(() => false)
report('E2-3b 真模型流式 partial 出现', sawPulse)
await page.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 60000 })
const text = (await page.locator('main').textContent()) ?? ''
report('E2-3c 回复定稿入流', text.includes('介绍完毕') || text.includes('事件溯源'), text.slice(-60))
// E2-4 stop mid-stream freezes the partial (aborted turns never finalize): the accumulated
// text survives as an interrupted terminal node (已停止 marker), the pulse stops, and later
// messages land after it. A reload must reconstruct the same node from the logged chunks.
const primary = page.locator('main button[class*="primary"]')
await page.locator('main textarea').fill('请从头背诵出师表全文,直接开始不要客套')
await primary.click()
await page.waitForSelector('main span[class*="pulse"]', { timeout: 30000 })
// Let visible content accumulate so the frozen node has a body to keep.
await page.waitForTimeout(2500)
await primary.click() // stop mid-stream (the no-finalize abort path)
await page.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 15000 })
const pulseGone = await page.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 2000 }).then(() => true).catch(() => false)
const frozenMark = await page.locator('main span[class*="stopped"]', { hasText: '已停止' }).count()
report('E2-4a 停止后 partial 定格(脉冲停+已停止标记+文本保留)', pulseGone && frozenMark >= 1, `pulseGone=${pulseGone} marks=${frozenMark}`)
await page.locator('main textarea').fill('请只回复四个字:顺序正常')
await primary.click()
await page.waitForSelector('main div[class*="bubble"]:has-text("顺序正常")', { timeout: 10000 })
const rows = await page.evaluate(() => {
const scroll = document.querySelector('main div[class*="scroll"]')
return [...(scroll?.children ?? [])].map((el) => (el.textContent ?? '').trim()).filter(Boolean)
})
const iStopped = rows.findIndex((t) => t.includes('出师表'))
const iNew = rows.findIndex((t) => t.includes('顺序正常'))
report('E2-4b 停止后再发消息顺序正确(新消息在末尾)', iNew > iStopped && iStopped >= 0, `stopped@${iStopped} new@${iNew}`)
// E2-4c reload: history replay re-freezes the interrupted node (live view and replay agree).
await page.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 60000 })
await page.reload({ waitUntil: 'load' })
await page.locator('aside button[class*="item"]').first().click()
await page.waitForSelector('main textarea:not([disabled])', { timeout: 8000 })
const marksAfterReload = await page.locator('main span[class*="stopped"]', { hasText: '已停止' }).count()
report('E2-4c 刷新后中断消息仍在(history 重建一致)', marksAfterReload >= 1, `marks=${marksAfterReload}`)
} catch (e) {
failures += 1
console.log(`FAIL 脚本异常 — ${String(e).slice(0, 300)}`)
} finally {
await browser.close()
}
console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`)
process.exit(failures === 0 ? 0 : 1)