133 lines
9.1 KiB
JavaScript
133 lines
9.1 KiB
JavaScript
|
|
// 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)
|