mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:41:58 +00:00
合入报告导出为自包含 HTML(数字点得开口径、来源、计算方式)与可验证报告数字详情里的口径卡一节;和可验证报告的遮罩改造合并:口径卡只给 id 的规则(有指标的值取自遮罩列、文档里写成「已遮罩」的)照旧,随文件附带的卡才附上它的输入指向的查询快照;数字详情保留显示文字核对和超过 2^53 的数照 JSON 原文显示,口径卡一节里输入的值同样照原文;样本页从 16 份变成 17 份
This commit is contained in:
@@ -98,7 +98,7 @@ const ESTIMATE = {
|
||||
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
|
||||
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
// 占位,换成真检查时按实测改
|
||||
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33,
|
||||
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30,
|
||||
}
|
||||
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
|
||||
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
|
||||
|
||||
@@ -595,6 +595,15 @@ async function sample(name, fn, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 点开正文里一个数字,等出处面板出来,交回面板里的全部文字(看完按 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 () => {
|
||||
try {
|
||||
samples = samplePages()
|
||||
@@ -602,7 +611,7 @@ await section('样本页:各种形状', async () => {
|
||||
check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e))
|
||||
return
|
||||
}
|
||||
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 16, Object.keys(samples).join('、'))
|
||||
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 17, Object.keys(samples).join('、'))
|
||||
|
||||
await sample('base', async (s, page) => {
|
||||
check('base:有无法核对的项,总览是中性的「可核对的项全部通过,另有 N 项无法核对」,不是绿色的通过', neutral(s),
|
||||
@@ -653,6 +662,18 @@ await section('样本页:各种形状', async () => {
|
||||
check('base:没有出处的数字不注明', !/按显示规则/.test(await detailOf(bare)))
|
||||
check('base:读屏把文字取值读作「取值」、数字读作「数字」', (await value.getAttribute('aria-label')).startsWith('取值 ')
|
||||
&& (await cellNumber.getAttribute('aria-label')).startsWith('数字 '))
|
||||
// 点开引用口径卡指标的数字:口径、计算方式(原式、代入式)、输入、复算,和输入指向的查询快照
|
||||
const metric = page.locator('.doc p button.num[data-kind="number"][data-result="pass"]').first()
|
||||
const mdetail = await detailOf(metric)
|
||||
check('base:指标数字的出处面板写明指标、口径与版本、原式、代入式、输入、复算',
|
||||
mdetail.includes('销售额(gmv)') && mdetail.includes('周报口径 v2')
|
||||
&& mdetail.includes('原式(计算方式)') && mdetail.includes('代入式(代入输入后的算式)')
|
||||
&& mdetail.includes('输入') && mdetail.includes('复算一致'), mdetail.slice(0, 400))
|
||||
check('base:每个输入带着取值和出处(哪次查询的哪一行哪一列)',
|
||||
mdetail.includes("← cell(nodes.fetch, 0, 'amount')") && mdetail.includes('取自 查询 Q1 · 第 1 行 · amount'),
|
||||
mdetail.slice(0, 400))
|
||||
check('base:输入指向的查询快照随文件附带,看得到 SQL 和被引用的那一行',
|
||||
mdetail.includes('SELECT store, amount, ratio FROM orders') && mdetail.includes('快照第 1 行'), mdetail.slice(0, 400))
|
||||
})
|
||||
|
||||
// 宽屏:出处面板停在右侧,正文让出这一栏。点开表格里右对齐的数字,它和同列的数字都不被面板盖住
|
||||
@@ -682,6 +703,22 @@ await section('样本页:各种形状', async () => {
|
||||
&& s.items.filter((i) => i.check === 'chain' && i.text.includes('的文档')).length === 2, JSON.stringify(s.reports.map((r) => r.node)))
|
||||
check('two-reports:月报的数字全部通过', s.reports.find((r) => r.node === 'monthly')?.result === 'pass')
|
||||
})
|
||||
// 只引用指标的报告:正文没引用门店流水,快照从口径卡的输入这一条路随文件附带,④ 认得这条引用
|
||||
await sample('metric-only', async (s, page) => {
|
||||
check('metric-only:总览通过', s.overall === 'pass', `${s.overall}:${s.text}`)
|
||||
check('metric-only:引用链写明从已核对的口径卡的输入认这份快照',
|
||||
s.checks.chain === 'pass' && hasItem(s, 'chain', 'pass', '口径卡「周报口径」的输入(指标 gmv)'), describe(s))
|
||||
check('metric-only:只嵌报告文档、口径卡和口径卡的输入指向的快照(会员消费谁都没引用,不嵌)',
|
||||
s.items.filter((i) => i.check === 'artifacts').length === 3
|
||||
&& !s.items.some((i) => i.check === 'artifacts' && i.text.includes('members')),
|
||||
String(s.items.filter((i) => i.check === 'artifacts').length))
|
||||
const detail = await detailText(page, page.locator('button.num[data-kind="number"]').first())
|
||||
check('metric-only:点开数字看得到指标、口径与版本、原式、代入式、输入和复算',
|
||||
detail.includes('销售额(gmv)') && detail.includes('周报口径 v2') && detail.includes('原式(计算方式)')
|
||||
&& detail.includes('代入式(代入输入后的算式)') && detail.includes('复算一致'), detail.slice(0, 400))
|
||||
check('metric-only:输入指向的查询快照看得到 SQL 和被引用的那一行',
|
||||
detail.includes('SELECT store, amount, ratio FROM orders') && detail.includes('快照第 1 行'), detail.slice(0, 400))
|
||||
})
|
||||
await sample('legacy', async (s, page) => {
|
||||
check('legacy:总览中性(运行结果无法核对),范围写明按结束事件推算', neutral(s)
|
||||
&& (await page.locator('#meta').innerText()).includes('早期运行,范围按结束事件推算'), s.text)
|
||||
@@ -902,6 +939,13 @@ await section('样本页:篡改', async () => {
|
||||
data.artifacts = data.artifacts.filter((a) => a.kind !== 'metric_set')
|
||||
return '删掉口径卡条目'
|
||||
}, (s) => [...turnsRed('numbers')(s), ['原因写明', hasItem(s, 'numbers', 'fail', '引用的工件不在文件的工件清单里')]])
|
||||
// 只引用指标的报告:拿掉口径卡,数字没了核对的对象(⑤ 变红);它的输入指向的快照还被已核对的
|
||||
// tool.end 事件引用着,④ 照样通过——事件本身就是一条合法的引用
|
||||
await tamper(samples['metric-only'], '拿掉被引用的口径卡(只引用指标的报告)', (data) => {
|
||||
data.artifacts = data.artifacts.filter((a) => a.kind !== 'metric_set')
|
||||
return '删掉口径卡条目'
|
||||
}, (s) => [...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain'])(s),
|
||||
['数字无法核对,写明引用的工件不在清单里', hasItem(s, 'numbers', 'fail', '引用的工件不在文件的工件清单里')]])
|
||||
// 证据目录里写着取自遮罩列的指标的值的老文档,服务端只给哈希、不附带它引用的工件(见样本页 masked-old-doc):伪造者把
|
||||
// 一份好好的文档也标成遮罩,页面照服务端的说法写原因、正文不显示,可它引用的那几份工件还列着,已核对的事件和文档里却
|
||||
// 没人引用它们,判不通过——藏不起一份报告还显示通过
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// 报告导出的检查:真服务端导出、断网打开点数字、不执行脚本也读得到正文;外加记录页对话框里那份导出按钮。
|
||||
//
|
||||
// 跑之前前后端都得起着(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))
|
||||
}
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 不执行脚本
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user