mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:41:59 +00:00
7576fa6ce9
为什么:这一波合入就进 main,后端新加的封存后事件、来源链要在界面上接得住,不能有订阅了收不到、点了没用的地方
(next-round 02 做法 2、9)。账本的标签、证据面板的「口径」一步、对账在第 4 波接界面,这一波只有接口。
- run/decode.ts:ledger.matched 解读成时间线上一行「比对已发布口径:1 条查询等价已证明,2 条有相关口径」,副标题写明「封存后比对,
不在封存范围内」,事后补比、按需比的另写「按现在的口径回看」;有没比完的标成提醒;不改相位。文案进 lib/terms.ts 的 LEDGER_TEXT
(连同 X10 的叫法:来自口径 / 相关口径 / 这次的推理 / 没有比对,等价已证明 / 助手选用,受管 / 已发布)。
- store/chat.ts:发起运行时带 origin_ref {conversation_id, turn_id}(等开轮请求拿到服务端的轮次 id);服务端核对不过
(origin_ref_invalid:对话刚被删)时不带来源照常发起——来源只是留痕,不挡这一轮的回答。
- pages/ChatPage.tsx:「在画布里打开」带 origin {run_id, conversation_id, turn_id}(这一轮跑过才有);核对不过时不带来源照常新建。
run/AssistantStream.tsx 的 onOpenGraph 多给一个轮次 id(只加参数,别处不受影响)。
- api/client.ts:runs.start 的 origin_ref、workflows.create 的 origin。
- 检查脚本:check-decode 加「口径账本」一节;check-chat 断言发起时带上 origin_ref、「在画布里打开」带上来源;check-report-export
加「口径一步」:没比对过的运行没有这一步,数据块里放一份编的标签断网打开,面板最上面是「口径」、写明封存后比对、遮罩的参数写
「已遮罩」(只打印这一节,不打印真数据)。
验证:tsc、check-copy;check-all --stacks 1 decode chat report-export 全过(1485 项)。
456 lines
25 KiB
JavaScript
456 lines
25 KiB
JavaScript
// 报告导出的检查:真服务端导出、断网打开点数字、不执行脚本也读得到正文;外加记录页对话框里那份导出按钮。
|
||
//
|
||
// 跑之前前后端都得起着(check-all 另起的那套,或者 ./scripts/dev.sh)。默认连 5273 / 8000;对别的实例跑时带上地址:
|
||
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-report-export.mjs
|
||
//
|
||
// 分几段:
|
||
// - 导出:从服务端找一份成功结束、有报告文档的运行(GET /api/runs 找成功结束的,再看证据接口),下载 report.html:
|
||
// 状态码、类型、附件名(RFC 5987)、页面本身零外链、封存哈希不执行脚本也看得到、导出只读不追加事件。
|
||
// 找不到带报告文档的运行时照实报失败并说明原因;
|
||
// - 断网打开:拦掉一切网络请求,从本地文件打开:正文(服务端渲染的 HTML)读得到,头部写明报告节点、运行、封存状态;
|
||
// 点开带出处的数字看得到出处链——口径卡指标(口径、原式、代入式、输入、复算)、查询(SQL、被引用的行)、
|
||
// 数据源遮罩列写「已遮罩」;Esc 关掉面板;没有发出任何请求;
|
||
// - 不执行脚本:正文照样读得到(附件预览常常不执行脚本),封存哈希和运行 ID 静态写着;
|
||
// - 深浅色、窄屏:390 宽不横向溢出,点开的面板从底部抽出,深色下底是深的、字是浅的。
|
||
//
|
||
// 读的是真数据:输出里不打印工作流名、报告内容和数字,只打印计数和运行号前 8 位。下载的文件写在临时目录,跑完删掉。
|
||
// 导出接口只读(不追加事件、不改运行)。找不到能点开指标数字的运行时,只查单元格引用的那条链,原因写明。
|
||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||
import { homedir, tmpdir } from 'node:os'
|
||
import { dirname, join } from 'node:path'
|
||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||
|
||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
||
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
|
||
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||
|
||
let failed = 0
|
||
const check = (name, cond, detail = '') => {
|
||
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
|
||
if (!cond) failed++
|
||
}
|
||
const note = (text) => console.log(` · ${text}`)
|
||
/** 等一处元素出现:locator.isVisible() 不等待,提示、按钮恢复要等一会儿才到 */
|
||
const appears = (locator, timeout = 5000) => locator.first().waitFor({ state: 'visible', timeout }).then(() => true, () => false)
|
||
|
||
const TMP = mkdtempSync(join(tmpdir(), 'agentlab-report-'))
|
||
// 导出的页面带着真数据:中途崩了也要删掉
|
||
process.on('exit', () => rmSync(TMP, { recursive: true, force: true }))
|
||
const browser = await chromium.launch({ executablePath: CHROME })
|
||
const cleanup = async () => { await browser.close().catch(() => {}); rmSync(TMP, { recursive: true, force: true }) }
|
||
|
||
async function section(name, fn) {
|
||
console.log(`\n=== ${name} ===`)
|
||
try {
|
||
await fn()
|
||
} catch (e) {
|
||
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||
}
|
||
}
|
||
|
||
const getJson = async (path) => {
|
||
const res = await fetch(`${API}${path}`)
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
return res.json()
|
||
}
|
||
|
||
const DATA_RE = /(<script type="application\/json" id="report-data">)([\s\S]*?)(<\/script>)/
|
||
const dataOf = (html) => JSON.parse(DATA_RE.exec(html)[2])
|
||
|
||
function writePage(name, html) {
|
||
const file = join(TMP, name)
|
||
writeFileSync(file, html)
|
||
return file
|
||
}
|
||
|
||
/** 断网打开一份本地文件:除了 file:// 本身,任何请求都拦掉并记下来 */
|
||
async function openFile(file, { colorScheme = 'light', width = 1280, javaScript = true } = {}) {
|
||
const ctx = await browser.newContext({ viewport: { width, height: 900 }, colorScheme, javaScriptEnabled: javaScript })
|
||
const requests = []
|
||
await ctx.route('**/*', (route) => {
|
||
const url = route.request().url()
|
||
if (url.startsWith('file:')) return route.continue()
|
||
requests.push(url)
|
||
return route.abort('blockedbyclient')
|
||
})
|
||
const page = await ctx.newPage()
|
||
const errors = []
|
||
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`))
|
||
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()) })
|
||
await page.goto(pathToFileURL(file).href)
|
||
return { ctx, page, requests, errors }
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 找一份可导出的运行
|
||
|
||
async function findRun() {
|
||
let seen = 0
|
||
let withDoc = 0
|
||
let fallback = null
|
||
let before = ''
|
||
for (let p = 0; p < 5; p++) {
|
||
let runs
|
||
try {
|
||
runs = await getJson(`/runs?status=succeeded&limit=200${before ? `&before=${encodeURIComponent(before)}` : ''}`)
|
||
} catch (e) {
|
||
return { why: `无法读取运行列表:${e.message}` }
|
||
}
|
||
for (const run of runs) {
|
||
seen++
|
||
const graph = await getJson(`/runs/${run.id}/evidence`).catch(() => null)
|
||
const reports = (graph?.mode === 'cited' ? graph.reports ?? [] : []).filter((r) => r.doc_artifact && r.hash_ok === true)
|
||
if (!reports.length) continue
|
||
withDoc++
|
||
const cited = reports.reduce((n, r) => n + (r.stats?.numbers_cited ?? 0), 0)
|
||
if (cited > 0) return { run, cited, reports: reports.length }
|
||
fallback ??= { run, cited, reports: reports.length }
|
||
}
|
||
if (runs.length < 200) break
|
||
before = runs[runs.length - 1].created_at
|
||
}
|
||
if (fallback) return fallback
|
||
if (!seen) return { why: '服务端里没有成功结束的运行' }
|
||
return { why: `${seen} 次成功结束的运行里 ${withDoc} 次带报告文档,没有一次有带出处的数字` }
|
||
}
|
||
|
||
let picked = null
|
||
let html = ''
|
||
|
||
// ---------------------------------------------------------------- 导出
|
||
|
||
await section('导出', async () => {
|
||
picked = await findRun()
|
||
if (!picked.run) {
|
||
check('找到一份成功结束、带报告文档的运行', false, picked.why)
|
||
return
|
||
}
|
||
const { run } = picked
|
||
check('找到一份成功结束、带报告文档的运行', true,
|
||
`运行 ${run.id.slice(0, 8)},${picked.reports} 份报告、${picked.cited} 个带出处的数字`)
|
||
const before = await getJson(`/runs/${run.id}/events`).catch(() => null)
|
||
const resp = await fetch(`${API}/runs/${run.id}/report.html`)
|
||
const body = await resp.text()
|
||
const ok = resp.status === 200 && /^text\/html/.test(resp.headers.get('content-type') ?? '')
|
||
check('导出接口返回 200 和 text/html', ok, `HTTP ${resp.status},${Math.round(Buffer.byteLength(body) / 1024)} KB`)
|
||
html = ok && DATA_RE.test(body) ? body : ''
|
||
const disposition = resp.headers.get('content-disposition') ?? ''
|
||
const star = /filename\*=UTF-8''([^;]+)/.exec(disposition)?.[1]
|
||
let name = ''
|
||
try { name = star ? decodeURIComponent(star) : '' } catch { name = '' }
|
||
check('附件名按 RFC 5987 编码,以「-报告.html」结尾', disposition.startsWith('attachment') && name.endsWith('-报告.html'))
|
||
check('另带一个纯 ASCII 的文件名给老客户端', /filename="[\x20-\x7e]+"/.test(disposition))
|
||
check('导出不缓存(no-store)', resp.headers.get('cache-control') === 'no-store')
|
||
// 页面本身(去掉数据块)不许有任何 http(s):// 引用;数据块里的字是数据,浏览器不会去取
|
||
const shell = html.replace(DATA_RE, '$1$3')
|
||
check('页面零外链:没有 http(s)://、外部脚本、样式表、图片和字体', !/https?:\/\//i.test(shell)
|
||
&& !/<script[^>]*\ssrc\s*=|<link\b|<img\b|@import|url\s*\(/i.test(shell))
|
||
check('内容安全策略默认不许连任何地方', /default-src 'none'/.test(shell))
|
||
check('数据块里没有裸的 < > &(截不断)', !/[<>&]/.test(DATA_RE.exec(html)?.[2] ?? '<'))
|
||
const after = await getJson(`/runs/${run.id}/events`).catch(() => null)
|
||
check('导出不追加事件', !!before && !!after && before.length === after.length, before && after ? `${before.length} → ${after.length} 条` : '')
|
||
const missing = await fetch(`${API}/runs/run-does-not-exist/report.html`)
|
||
check('不存在的运行回 404', missing.status === 404)
|
||
if (picked.cited === 0) note('这份运行的报告没有带出处的数字(模型没写引用标记),出处面板按无证据数字查')
|
||
})
|
||
|
||
const skipped = () => {
|
||
if (html) return false
|
||
note('没有拿到导出的页面(原因见上一节),这一节跳过')
|
||
return true
|
||
}
|
||
|
||
/** 点开正文里一个数字,等出处面板出来,交回面板里的全部文字(看完按 Esc 关掉) */
|
||
async function detailText(page, locator) {
|
||
await locator.click()
|
||
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
|
||
const text = await page.locator('#detail').innerText()
|
||
await page.keyboard.press('Escape')
|
||
return text
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 断网打开
|
||
|
||
await section('断网打开', async () => {
|
||
if (skipped()) return
|
||
const data = dataOf(html)
|
||
const { ctx, page, requests, errors } = await openFile(writePage('report.html', html))
|
||
try {
|
||
check('没有发出任何网络请求', requests.length === 0, requests.slice(0, 3).join('、'))
|
||
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||
// 头部:报告节点、运行、封存状态
|
||
const head = await page.locator('header').innerText()
|
||
check('头部写明报告节点和运行 ID', /报告节点「/.test(head) && head.includes(data.meta.run_id.slice(0, 8)), head.slice(0, 120))
|
||
check('封存状态写明(已封存给清单哈希)', /封存/.test(head) && (data.meta.seal?.sealed ? /清单哈希/.test(head) : true))
|
||
// 正文:服务端渲染的 HTML
|
||
const docText = await page.locator('#report').innerText()
|
||
check('正文整份读得到(服务端渲染)', docText.trim().length > 0, `${docText.trim().length} 字`)
|
||
const buttons = page.locator('#report button[data-seg]')
|
||
const count = await buttons.count()
|
||
check('有状态的数字、取值、名称都画成了按钮', count > 0, `${count} 个`)
|
||
const refs = await page.evaluate(() => Object.keys(JSON.parse(document.getElementById('report-data').textContent).refs ?? {}))
|
||
check('每个按钮都点得开一条出处链', count === refs.length || refs.length >= count, `按钮 ${count} 个、链 ${refs.length} 条`)
|
||
// 点开一个带出处的数字
|
||
const cited = page.locator('#report button[data-seg][data-state="deterministic"]').first()
|
||
if (await cited.count()) {
|
||
const key = await cited.getAttribute('data-seg')
|
||
const text = await detailText(page, cited)
|
||
const chains = Object.fromEntries(Object.entries(data.chains ?? {}))
|
||
const chain = chains[data.refs[key] ?? ''] ?? []
|
||
const kinds = chain.map((s) => s.step)
|
||
// 每种步骤在面板里的标题(runtime.js 的 PARTS):链里有的,面板都得画出一节
|
||
const TITLES = { metric: '口径卡指标', input: '输入', query: '查询', run_input: '运行输入',
|
||
entity: '表或字段', quote: '引文' }
|
||
check('点开数字面板写明类型和状态', /「/.test(text) && /(有出处|无证据|未裁判|猜测)/.test(text))
|
||
check('面板列出出处链的每个步骤', kinds.length > 0
|
||
? kinds.every((k) => text.includes(TITLES[k] ?? k)) : /没有可展示的出处链/.test(text),
|
||
`${kinds.join('、') || '(空链)'}|${text.slice(0, 160).replace(/\n/g, ' ')}`)
|
||
if (kinds.includes('metric')) {
|
||
check('指标步骤带口径、计算方式(原式、代入式)、输入和复算',
|
||
/口径卡「/.test(text) && /原式(计算方式)/.test(text) && /代入式/.test(text)
|
||
&& (/输入/.test(text) || /缺少输入/.test(text)) && /复算/.test(text), text.slice(0, 300))
|
||
// 有出处(确定性复算一致)和追到原始数据分开说:链里带了 untraced 的,面板写明追没追到、追到哪个节点为止
|
||
const untraced = chain.find((s) => s.step === 'metric')?.untraced
|
||
if (Array.isArray(untraced)) {
|
||
check('指标步骤写明输入追没追到原始数据', /原始数据/.test(text)
|
||
&& untraced.every((u) => text.includes(String(u.path))), text.slice(0, 300))
|
||
}
|
||
}
|
||
if (kinds.includes('query')) {
|
||
check('查询步骤带 SQL 和被引用的行', /SQL/.test(text) && /被引用的行/.test(text), text.slice(0, 300))
|
||
const masked = chain.filter((s) => s.step === 'query' && Array.isArray(s.masked) && s.masked.length)
|
||
if (masked.length) check('遮罩列的值写「已遮罩」', /已遮罩/.test(text))
|
||
}
|
||
// 面板开着再点另一个:换到那一个,Esc 关掉
|
||
const another = page.locator('#report button[data-seg][data-state="deterministic"]').nth(1)
|
||
if (await another.count()) {
|
||
await another.click()
|
||
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
|
||
check('面板开着时点另一个数字,换到那一个', await page.locator('#detail').getAttribute('data-open') === await another.getAttribute('data-seg'))
|
||
}
|
||
await page.keyboard.press('Escape')
|
||
check('Esc 关掉出处面板', await page.locator('#detail').isHidden())
|
||
} else {
|
||
check('报告里有可点的数字', picked?.cited === 0, '证据图说有带出处的数字,页面上却一个都没有')
|
||
}
|
||
} finally {
|
||
await ctx.close()
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 口径一步(口径账本,next-round 02 做法 3)
|
||
|
||
await section('口径一步', async () => {
|
||
if (skipped()) return
|
||
const data = dataOf(html)
|
||
// 没比对过的运行:数据块里没有 ledger,面板里也没有「口径」一步
|
||
if (!data.ledger) {
|
||
const plain = await openFile(writePage('report-plain.html', html))
|
||
try {
|
||
const cited = plain.page.locator('#report button[data-seg][data-state="deterministic"]').first()
|
||
if (await cited.count()) {
|
||
await cited.click()
|
||
await plain.page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
|
||
check('没比对过的运行:面板里没有「口径」一步', await plain.page.locator('#detail [data-step="ledger"]').count() === 0)
|
||
}
|
||
} finally {
|
||
await plain.ctx.close()
|
||
}
|
||
}
|
||
// 服务端比对过的运行面板里是什么样:数据块里放一份口径账本给的标签(来自口径、相关口径各一个),断网打开点开看。
|
||
// 只改数据块(脚本的哈希不变,内容安全策略照样放行)
|
||
const keys = Object.keys(data.refs ?? {})
|
||
if (!keys.length) return note('报告里没有可点的片段,口径一步不查')
|
||
const labels = {
|
||
[keys[0]]: { kind: 'caliber', how: 'independent', caliber: 'wf-x@3', workflow_id: 'wf-x', version: 3, level: 'governed',
|
||
workflow_name: '周销售', level_text: '受管', column: 'gmv',
|
||
binding: [{ field: 'start', value: '2026-09-01' }, { field: 'phone', masked: true, value: '已遮罩' }] },
|
||
...(keys[1] ? { [keys[1]]: { kind: 'related', related: [{ caliber: 'wf-x@3', workflow_id: 'wf-x', version: 3,
|
||
workflow_name: '周销售', level_text: '受管', diff: { items: [{ aspect: 'filter', text: '口径只保留已支付的行,这次没有这个条件' }] } }] } } : {}),
|
||
}
|
||
const ledger = { labels, note: '封存后比对,不在封存范围内', retro: true, retro_note: '按现在的口径回看' }
|
||
const patched = html.replace(DATA_RE, (_, a, body, c) => a + JSON.stringify({ ...JSON.parse(body), ledger })
|
||
.replace(/</g, '\\u003c') + c)
|
||
const { ctx, page, errors } = await openFile(writePage('report-ledger.html', patched))
|
||
try {
|
||
const first = page.locator(`#report button[data-seg="${keys[0]}"]`).first()
|
||
if (!(await first.count())) return note('数据块里的片段在正文里找不到按钮,口径一步不查')
|
||
// 只打印「口径」这一节(数据块里放的是编的标签):面板别处是真数据,不打印
|
||
const ledgerText = async (seg) => {
|
||
await seg.click()
|
||
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
|
||
const part = page.locator('#detail [data-step="ledger"]')
|
||
const text = (await part.count()) ? await part.innerText() : ''
|
||
const order = await page.evaluate(() => [...document.querySelectorAll('#detail-body > section')].map((s) => s.dataset.step ?? ''))
|
||
await page.keyboard.press('Escape')
|
||
return { text, order }
|
||
}
|
||
const { text, order } = await ledgerText(first)
|
||
check('面板最上面多「口径」一步:来自口径「周销售」v3(受管)、等价已证明', /口径/.test(text)
|
||
&& text.includes('来自口径「周销售」v3(受管)') && text.includes('等价已证明'), text.replace(/\n/g, ' '))
|
||
check('参数照给,遮罩的写「已遮罩」', text.includes('start = 「2026-09-01」') && text.includes('phone = 已遮罩'))
|
||
check('写明封存后比对、不在封存范围内,事后补比的写按现在的口径回看',
|
||
text.includes('封存后比对,不在封存范围内') && text.includes('按现在的口径回看'))
|
||
check('「口径」一步排在出处链前面', order[1] === 'ledger', order.slice(0, 2).join(','))
|
||
if (keys[1]) {
|
||
const second = page.locator(`#report button[data-seg="${keys[1]}"]`).first()
|
||
if (await second.count()) {
|
||
const other = (await ledgerText(second)).text
|
||
check('相关口径:写明是哪一份、差在哪', other.includes('相关口径') && other.includes('口径只保留已支付的行'),
|
||
other.replace(/\n/g, ' '))
|
||
}
|
||
}
|
||
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||
} finally {
|
||
await ctx.close()
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 不执行脚本
|
||
|
||
await section('不执行脚本', async () => {
|
||
if (skipped()) return
|
||
const data = dataOf(html)
|
||
const { ctx, page } = await openFile(join(TMP, 'report.html'), { javaScript: false })
|
||
try {
|
||
const docText = await page.locator('#report').innerText()
|
||
check('正文照样整份读得到(附件预览常常不执行脚本)', docText.trim().length > 0, `${docText.trim().length} 字`)
|
||
const head = await page.locator('header').innerText()
|
||
check('运行 ID 和封存哈希静态写着', head.includes(data.meta.run_id.slice(0, 8))
|
||
&& (!data.meta.seal?.manifest_hash || head.includes(data.meta.seal.manifest_hash.slice(0, 12))), head.slice(0, 160))
|
||
} finally {
|
||
await ctx.close()
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 深浅色与窄屏
|
||
|
||
await section('深浅色与窄屏', async () => {
|
||
if (skipped()) return
|
||
const dark = await openFile(join(TMP, 'report.html'), { colorScheme: 'dark' })
|
||
try {
|
||
const deep = await dark.page.evaluate(() => getComputedStyle(document.body).backgroundColor)
|
||
const [r, g, b] = (/\((\d+), (\d+), (\d+)/.exec(deep) ?? []).slice(1).map(Number)
|
||
check('深色下底是深的、字是浅的', r + g + b < 200, deep)
|
||
} finally {
|
||
await dark.ctx.close()
|
||
}
|
||
const narrow = await openFile(join(TMP, 'report.html'), { width: 390 })
|
||
try {
|
||
check('390 宽不横向溢出', await narrow.page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
|
||
const first = narrow.page.locator('#report button[data-seg]').first()
|
||
if (await first.count()) {
|
||
await first.click()
|
||
await narrow.page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
|
||
const fromBottom = await narrow.page.evaluate(() => {
|
||
const r = document.getElementById('detail').getBoundingClientRect()
|
||
return r.bottom >= window.innerHeight - 2 || r.top > window.innerHeight * 0.2
|
||
})
|
||
check('窄屏上面板从底部抽出', fromBottom)
|
||
await narrow.page.keyboard.press('Escape')
|
||
} else {
|
||
note('正文里没有可点的片段,窄屏面板没查')
|
||
}
|
||
} finally {
|
||
await narrow.ctx.close()
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 问数据成果上的导出按钮
|
||
|
||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||
const SAMPLE_NAME = '门店周报-报告.html'
|
||
|
||
/** 预览页 ?syn=evidence(带逐段证据的一轮):报告文档和导出接口由 page.route 伪造,非 GET 一律掐掉 */
|
||
async function preview(syn, { reportHtml } = {}) {
|
||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, acceptDownloads: true })
|
||
const page = await ctx.newPage()
|
||
const hits = []
|
||
// 后注册的路由先匹配:兜底的先注册,假装报告文档和导出接口的后注册,请求才落到假接口上
|
||
await page.route('**/api/**', (route) => (route.request().method() === 'GET' ? route.continue() : route.abort()))
|
||
const fx = JSON.parse(readFileSync(join(ROOT, 'frontend/src/run/__tests__/evidence-doc.json'), 'utf8'))
|
||
// 报告文档和导出接口的后注册才落到假接口上。导出接口带查询串(?report=…),模式末尾的 * 把它吞进匹配
|
||
await page.route(`**/api/artifacts/${fx.doc_artifact}`, (route) => route.fulfill({
|
||
contentType: 'application/json', body: JSON.stringify({ id: fx.doc_artifact, content: fx.doc }),
|
||
}))
|
||
await page.route('**/api/runs/*/report.html*', (route) => {
|
||
const url = new URL(route.request().url())
|
||
hits.push(url.pathname + url.search)
|
||
return route.fulfill(reportHtml ?? {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/html; charset=utf-8',
|
||
'content-disposition': `attachment; filename="agentlab-runevid-report.html"; filename*=UTF-8''${encodeURIComponent(SAMPLE_NAME)}` },
|
||
body: '<!doctype html><title>report</title>',
|
||
})
|
||
})
|
||
const errors = []
|
||
page.on('pageerror', (e) => errors.push(e.message))
|
||
await page.goto(`${WEB}/preview.html?syn=${syn}`, { waitUntil: 'networkidle' })
|
||
await page.waitForSelector('[data-turn]', { timeout: 15_000 })
|
||
return { ctx, page, hits, errors, fx }
|
||
}
|
||
|
||
await section('问数据成果上的导出按钮', async () => {
|
||
if (!WEB) {
|
||
note('没有前端地址(AGENTLAB_WEB),这一节跳过')
|
||
return
|
||
}
|
||
{
|
||
const { ctx, page, hits, errors, fx } = await preview('evidence')
|
||
try {
|
||
const html = page.locator('[data-turn="evidence"] [data-answer-export="html"]')
|
||
check('带证据的成果:导出按钮在,悬停写明数字点得开出处', await html.count() === 1
|
||
&& (await html.getAttribute('title')).includes('口径、来源和计算方式'))
|
||
// 按钮浮在答案右上角、悬停才显形:先挪上去再点,点击本身才落在按钮上
|
||
await page.locator('[data-turn="evidence"]').hover()
|
||
await html.hover()
|
||
const [download] = await Promise.all([page.waitForEvent('download', { timeout: 10_000 }), html.click()])
|
||
check('下载的文件名同服务端(RFC 5987 的中文名)', download.suggestedFilename() === SAMPLE_NAME, download.suggestedFilename())
|
||
check('请求的是这次运行、点名的报告节点的导出接口', hits.length === 1
|
||
&& hits[0] === `/api/runs/${fx.run_id}/report.html?report=write`, hits.join('、'))
|
||
check('导出后提示文件名', await page.getByText(`已导出「${SAMPLE_NAME}」`).first().isVisible())
|
||
check('页面没有报错', !errors.length, errors.slice(0, 2).join(' | '))
|
||
const md = page.locator('[data-turn="evidence"] [data-answer-export="md"]')
|
||
check('Markdown 照旧留着一个小的', await md.count() === 1 && (await md.innerText()).trim() === 'MD')
|
||
} finally {
|
||
await ctx.close()
|
||
}
|
||
}
|
||
{
|
||
// 没有证据的旧答案:只有 Markdown 导出,没有 HTML 按钮
|
||
const { ctx, page, hits } = await preview('evidence')
|
||
try {
|
||
const legacy = page.locator('[data-turn="evidence-legacy"]')
|
||
check('旧运行(没有 _evidence):没有 HTML 导出按钮', await legacy.locator('[data-answer-export="html"]').count() === 0)
|
||
const md = legacy.locator('button[title="导出为 Markdown 文件"]')
|
||
check('旧运行:照旧只有 Markdown 导出', await md.count() === 1)
|
||
await legacy.hover()
|
||
const [download] = await Promise.all([page.waitForEvent('download', { timeout: 10_000 }), md.click()])
|
||
check('旧运行:下载的是 .md 文件', download.suggestedFilename().endsWith('.md'), download.suggestedFilename())
|
||
check('旧运行:不请求导出接口', hits.length === 0, hits.join('、'))
|
||
} finally {
|
||
await ctx.close()
|
||
}
|
||
}
|
||
{
|
||
// 服务端回 409(文档被改过):原样提示原因,按钮恢复可点
|
||
const refusal = { status: 409, contentType: 'application/json',
|
||
body: JSON.stringify({ detail: '报告文档与哈希不一致,疑似被修改,不能导出', code: 'evidence_doc_tampered' }) }
|
||
const { ctx, page } = await preview('evidence', { reportHtml: refusal })
|
||
try {
|
||
await page.locator('[data-turn="evidence"]').hover()
|
||
await page.locator('[data-turn="evidence"] [data-answer-export="html"]').click()
|
||
check('服务端回 409 时原样提示原因',
|
||
await appears(page.getByText('报告文档与哈希不一致,疑似被修改,不能导出')))
|
||
check('提示之后按钮恢复可点',
|
||
await appears(page.locator('[data-turn="evidence"] [data-answer-export="html"]:enabled'), 8000))
|
||
} finally {
|
||
await ctx.close()
|
||
}
|
||
}
|
||
})
|
||
|
||
await cleanup()
|
||
console.log(failed ? `\n${failed} 项不通过` : '\n全部通过')
|
||
process.exit(failed ? 1 : 0)
|