Files
AgentLab/scripts/check-ledger.mjs
T
ThearchyHelios 9ad6c035d0 口径账本的界面检查:check-ledger 换成真检查,check-evidence、check-chat 各加一段
都用 page.route 假接口加夹具(scripts/ledger-fixtures.mjs:GET /api/runs/{id}/ledger 的形状照服务端 run_view,挂在可点击
证据的夹具 evidence-doc.json 上,只用通用名),写请求在浏览器层拦下、核对请求体,认不出的写请求一律记账、结束时必须为空。

- check-ledger(骨架里的占位换掉):
  · 比对还没出结果:「口径」一步写「正在比对口径」、按退避再取(间隔越来越长),等满了照实说、给「再取一次」、不再取,
    点了重新取、比对过了就画出来,之后不再取(store/ledger 在开发构建里挂 window.__ledger,把等待起点挪到两分钟前);
  · 三种情况:来自口径(口径、级别、怎么认的、参数、事后补比的说明)、相关口径(差在哪 + 看差多少)、这次的推理;
  · 对账:参数要核对时表单直接打开、照入口字段画 → 发起带核对过的参数(空着的不送)→ 409 带回的选项画「选择要对照的
    指标」、一组没选不发起 → 选好再发起带上对照 → 结果卡片先写进行中、再取一次后画出按你的问法 / 按口径 / 差值 / 相对差,
    点差值打开出处链;
  · 可沉淀的问题:工作流列表弹窗里的抽屉,三类来源分开写、各写怎么认出来的;聚类只按那一轮固化;「不是口径」不写理由
    不能确认、写了才记下并拿掉这一项;固化走 from-run 的 freeze(带同组的别的运行和来源轮次)、打开画布里的草稿;
  · 使用者:不能出具的照服务端的原因写「请搭建者出具」、不摆按钮,对账照样能用,出具过的正式运行不给链接、写由搭建者查看;
  · 设置里口径账本一组:显示生效的值,不合规的值写范围、不让存,保存条写改了哪几项,整组写 ledger。
- check-evidence「口径账本」一节:来自口径的数字挂「受管 / 已发布」小标记(紧跟数字、悬停写口径和认法、读屏念在数字上),
  相关口径、这次的推理、没比对的不挂;面板「口径」一步在出处链最上面,四种标签各写各的,不是数的片段不画;同一次运行只取
  一次账本。别的节的夹具一律答不比对,不落到真服务端。
- check-chat「口径账本」一节:交付了的轮次底部先写「正在比对口径」,按退避再取到比对出结果后写摘要、比完不再取;按口径正式
  出具、对账按钮照服务端的动作摆,出具的请求只说按哪份口径,出具过的正式运行挂在轮次下面(档位、打开运行);对账请求带
  哪条查询、哪份口径、核对过的参数。
2026-10-16 20:34:21 +08:00

402 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// 口径账本的界面检查(docs/design/next-round/02-口径账本.md 做法 3–6、8 的界面,第 4 波 W4-c)。
//
// 守的是:
// - 证据面板「口径」一步:比对还没出结果时写「正在比对口径」,按退避再取(不靠实时流:ledger.matched 在事件总线关闭
// 之后才追加);等满了还没有结论照实说、给「再取一次」,点了重新取、取到就画;三种情况(来自口径 / 相关口径 / 这次的
// 推理)各写各的;
// - 口径对账:核对参数(推断的参数要先核对)→ 发起(认不出列怎么配时照 409 带回的选项选对照的指标,再发起)→ 结果卡片:
// 这次 / 口径 / 差值 / 相对差四个数各自取值、点开是出处链;请求体逐项核对;
// - 可沉淀的问题:工作流列表弹窗里的抽屉,三类来源分开写;「固化成口径草稿」走 from-run 的 freeze(带同组的别的运行)、
// 打开画布;聚类按人挑的那一轮固化;「不是口径」要写理由;
// - 使用者看到的入口照服务端的动作:不能出具的写原因(请搭建者出具)、不摆出具按钮,能对账的照样对账;出具过的正式运行
// 不给打开运行的链接(使用者读不了它的原始记录),写由搭建者查看;
// - 设置里口径账本一组:和其余偏好共用保存条,整组写 ledger,值不合规不让存。
//
// 不写库:所有写请求都在浏览器层拦下(伪造的答复),核对请求体;认不出的写请求一律 409 并记账,检查结束时必须是空的。
// 夹具见 scripts/ledger-fixtures.mjs(挂在可点击证据的夹具 evidence-doc.json 上,只用通用名)。
//
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273:
// AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-ledger.mjs
import { readFileSync } from 'node:fs'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
import {
ISSUE, QUEUE, RECONCILE, RECONCILE_VALUES, WHOLE, WHOLE_LABELS, ledgerOf, metricDetail, reconciledOf,
} from './ledger-fixtures.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const ONLY = (process.env.CHECK_ONLY ?? '').split(',').map((s) => s.trim()).filter(Boolean)
const fx = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-doc.json', import.meta.url), 'utf8'))
const RUN = fx.run_id
let failed = 0
const check = (name, cond, detail = '') => {
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 200)
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
if (!cond) failed++
}
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
}
const until = async (fn, ms = 6000, step = 100) => {
const end = Date.now() + ms
let v = await fn()
while (!v && Date.now() < end) {
await new Promise((r) => setTimeout(r, step))
v = await fn()
}
return v
}
const stray = []
const ADMIN = { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false }
const USER = { mode: 'local', username: 'carol', display_name: '使用者丙', role: 'user', groups: [], verified: true, user_can_chat: true }
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 开一页:/api/ 下的请求按 reply 答,记下每个请求(sent);reply 不认的 GET 交给真服务端,不认的写请求一律 409 并记进
* stray(检查结束时必须是空的)
*/
async function open(path, { me = ADMIN, reply = () => null, w = 1280, h = 900 } = {}) {
const ctx = await browser.newContext({ viewport: { width: w, height: h } })
opened.add(ctx)
ctx.setDefaultTimeout(8000)
const page = await ctx.newPage()
const errors = []
const sent = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
const req = route.request()
const url = new URL(req.url())
const p = decodeURIComponent(url.pathname.replace(/^\/api/, ''))
let body = null
try { body = req.postDataJSON() } catch { body = null }
sent.push({ key: `${req.method()} ${p}`, body, at: Date.now() })
if (p === '/auth/me') return route.fulfill({ json: me })
const out = await reply(req.method(), p, body, url)
if (out) return route.fulfill({ status: out.status ?? 200, json: out.json ?? {} })
if (req.method() === 'GET') return route.continue()
stray.push(`${req.method()} ${p}`)
return route.fulfill({ status: 409, json: { detail: '检查脚本不写库' } })
})
await page.goto(`${WEB}${path}`, { waitUntil: 'domcontentloaded' })
return { ctx, page, errors, sent }
}
/** 可点击证据的组件预览页:片段接口、证据图按夹具答,账本按 ledger() 答 */
function evidenceReply(ledger, extra = () => null) {
return async (method, p, body, url) => {
const more = await extra(method, p, body, url)
if (more) return more
if (method !== 'GET') return null
if (p === `/runs/${RUN}/ledger`) return { json: await ledger() }
const seg = p.match(new RegExp(`^/runs/${RUN}/evidence/segments/([^/]+)$`))
if (seg) return fx.segments[seg[1]] ? { json: fx.segments[seg[1]] } : { status: 404, json: { detail: '报告中没有这个片段' } }
if (p === `/runs/${RUN}/evidence`) return { json: fx.graph }
// 组件预览页上别的几份夹具:不比对
const other = p.match(/^\/runs\/([^/]+)\/ledger$/)
if (other) return { json: { run_id: other[1], state: 'ineligible', reason: '检查脚本的夹具不比对口径' } }
return null
}
}
const panelPart = (page) => page.locator('[data-evidence-panel] [data-ev-ledger]')
async function openSeg(page, id) {
await page.locator(`#evidence-wide [data-seg="${id}"]`).click()
await page.waitForSelector('[data-evidence-panel]', { timeout: 4000 }).catch(() => {})
}
// ---------------------------------------------------------------- 正在比对、退避、等满了、再取一次
await section('比对还没出结果:正在比对口径、按退避再取、等满了照实说、再取一次', async () => {
const state = { matched: false, gets: [] }
const { page, errors } = await open('/ui-harness.html?evidence=1', { reply: evidenceReply(() => {
state.gets.push(Date.now())
return state.matched ? ledgerOf(RUN) : { run_id: RUN, state: 'pending', note: '封存后比对,不在封存范围内' }
}) })
await page.waitForSelector('[data-evidence-doc]', { timeout: 10000 })
await openSeg(page, 's6')
const waiting = await page.locator('[data-evidence-panel] [data-ledger-state="pending"]').waitFor({ timeout: 5000 }).then(() => true, () => false)
check('「口径」一步写「正在比对口径」', waiting && (await panelPart(page).innerText()).includes('正在比对口径'))
check('……说明比完会自动更新', (await panelPart(page).innerText()).includes('比完这里会自动更新'))
check('还没比完时正文上不挂小标记', await page.locator('[data-ev-caliber-of]').count() === 0)
await until(() => state.gets.length >= 4, 9000)
const gaps = state.gets.slice(1).map((t, i) => t - state.gets[i])
check('按退避再取:间隔越来越长(1、2、3 秒……)', state.gets.length >= 4 && gaps[0] >= 700 && gaps[1] >= gaps[0] - 150
&& gaps[2] >= gaps[1] - 150, gaps.map((g) => `${g}ms`).join(', '))
// 把这一轮等待的起点挪到两分钟以前:下一次取回来还是 pending,就该照实说等满了,不再取
await page.evaluate((run) => window.__ledger.setState((s) => ({ slots: { ...s.slots, [run]: { ...s.slots[run], since: Date.now() - 200_000 } } })), RUN)
const timedOut = await page.locator('[data-evidence-panel] [data-ledger-state="timeout"]').waitFor({ timeout: 15000 }).then(() => true, () => false)
check('等满了还没有结论:照实说,给「再取一次」', timedOut
&& (await panelPart(page).innerText()).includes('比对口径还没有结果') && await page.locator('[data-ledger-retry]').count() >= 1)
const after = state.gets.length
await page.waitForTimeout(3500)
check('……等满了就不再取', state.gets.length === after, `${state.gets.length - after} 次`)
state.matched = true
await page.locator('[data-evidence-panel] [data-ledger-retry]').click()
const drawn = await page.locator('[data-evidence-panel] [data-ev-ledger="caliber"]').waitFor({ timeout: 5000 }).then(() => true, () => false)
check('点「再取一次」:重新取,比对过了就画出来', drawn && state.gets.length === after + 1, `${state.gets.length - after} 次`)
check('正文上的小标记跟着出来', await page.locator('#evidence-wide [data-ev-caliber-of="s6"]').count() === 1)
await page.waitForTimeout(2500)
check('比对过了、没有要等的:不再取', state.gets.length === after + 1, `${state.gets.length - after} 次`)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
// ---------------------------------------------------------------- 三种情况
await section('「口径」一步的三种情况:来自口径、相关口径、这次的推理', async () => {
const { page, errors } = await open('/ui-harness.html?evidence=1', { reply: evidenceReply(() => ledgerOf(RUN, { retro: true })) })
await page.waitForSelector('#evidence-wide [data-ev-caliber-of="s6"]', { timeout: 10000 })
await openSeg(page, 's6')
await page.locator('[data-evidence-panel] [data-ev-ledger="caliber"]').waitFor({ timeout: 5000 })
const caliber = await panelPart(page).innerText()
check('来自口径:口径、版本、级别、怎么认的、参数', caliber.includes('来自口径「周报口径」v2(受管)') && caliber.includes('等价已证明')
&& caliber.includes('week = 2026-W37'), caliber)
check('……事后补比的写「按现在的口径回看」', caliber.includes('封存后比对,不在封存范围内 · 按现在的口径回看'), caliber)
await openSeg(page, 's8')
await page.locator('[data-evidence-panel] [data-ev-ledger="related"]').waitFor({ timeout: 5000 })
const related = await panelPart(page).innerText()
check('相关口径:差在哪 + 看差多少', related.includes('相关口径「订单口径」v1(已发布)') && related.includes('口径只保留状态为已支付的行')
&& await page.locator('[data-evidence-panel] [data-ledger-reconcile-open]').count() === 1, related)
check('……这个数本身是这次的推理(照实说)', related.includes('这个数是这次的推理'), related)
await openSeg(page, 's12')
await page.locator('[data-evidence-panel] [data-ev-ledger="reasoning"]').waitFor({ timeout: 5000 })
check('这次的推理:没有相关的已发布口径', (await panelPart(page).innerText()).includes('没有相关的已发布口径'))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
// ---------------------------------------------------------------- 对账
await section('口径对账:核对参数 → 发起 → 选对照的指标 → 三个数各自点得开', async () => {
const state = { posts: [], reconciled: [], polls: 0 }
const extra = (method, p, body) => {
if (method === 'POST' && p === `/ledger/answers/${RUN}/reconcile`) {
state.posts.push(body)
if (!body?.pairs) {
return { status: 409, json: { detail: '认不出这次的哪一列对应口径的哪个指标,请选择要对照的指标', code: 'ledger_reconcile_needs_pairs',
options: { columns: ['orders', 'refunds'], metrics: [{ id: 'orders', name: '订单数', unit: '单' }] } } }
}
state.reconciled = [reconciledOf('running')]
return { status: 201, json: { run_id: 'run-rec-1', reconciled: state.reconciled[0] } }
}
const m = p.match(/^\/runs\/run-rec-1\/evidence\/metrics\/reconcile\/([^/]+)$/)
if (method === 'GET' && m) return { json: metricDetail('run-rec-1', m[1]) }
return null
}
const ledger = () => {
// 发起之后:第一次取回来对账还在进行中,再取一次就做完了
if (state.reconciled[0]?.status === 'running') {
state.polls += 1
if (state.polls > 1) state.reconciled = [reconciledOf('succeeded')]
}
return ledgerOf(RUN, { reconciled: state.reconciled })
}
const { page, errors } = await open('/ui-harness.html?evidence=1', { reply: evidenceReply(ledger, extra) })
await page.waitForSelector('#evidence-wide [data-seg="s8"]', { timeout: 10000 })
await openSeg(page, 's8')
await page.locator('[data-evidence-panel] [data-ledger-reconcile-open]').click()
const dialog = page.locator('[data-ledger-reconcile-dialog]')
await dialog.waitFor({ timeout: 4000 })
const text = await dialog.innerText()
check('对账框:差在哪、写明只读', text.includes('口径只保留状态为已支付的行') && text.includes('只读'), text)
check('参数是推断的:先核对(表单直接打开、写原因)', await page.locator('[data-ledger-params="edit"]').count() === 1
&& text.includes('参数按这次的 SQL 推断,请核对'))
check('表单照动作里的入口字段画:推断出的值填好,有缺省值的写明留空用缺省值',
await page.locator('[data-ledger-param-input="start"]').inputValue() === '2026-09-01'
&& await page.locator('[data-ledger-param-input="store"]').inputValue() === '' && text.includes('留空用口径的缺省值'))
await page.locator('[data-ledger-param-input="start"]').fill('2026-09-03')
await page.locator('[data-ledger-reconcile-launch]').click()
await until(() => state.posts.length === 1)
check('第一次发起:带核对过的参数(confirm),空着的不送', JSON.stringify(state.posts[0]) === JSON.stringify({
query: 'q-orders', workflow_id: 'wf-orders', version: 1, inputs: { start: '2026-09-03' }, confirm: true }), JSON.stringify(state.posts[0]))
const pairs = await page.locator('[data-ledger-pairs]').waitFor({ timeout: 4000 }).then(() => true, () => false)
check('认不出列怎么配:照服务端给的选项画「选择要对照的指标」', pairs && await page.locator('[data-ledger-pair]').count() === 2
&& (await page.locator('[data-ledger-reconcile-problem]').innerText()).includes('请选择要对照的指标'))
await page.locator('[data-ledger-reconcile-launch]').click()
check('一组都没选:不发起,说要至少选一组', state.posts.length === 1
&& (await page.locator('[data-ledger-reconcile-problem]').innerText()).includes('至少选一组对照'))
await page.locator('[data-ledger-pair="orders"]').selectOption('orders')
await page.locator('[data-ledger-reconcile-launch]').click()
await until(() => state.posts.length === 2)
check('选好再发起:带上对照(这次的列 → 口径卡上的指标)', JSON.stringify(state.posts[1]?.pairs) === JSON.stringify([{ ours: 'orders', metric: 'orders' }])
&& state.posts[1]?.confirm === true, JSON.stringify(state.posts[1]))
check('发起之后对账框收起', await until(async () => await dialog.count() === 0, 4000))
const card = page.locator('[data-evidence-panel] [data-ledger-reconciled="run-rec-1"]')
const running = await card.waitFor({ timeout: 6000 }).then(() => true, () => false)
check('结果卡片:对账运行还在进行时写「对账进行中」', running && (await card.innerText()).includes('对账进行中'))
const done = await page.locator('[data-evidence-panel] [data-ledger-reconciled-state="succeeded"]').waitFor({ timeout: 12000 }).then(() => true, () => false)
check('……再取一次(运行还没结束时接着等),做完了画出每组对照', done)
await until(async () => (await card.innerText()).includes(RECONCILE_VALUES.diff_0), 5000)
const cardText = (await card.innerText()).replace(/\s+/g, ' ')
check('四个数:按你的问法、按口径、差值、相对差', cardText.includes(`按你的问法 ${RECONCILE_VALUES.ours_0}`)
&& cardText.includes(`按口径 ${RECONCILE_VALUES.cal_0}`) && cardText.includes(`差值 ${RECONCILE_VALUES.diff_0}`)
&& cardText.includes(`相对差 ${RECONCILE_VALUES.rel_0}`), cardText)
check('……写参数从哪来、原答案里写的是什么', cardText.includes('按核对过的参数') && cardText.includes('原答案里写的是 8.7%'), cardText)
await card.locator('[data-ledger-value="diff_0"]').click()
const drawer = page.locator('[data-evidence-panel="side"]').last()
const opened = await until(async () => (await drawer.innerText().catch(() => '')).includes('差值'), 5000)
check('点差值:打开它的出处链(同应用页点数)', opened, (await drawer.innerText().catch(() => '')).slice(0, 80))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
// ---------------------------------------------------------------- 可沉淀的问题
const WF = { id: 'wf-led-check', name: '检查用工作流', description: '', version: 1, status: 'draft', tags: [], is_template: false,
graph: { nodes: [{ id: 'in', type: 'input', position: { x: 0, y: 0 }, data: { label: '入口', config: { fields: [] } } }], edges: [] } }
const FROZEN = { ...WF, id: 'wf-frozen', name: '上周销售额是多少(固化的口径草稿)', tags: ['certified'] }
await section('可沉淀的问题:工作流列表的抽屉,三类来源分开写,固化成口径草稿、不是口径要写理由', async () => {
const state = { queue: structuredClone(QUEUE), freezes: [], dismisses: [] }
const reply = (method, p, body) => {
if (method === 'GET' && p === `/workflows/${WF.id}`) return { json: WF }
if (method === 'GET' && p === `/workflows/${FROZEN.id}`) return { json: FROZEN }
if (method === 'POST' && p === '/workflows/validate') return { json: { ok: true, issues: [] } }
if (method === 'POST' && p === '/workflows/variables') return { json: { variables: [], issues: [] } }
if (p.startsWith('/conversations')) return { json: method === 'GET' ? [] : { id: 'cv', kind: 'canvas', title: '', turns: [] } }
if (method === 'GET' && p === '/ledger/certify-queue') return { json: state.queue }
if (method === 'POST' && p === '/ledger/certify-queue/dismiss') {
state.dismisses.push(body)
return { json: { ok: true, item_id: body?.item_id } }
}
if (method === 'POST' && p === '/copilot/from-run') {
state.freezes.push(body)
return { json: { workflow_id: FROZEN.id, name: FROZEN.name, nodes: 5, edges: 4, params: ['p1'], metrics: ['m1'], sources: [] } }
}
return null
}
const { page, errors } = await open(`/studio/${WF.id}`, { reply })
await page.locator('button[title="切换、新建、从模板新建工作流"]').click()
await page.locator('[data-certify-open]').click()
const drawer = page.locator('[data-certify-drawer]')
await drawer.waitFor({ timeout: 5000 })
await page.locator('[data-certify-item]').first().waitFor({ timeout: 5000 })
const kinds = await page.locator('[data-certify-kind]').evaluateAll((els) => els.map((e) => e.getAttribute('data-certify-kind')))
check('三类来源分开列:确定性重复、问题聚类、被反复用的答案', JSON.stringify(kinds) === JSON.stringify(['shape', 'cluster', 'reused']), kinds.join(','))
const text = (await drawer.innerText()).replace(/\s+/g, ' ')
check('各类写明是怎么认出来的(聚类不等于口径相同)', text.includes('确定性重复:同样形状的 SQL') && text.includes('问法相近不等于口径相同'), text.slice(0, 200))
check('确定性重复:几次运行、几段对话、参数候选、已有相关口径', text.includes('4 次运行 · 3 段对话') && text.includes('1 个参数候选 · 1 个口径常量')
&& text.includes('已有相关口径'), text.slice(0, 300))
check('被反复用的答案:怎么被用的、已固化成哪张草稿', text.includes('导出 2 次 · 在画布里打开过') && text.includes('本月新客数(固化的口径草稿)'))
const cluster = page.locator('[data-certify-item="cluster:bb22"]')
check('问题聚类没有共同的 SQL:不给整项固化,按人挑的那一轮固化', await cluster.locator('[data-certify-freeze]').count() === 0
&& await cluster.locator('[data-certify-freeze-turn]').count() === 2)
await cluster.locator('[data-certify-dismiss]').click()
const dialog = page.getByRole('dialog', { name: '这不是口径' })
await dialog.waitFor({ timeout: 4000 })
check('「不是口径」不写理由:确认键不能点', await dialog.getByRole('button', { name: '确认不是口径' }).isDisabled()
&& state.dismisses.length === 0)
await dialog.getByRole('textbox').fill('问的是各门店的临时核对')
await dialog.getByRole('button', { name: '确认不是口径' }).click()
await until(() => state.dismisses.length === 1)
check('写了理由才记下:请求带这一项、种类和理由', JSON.stringify(state.dismisses[0]) === JSON.stringify({
item_id: 'cluster:bb22', kind: 'cluster', note: '问的是各门店的临时核对' }), JSON.stringify(state.dismisses[0]))
check('……这一项从抽屉里拿掉', await until(async () => await page.locator('[data-certify-item="cluster:bb22"]').count() === 0, 3000))
await page.locator('[data-certify-item="shape:aa11"] [data-certify-freeze]').click()
await until(() => state.freezes.length === 1)
check('固化成口径草稿:走 from-run 的 freeze,带同组的别的运行和来源轮次', JSON.stringify(state.freezes[0]) === JSON.stringify({
run_id: 'run-s1', freeze: true, peers: ['run-s2', 'run-s3'], conversation_id: 'c-1', turn_id: 't-1' }), JSON.stringify(state.freezes[0]))
check('……打开画布里的草稿', await until(() => new URL(page.url()).pathname === `/studio/${FROZEN.id}`, 5000), page.url())
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
// ---------------------------------------------------------------- 使用者
await section('使用者看到的入口照服务端的动作:不能出具的写原因,能对账的照样对账,出具的运行不给链接', async () => {
const EV = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-doc.json', import.meta.url), 'utf8'))
const RUN_U = 'run-user-led'
const at = new Date(Date.now() - 86400e3).toISOString()
const conv = { id: 'c-user-led', title: '问数据:上周的周报', kind: 'chat', workflow_id: null, archived: false, created_at: at,
last_active_at: at, turn_count: 1, last_question: '上周的周报', last_status: 'done', last_run_id: RUN_U }
const turn = { id: 'tu1', seq: 0, question: '上周的周报', answer: '(周报原文)', explanation: '', graph: null, run_id: RUN_U,
status: 'done', error: '', review: null, created_at: at,
meta: { v: 1, runId: RUN_U, runClass: 'exploratory', runStatus: 'succeeded', evidence: true } }
const refused = { ...ISSUE, available: false, auto: false, reason: '这份口径没有被你能使用的应用固定,不能由你按口径出具,请搭建者出具' }
delete refused.auto_reason
const LED = ledgerOf(RUN_U, { labels: WHOLE_LABELS, whole: WHOLE, actions: [refused, RECONCILE],
// 出具过的是另一份口径(订单口径):周报口径这一项还没出具过,照服务端的原因写为什么不能出具
issued: [{ run_id: 'run-iss-u', status: 'succeeded', workflow_id: 'wf-orders', version: 1, tier: 'degraded', tier_text: '降档出具', auto: true }] })
const reply = (method, p) => {
if (method === 'GET' && p === '/conversations') return { json: [conv] }
if (method === 'GET' && p === `/conversations/${conv.id}`) return { json: { ...conv, turns: [turn] } }
if (p.startsWith('/conversations')) return { json: {} }
if (method === 'GET' && p === `/runs/${RUN_U}`) return { json: { id: RUN_U, status: 'succeeded', run_class: 'exploratory', output: EV.output, usage: {} } }
if (method === 'GET' && p === `/runs/${RUN_U}/events`) return { json: [] }
if (method === 'GET' && p === `/runs/${RUN_U}/ledger`) return { json: LED }
if (method === 'GET' && p === `/runs/${RUN_U}/evidence`) return { json: EV.graph }
if (method === 'GET' && p.startsWith('/artifacts/')) return { json: { id: EV.doc_artifact, content: { ...EV.doc, run_id: RUN_U } } }
if (method === 'GET' && p.startsWith('/approvals')) return { json: [] }
return null
}
const { page, errors } = await open(`/chat/${conv.id}`, { me: USER, reply })
const box = page.locator('[data-ledger-turn="matched"]').first()
const shown = await box.waitFor({ timeout: 12000 }).then(() => true, () => false)
const text = shown ? (await box.innerText()).replace(/\s+/g, ' ') : ''
check('使用者也看到摘要', text.includes('3 个数来自口径「周报口径」v2(受管)'), text)
check('不能出具:照服务端的原因写「请搭建者出具」,不摆出具按钮', text.includes('请搭建者出具')
&& await page.locator('[data-ledger-issue-button]').count() === 0, text)
check('能对账:照样摆对账按钮', await page.locator('[data-ledger-turn-reconcile]').count() === 1)
const issued = page.locator('[data-ledger-issued="run-iss-u"]')
const issuedText = (await issued.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('自动出具过的正式运行:写「按口径自动出具」、哪份口径和档位', issuedText.includes('按口径自动出具')
&& issuedText.includes('「订单口径」v1(已发布)') && issuedText.includes('降档出具'), issuedText)
check('……使用者不给打开运行的链接,写由搭建者查看', await issued.locator('a').count() === 0 && issuedText.includes('由搭建者在记录页查看'), issuedText)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
// ---------------------------------------------------------------- 设置
await section('设置里的口径账本一组:共用保存条,整组写 ledger,值不合规不让存', async () => {
const state = { puts: [], ledger: { auto_issue: true, issue_daily_limit: 20, match_timeout_s: 8, recall_limit: 50 } }
const reply = (method, p, body) => {
if (method === 'GET' && p === '/settings') return { json: { run: {}, limits: { max_agent_steps: 100 }, apps: {}, ledger: state.ledger } }
if (method === 'PUT' && p === '/settings') {
state.puts.push(body)
if (body?.values?.ledger) state.ledger = body.values.ledger
return { json: { ledger: state.ledger } }
}
return null
}
const { page, errors } = await open('/settings/prefs', { reply })
const sec = page.locator('[data-ledger-settings]')
await sec.waitFor({ timeout: 8000 })
const text = await sec.innerText()
check('四项都在:自动出具、每日上限、比对时限、升版后重算几组', ['自动按口径正式出具', '按口径正式出具每日上限', '口径比对时限(秒)',
'口径升版后每次最多重算几组'].every((t) => text.includes(t)), text)
check('显示的是服务端生效的值', await page.locator('[data-ledger-setting="issue_daily_limit"]').inputValue() === '20'
&& await page.locator('[data-ledger-setting="recall_limit"]').inputValue() === '50'
&& await page.locator('[data-ledger-setting="auto_issue"]').isChecked())
await page.locator('[data-ledger-setting="recall_limit"]').fill('5000')
const bar = page.locator('[data-prefs-bar]')
check('不合规的值:写明范围,保存键不能点', (await sec.innerText()).includes('请填写 0 到 1,000 之间的整数')
&& await bar.getByRole('button', { name: '保存设置' }).isDisabled())
await page.locator('[data-ledger-setting="recall_limit"]').fill('30')
await page.locator('[data-ledger-setting="auto_issue"]').uncheck()
check('保存条写改了哪几项(服务端的叫法)', (await bar.innerText()).includes('有 2 项未保存')
&& (await bar.innerText()).includes('自动按口径正式出具') && (await bar.innerText()).includes('口径升版后每次最多重算几组'))
await bar.getByRole('button', { name: '保存设置' }).click()
await until(() => state.puts.length === 1)
check('整组写 ledger(没改的项照原值带上,不会被冲回默认)', JSON.stringify(state.puts[0]?.values) === JSON.stringify({
ledger: { auto_issue: false, issue_daily_limit: 20, match_timeout_s: 8, recall_limit: 30 } }), JSON.stringify(state.puts[0]))
check('存完:保存条写「已保存」', await until(async () => (await bar.innerText().catch(() => '')).includes('已保存'), 3000))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
check('认不出的写请求一个都没有(都在浏览器层拦下)', stray.length === 0, stray.join(' | '))
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 口径账本全部通过')
process.exit(failed ? 1 : 0)