mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 09:31:59 +00:00
03c18c4d78
一、文案(前端约 1600 处,后端传到界面的约 880 处) - 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」; 总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别 - 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法 - 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」); 报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供; 发布弹窗里结论句的选项不再是 off / require_citation - 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份 - 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例 二、说错的提示(都有测试证明) - 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀 - 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页 - 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填 - 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因 - 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头 - 导出的证据 CSV 里两列同名,第三列改为「片段编号」 三、前后端对齐 - 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对 - 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上 四、检查 - 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证 - 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、 界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
741 lines
38 KiB
JavaScript
741 lines
38 KiB
JavaScript
// 各页面的端到端检查:真浏览器、真后端、真数据。
|
||
//
|
||
// check-decode 守翻译,check-stream 守渲染,这一层守的是"接起来还对不对"——
|
||
// 前两层都能通过而这一层坏掉:store 没把事件存进去、tab 切换把状态卸载了、
|
||
// 唯一的运行入口被改版顺手删了、组件类把工具类盖掉了。
|
||
//
|
||
// 跑之前前后端都得起着(./scripts/dev.sh)。默认连 5273 / 8000;对别的实例(比如一份
|
||
// 沙箱拷贝)跑时带上地址:
|
||
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-ui.mjs
|
||
// 只跑其中几节:CHECK_ONLY=运行历史,样式层叠(按节名包含匹配)。SHOT_DIR=<目录> 时各节存一张截图。
|
||
//
|
||
// 读的是真数据,写一律拦在浏览器里:非 GET 的接口请求只放行纯计算的校验和变量分析,
|
||
// 其余的要么由那一节自己伪造回应,要么直接掐掉。所以这份检查对哪个库跑都不留痕迹。
|
||
import { readFileSync } from 'node:fs'
|
||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||
|
||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
|
||
const CHROME = process.env.CHROME_PATH
|
||
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||
const SHOTS = process.env.SHOT_DIR ?? ''
|
||
const ONLY = process.env.CHECK_ONLY?.split(',').map((x) => x.trim()).filter(Boolean)
|
||
|
||
// 仓库是公开的,这份输出会被贴进报告:跑的是真数据,对话标题、工作流名、节点名、
|
||
// 文档标题、数据源名一律换成占位符再打印。本脚本源码里自己写着的串(夹具)本来就公开,不换
|
||
const ownSource = readFileSync(new URL(import.meta.url), 'utf8')
|
||
const listOf = async (path) => {
|
||
try { const r = await fetch(API + path); return r.ok ? await r.json() : [] } catch { return [] }
|
||
}
|
||
const secrets = []
|
||
for (const c of await listOf('/conversations?kind=chat&include_archived=true')) secrets.push([c.title, '‹对话标题›'])
|
||
for (const w of await listOf('/workflows')) {
|
||
secrets.push([w.name, '‹工作流名›'])
|
||
for (const n of w.graph?.nodes ?? []) secrets.push([n.data?.label ?? n.label, '‹节点名›'])
|
||
}
|
||
for (const d of await listOf('/kb/documents')) secrets.push([d.title, '‹文档标题›'])
|
||
for (const d of await listOf('/datasources')) secrets.push([d.name, '‹数据源名›'], [d.description, '‹数据源描述›'])
|
||
const maskList = [...new Map(secrets
|
||
.filter(([v]) => typeof v === 'string' && v.trim().length >= 2 && !ownSource.includes(v))).entries()]
|
||
.sort((a, b) => b[0].length - a[0].length)
|
||
const masked = (t) => maskList.reduce((out, [v, tag]) => out.split(v).join(tag), String(t))
|
||
|
||
let failed = 0
|
||
const check = (name, cond, detail = '') => {
|
||
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${masked(detail)}` : ''}`)
|
||
if (!cond) failed++
|
||
}
|
||
|
||
const browser = await chromium.launch({ executablePath: CHROME })
|
||
const ctx = await browser.newContext({ viewport: { width: 1500, height: 940 } })
|
||
|
||
// 写操作的闸门。各节自己用 page.route 伪造的接口优先于这里(页面级路由先匹配),
|
||
// 漏到这里的非 GET 请求记下来再掐掉:逛页面本身会触发哪些写,一眼看得见
|
||
const COMPUTE_ONLY = [/\/api\/workflows\/(validate|variables)$/]
|
||
const blocked = []
|
||
const blockedUrls = new Set()
|
||
await ctx.route('**/api/**', (route) => {
|
||
const r = route.request()
|
||
const path = new URL(r.url()).pathname
|
||
if (['GET', 'HEAD'].includes(r.method()) || COMPUTE_ONLY.some((re) => re.test(path))) return route.continue()
|
||
blocked.push(`${r.method()} ${path}`)
|
||
blockedUrls.add(r.url())
|
||
return route.abort('blockedbyclient')
|
||
})
|
||
|
||
/**
|
||
* 一节一节地跑:某一节里元素找不到、等待超时,只记成这一节失败,关掉它开的页面,
|
||
* 接着跑下一节——不让一处卡住把后面几十项一起吞掉
|
||
*/
|
||
const opened = new Set()
|
||
async function section(name, fn) {
|
||
if (ONLY?.length && !ONLY.some((k) => name.includes(k))) return
|
||
console.log(`\n=== ${name} ===`)
|
||
const mark = blocked.length
|
||
try {
|
||
await fn()
|
||
} catch (e) {
|
||
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||
} finally {
|
||
for (const p of opened) await p.close().catch(() => {})
|
||
opened.clear()
|
||
}
|
||
// 页面自己发出、被闸门掐掉的写请求:逛页面就想写库的地方都在这里,列出来备查,不算失败
|
||
const writes = [...new Set(blocked.slice(mark).map((w) => w.replace(/[0-9a-f]{32}/g, '…')))]
|
||
if (writes.length) console.log(` · 拦下的写请求:${writes.join('、')}`)
|
||
}
|
||
|
||
/** 开一页并收集真实报错。favicon 的 404 不算,被上面的闸门掐掉的写请求也不算 */
|
||
async function newPage() {
|
||
const page = await ctx.newPage()
|
||
opened.add(page)
|
||
const errors = []
|
||
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
|
||
page.on('console', (m) => {
|
||
if (m.type() !== 'error' || m.text().includes('404')) return
|
||
if (blockedUrls.has(m.location()?.url)) return
|
||
errors.push(m.text())
|
||
})
|
||
return { page, errors }
|
||
}
|
||
async function visit(path) {
|
||
const { page, errors } = await newPage()
|
||
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(700)
|
||
return { page, errors }
|
||
}
|
||
const shot = async (page, name) => {
|
||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png` })
|
||
}
|
||
const overflowOf = (page) => page.evaluate(() =>
|
||
document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||
/** 等一句提示出现(toast 约 4 秒就走,固定等一段再数会错过),再留一小段看它有没有重复 */
|
||
async function countNotice(page, text, settle = 1200) {
|
||
const seen = await page.getByText(text).first().waitFor({ timeout: 8000 }).then(() => true, () => false)
|
||
if (!seen) return 0
|
||
await page.waitForTimeout(settle)
|
||
return ((await page.locator('body').innerText()).match(new RegExp(text, 'g')) ?? []).length
|
||
}
|
||
|
||
await section('画布助手栏', async () => {
|
||
const { page, errors } = await visit('/studio')
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
|
||
const body = await page.locator('body').innerText()
|
||
check('不再有两层 tab 嵌套',
|
||
!body.includes('时间线') && !body.includes('原始事件'))
|
||
|
||
// 用户的原话:"没有一个很好的地方引导用户写问题"。空态下输入区就该是主体
|
||
check('空态给出了明确的邀请', body.includes('需要助手做什么'))
|
||
check('说清楚它能够到什么', /\d+ 个工具/.test(body), body.match(/\d+ 个工具/)?.[0])
|
||
const examples = await page.locator('aside button').filter({ hasText: /。|,/ }).count()
|
||
check('例句是完整句子而不是截断的 chip', examples >= 2, `${examples} 条`)
|
||
|
||
const composer = page.locator('aside textarea').first()
|
||
check('输入框自动聚焦,不用先点一下',
|
||
await composer.evaluate((el) => el === document.activeElement))
|
||
|
||
// 改版最容易顺手删掉的东西:唯一的运行入口
|
||
check('发起运行的入口还在(已搬到工具栏)',
|
||
await page.getByRole('button', { name: /^运行/ }).count() > 0)
|
||
|
||
// 这是这次重构的核心承诺:属性是盖在助手上的一层,不是把它换掉。
|
||
// 切 tab 会卸载组件,草稿、滚动位置、展开状态全丢——那正是要修的问题
|
||
await composer.fill('测试草稿不要丢')
|
||
await page.locator('.react-flow__node').first().click()
|
||
await page.waitForTimeout(350)
|
||
const sheet = await page.locator('body').innerText()
|
||
check('选中节点滑出属性面板', sheet.includes('节点名称') || sheet.includes('ID:'))
|
||
check('面板上有明确的返回,让人知道底下还有东西',
|
||
await page.getByTitle('返回助手(Esc)').count() > 0)
|
||
|
||
await page.keyboard.press('Escape')
|
||
await page.waitForTimeout(350)
|
||
check('Esc 关得掉', await page.getByTitle('返回助手(Esc)').count() === 0)
|
||
check('回来之后输入的字还在', await composer.inputValue() === '测试草稿不要丢',
|
||
await composer.inputValue())
|
||
await composer.fill('')
|
||
|
||
const overflow = await overflowOf(page)
|
||
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
|
||
await shot(page, 'studio')
|
||
})
|
||
|
||
await section('变量表与模板补全', async () => {
|
||
const { page, errors } = await visit('/studio')
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
|
||
await page.getByRole('button', { name: /^变量/ }).click()
|
||
await page.waitForTimeout(700)
|
||
const rows = await page.locator('table tbody tr').count()
|
||
check('变量表列出了变量', rows > 0, `${rows} 行`)
|
||
const body = await page.locator('body').innerText()
|
||
check('列出的是可直接粘的写法', body.includes('{{ input.'), '')
|
||
|
||
// 抽屉是挤压式的:画布不能被压没,React Flow 容器高度归零会直接报错
|
||
const canvasH = await page.locator('.react-flow').first()
|
||
.evaluate((el) => el.getBoundingClientRect().height)
|
||
check('画布没有被压没', canvasH > 120, `${Math.round(canvasH)}px`)
|
||
await page.getByRole('button', { name: /^变量/ }).click()
|
||
await page.waitForTimeout(400)
|
||
|
||
// 补全:模板取不到值会静默渲染成空字符串,补全是从源头消灭这类错误。
|
||
// 默认那张图只有 输入→成果,没有带模板的字段,先从面板拖一个模型调用出来
|
||
await page.locator('text=模型调用').first().click()
|
||
await page.waitForTimeout(700)
|
||
// 必须限定在属性面板里:底下助手栏的 Copilot 输入框也是 aside textarea,
|
||
// 而它被这一层盖着,点它会一直超时
|
||
const field = page.locator('.sheet-in textarea').first()
|
||
await field.click()
|
||
await page.keyboard.type('{{')
|
||
await page.waitForTimeout(400)
|
||
const hasPanel = (await page.locator('body').innerText()).includes('⏎ 插入')
|
||
check('打 {{ 弹出候选', hasPanel)
|
||
if (hasPanel) {
|
||
await page.keyboard.press('Enter')
|
||
await page.waitForTimeout(350)
|
||
const v = await field.inputValue()
|
||
check('选中后插入成完整写法', /\{\{ \S+ \}\}/.test(v), v.slice(0, 40))
|
||
// 完全受控组件,插入后光标会被重置到末尾,必须自己放回去
|
||
await page.keyboard.type('X')
|
||
check('光标停在插入内容之后', (await field.inputValue()).endsWith('X'),
|
||
(await field.inputValue()).slice(-20))
|
||
}
|
||
await shot(page, 'variables')
|
||
})
|
||
|
||
await section('动效对前庭敏感者可关', async () => {
|
||
// 这套界面里动的东西不少(边在流动、节点在脉冲、面板在滑)。
|
||
// 系统里关了动效还照播,对前庭敏感的人是实打实的难受
|
||
const { page } = await newPage()
|
||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||
await page.goto(`${WEB}/studio`, { waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(500)
|
||
const durations = await page.evaluate(() => {
|
||
const probe = document.createElement('div')
|
||
probe.className = 'rise-in'
|
||
document.body.appendChild(probe)
|
||
const d = getComputedStyle(probe).animationDuration
|
||
probe.remove()
|
||
return d
|
||
})
|
||
check('关掉动效后动画确实停了', parseFloat(durations) < 0.01, durations)
|
||
})
|
||
|
||
await section('运行历史', async () => {
|
||
const { page, errors } = await visit('/runs')
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
|
||
// 行是按天分组的按钮,时间只写「11:36」「9/25 20:52」,所以按结构认,不按日期格式认
|
||
const rows = page.locator('[data-runs-list] [data-run-id]')
|
||
await rows.first().waitFor({ timeout: 10000 }).catch(() => {})
|
||
const n = await rows.count()
|
||
if (!n && !(await listOf('/runs?limit=1')).length) {
|
||
check('(跳过:库里还没有运行记录)', true)
|
||
return
|
||
}
|
||
check('列出了运行记录', n > 0, `${n} 条`)
|
||
if (!n) return
|
||
|
||
// 列表原本每行只有"临时图 · 1.2s · 340 tok",十条长得一模一样。现在第二行是这一条
|
||
// 自己的事:成功的写摘要,失败的写原因,等人的写等谁
|
||
const withContext = await rows.evaluateAll((els) =>
|
||
els.filter((el) => el.querySelectorAll(':scope > div').length >= 3).length)
|
||
check('行里有这一条自己的内容,不只是耗时和 token', withContext > 0, `${withContext}/${n} 行带第二行`)
|
||
|
||
const first = rows.first()
|
||
const id = await first.getAttribute('data-run-id')
|
||
await first.click()
|
||
await page.waitForURL(`**/runs/${id}**`, { timeout: 5000 }).catch(() => {})
|
||
check('点开一条,地址跟着换成它', new URL(page.url()).pathname === `/runs/${id}`, page.url())
|
||
check('列表里标着正在看的是哪一条', await page.locator(`[data-runs-list] [data-run-id="${id}"][aria-current="true"]`)
|
||
.waitFor({ timeout: 4000 }).then(() => true, () => false))
|
||
const pane = page.locator('[data-view-pane="stream"]')
|
||
await pane.waitFor({ timeout: 8000 }).catch(() => {})
|
||
await pane.locator('[data-turn]').first().waitFor({ timeout: 6000 }).catch(() => {})
|
||
await page.waitForTimeout(400)
|
||
const body = await pane.innerText().catch(() => '')
|
||
check('详情走的是可读视图,不是事件表',
|
||
!/\bnode\.(started|finished)\b/.test(body) && !/\brun\.started\b/.test(body))
|
||
// 按结构认,不按字数:最新一条可能只是个「hi」,正确的视图也就三十几个字。
|
||
// 有轮次、轮次头说得出状态、跑过节点的就列得出节点行
|
||
const view = await pane.evaluate((el) => ({
|
||
turns: el.querySelectorAll('[data-turn]').length,
|
||
status: [...el.querySelectorAll('[data-turn-status]')].map((s) => s.textContent.trim()),
|
||
nodes: el.querySelectorAll('[data-node-id]').length,
|
||
})).catch(() => ({ turns: 0, status: [], nodes: 0 }))
|
||
const ran = (await listOf(`/runs/${id}/events?after=0`)).some((e) => e.type === 'node.started')
|
||
check('详情有内容:轮次、状态、节点行都在',
|
||
view.turns > 0 && view.status.length > 0 && view.status.every(Boolean) && (!ran || view.nodes > 0),
|
||
`${view.turns} 轮 · 状态「${view.status.join('、')}」· ${view.nodes} 个节点行${ran ? '' : '(这条没跑到节点)'}`)
|
||
|
||
// 原始事件不是常驻 tab,但必须还能看到
|
||
const raw = page.locator('[data-action="raw"]')
|
||
await raw.click()
|
||
const rawShown = await page.locator('[data-raw-events]').waitFor({ timeout: 4000 }).then(() => true, () => false)
|
||
check('切得到原始事件', rawShown && /node\.(started|finished)|run\.started/.test(await pane.innerText()))
|
||
check('开关说得出现在是开着的', await raw.getAttribute('aria-pressed') === 'true')
|
||
await raw.click()
|
||
await page.waitForTimeout(300)
|
||
check('切得回来', await page.locator('[data-raw-events]').count() === 0
|
||
&& !/\bnode\.started\b/.test(await pane.innerText()))
|
||
await shot(page, 'runs')
|
||
})
|
||
|
||
await section('问数据', async () => {
|
||
const { page, errors } = await visit('/chat')
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
const body = await page.locator('body').innerText()
|
||
check('有空态或历史,不是白屏', body.length > 60, `${body.length} 字`)
|
||
check('有输入框', await page.locator('textarea').count() > 0)
|
||
const overflow = await overflowOf(page)
|
||
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
|
||
await shot(page, 'chat')
|
||
})
|
||
|
||
await section('向量模型设置', async () => {
|
||
// 以前这里只有三个写死的选项(本地 / OpenAI 3-small / 3-large),接不了
|
||
// 本机起的服务。而本地哈希向量不支持语义检索这件事,界面上也得说出来。
|
||
const { page, errors } = await visit('/knowledge')
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||
|
||
let body = await page.locator('body').innerText()
|
||
check('显示当前用的是什么、几维', /·\s*\d+\s*维/.test(body),
|
||
body.match(/[\w:.-]+ · \d+ 维/)?.[0])
|
||
if (body.includes('local-hashing')) {
|
||
check('本地哈希要标出不支持语义检索', body.includes('不支持语义检索'))
|
||
}
|
||
|
||
await page.getByRole('button', { name: '更换' }).first().click()
|
||
await page.waitForTimeout(400)
|
||
body = await page.locator('body').innerText()
|
||
check('能配自定义端点', body.includes('自定义端点'))
|
||
check('也留着本地那一项', body.includes('本地哈希向量'))
|
||
// 模型名手填太容易错,必须能探
|
||
check('给得出探测入口', body.includes('获取模型列表'))
|
||
|
||
const overflow = await overflowOf(page)
|
||
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
|
||
await shot(page, 'embedding')
|
||
})
|
||
|
||
await section('向量模型:配了语义模型却退回本地', async () => {
|
||
// 配的是本机 LM Studio 上的模型,服务启动时它没起,进程就一直用本地哈希。
|
||
// 以前页面按"配的是不是 local"判断:不说没有语义能力,还高亮"重建索引"——
|
||
// 那些"对不上"的向量正是那个模型建好的,一点就被哈希覆盖。真实环境撞不撞得上
|
||
// 这个状态看运气,所以这里伪造状态接口,几种情况各看一遍;写请求一律拦下
|
||
const embeddingPage = async (initial, onPut) => {
|
||
let status = initial
|
||
const { page, errors } = await newPage()
|
||
const sent = []
|
||
await page.route('**/api/**', (route) => {
|
||
const r = route.request()
|
||
if (new URL(r.url()).pathname.endsWith('/api/kb/embedding')) {
|
||
if (r.method() === 'GET') return route.fulfill({ json: status })
|
||
if (r.method() === 'PUT') {
|
||
sent.push(r.postDataJSON())
|
||
status = onPut(status)
|
||
return route.fulfill({ json: {
|
||
embedder: status.embedder, dim: status.dim,
|
||
stale_chunks: status.stale_chunks, stale_memories: status.stale_memories } })
|
||
}
|
||
}
|
||
return r.method() === 'GET' ? route.continue() : route.abort()
|
||
})
|
||
await page.goto(`${WEB}/knowledge`, { waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(500)
|
||
return { page, errors, sent, body: () => page.locator('body').innerText() }
|
||
}
|
||
const down = {
|
||
embedder: 'local-hashing', dim: 512, configured: true, kind: 'openai',
|
||
model: 'text-embedding-qwen3-embedding-4b', base_url: 'http://127.0.0.1:1234/v1',
|
||
stale_chunks: 68, stale_memories: 5, unindexed_chunks: 0,
|
||
has_semantics: false, default_alpha: 0, fallback: true,
|
||
fallback_reason: '无法使用 http://127.0.0.1:1234/v1 上的向量模型 '
|
||
+ 'text-embedding-qwen3-embedding-4b:APIConnectionError: Connection error.。请检查服务是否可用、模型名是否正确;使用官方接口时还需检查 OPENAI_API_KEY。',
|
||
}
|
||
const up = {
|
||
...down, embedder: 'openai:text-embedding-qwen3-embedding-4b', dim: 2560,
|
||
stale_chunks: 0, stale_memories: 0, has_semantics: true, default_alpha: 0.5,
|
||
fallback: false, fallback_reason: '',
|
||
}
|
||
|
||
const a = await embeddingPage(down, () => up)
|
||
let body = await a.body()
|
||
check('退回本地时标出不支持语义检索', body.includes('不支持语义检索'))
|
||
check('说清配的是哪个、为什么没用上',
|
||
body.includes('text-embedding-qwen3-embedding-4b') && body.includes('但连接失败'))
|
||
check('没连上的原因原样给出来', body.includes('APIConnectionError'))
|
||
check('不再劝人重建(会把建好的向量覆盖掉)',
|
||
await a.page.getByRole('button', { name: /重建索引/ }).count() === 0)
|
||
check('存量提示改口成暂缓重建', body.includes('请暂缓重建'))
|
||
|
||
const reconnect = a.page.getByRole('button', { name: '重新连接' })
|
||
check('给得出「重新连接」', await reconnect.count() === 1)
|
||
if (await reconnect.count()) {
|
||
await reconnect.click()
|
||
await a.page.waitForTimeout(800)
|
||
}
|
||
const req = a.sent[0]
|
||
check('重新连接发的是保存着的那份配置',
|
||
req?.kind === 'openai' && req.model === down.model && req.base_url === down.base_url,
|
||
JSON.stringify(req))
|
||
body = await a.body()
|
||
check('连上了说一声', body.includes('已重新连接'))
|
||
check('连上之后提示消失', !body.includes('不支持语义检索') && !body.includes('连接失败'))
|
||
check('没有运行时报错', !a.errors.some((e) => e.startsWith('pageerror')), a.errors.slice(0, 2).join(' | '))
|
||
await a.page.close()
|
||
|
||
// 明确选了本地哈希是正常情况:照旧标不支持语义检索,重建按钮也还在
|
||
const b = await embeddingPage(
|
||
{ ...down, kind: 'local', model: '', base_url: '', fallback: false, fallback_reason: '' },
|
||
(s) => s)
|
||
body = await b.body()
|
||
check('明确选了本地哈希:照旧标不支持语义检索', body.includes('不支持语义检索'))
|
||
check('……不冒出"连接失败"', !body.includes('连接失败'))
|
||
check('……重建按钮还在,正常情况别一起藏了',
|
||
await b.page.getByRole('button', { name: /重建索引/ }).count() === 1)
|
||
})
|
||
|
||
await section('会话', async () => {
|
||
// 对话以前只活在内存里,刷新就没了。这一组守的是它真的落了库:
|
||
// 列表能列出来、切过去内容跟着变、刷新还在。
|
||
//
|
||
// 最后一项守的是一个真踩过的坑:进页面时自动建一条空会话,而 create 是
|
||
// 异步的、StrictMode 又把 effect 跑两遍,于是每次访问都留下两三条"新对话"。
|
||
// 写请求被闸门掐掉了,库里的条数不会变,所以直接数页面有没有想去建
|
||
const before = await listOf('/conversations')
|
||
const mark = blocked.length
|
||
|
||
const { page, errors } = await visit('/chat')
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||
check('有"新对话"按钮', await page.getByRole('button', { name: /新对话/ }).first().isVisible())
|
||
|
||
const seeded = before.find((c) => c.turn_count > 0)
|
||
if (seeded) {
|
||
await page.locator('aside button[title]').filter({ hasText: seeded.title }).first().click()
|
||
await page.waitForTimeout(700)
|
||
check('切过去能看到那次问的话', (await page.locator('main').innerText()).includes(seeded.title))
|
||
await page.reload({ waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(900)
|
||
check('刷新后还在', (await page.locator('main').innerText()).includes(seeded.title))
|
||
} else {
|
||
check('(跳过:库里还没有带轮次的会话)', true)
|
||
}
|
||
|
||
const creates = blocked.slice(mark).filter((w) => /^POST \/api\/conversations$/.test(w))
|
||
check('逛一圈没有想凭空建会话', creates.length === 0, `${creates.length} 次`)
|
||
await shot(page, 'conversations')
|
||
})
|
||
|
||
await section('URL 指得到', async () => {
|
||
// 在此之前「在看哪个对话/哪次运行/哪一屏」全在 store 和 localStorage 里:
|
||
// 一次对话没有地址,发给同事只能发截图;刷新靠 localStorage,换台机器就丢;
|
||
// 浏览器后退会直接离开整个页面而不是回到上一个对话。
|
||
const convs = await listOf('/conversations')
|
||
const seeded = convs.filter((c) => c.turn_count > 0)
|
||
|
||
if (seeded.length >= 2) {
|
||
const [a, b] = seeded
|
||
const { page, errors } = await visit(`/chat/${a.id}`)
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||
check('深链接直达那个对话', (await page.locator('main').innerText()).includes(a.title),
|
||
`想要「${a.title}」`)
|
||
check('地址里就是那个 chatID', page.url().endsWith(`/chat/${a.id}`), page.url())
|
||
|
||
// 点另一个:地址要跟着走,而不是只改 store
|
||
await page.locator('aside button[title]').filter({ hasText: b.title }).first().click()
|
||
await page.waitForTimeout(600)
|
||
check('切换对话会改地址', page.url().endsWith(`/chat/${b.id}`), page.url())
|
||
|
||
// 刷新靠的是 URL,不是 localStorage
|
||
await page.reload({ waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(800)
|
||
check('刷新后还在同一个对话', page.url().endsWith(`/chat/${b.id}`)
|
||
&& (await page.locator('main').innerText()).includes(b.title))
|
||
|
||
// 后退回到上一个对话,而不是甩出整个页面——这条以前是彻底失效的
|
||
await page.goBack({ waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(700)
|
||
check('后退回到上一个对话', page.url().endsWith(`/chat/${a.id}`), page.url())
|
||
await shot(page, 'url-chat')
|
||
await page.close()
|
||
} else {
|
||
check('(跳过:库里不足两个带轮次的会话)', true)
|
||
}
|
||
|
||
{
|
||
// 链接过期/对话被删:不能白屏,也不能一声不响地跳走。有别的对话就落到最近那个,
|
||
// 一个都没有就回首屏
|
||
const { page } = await newPage()
|
||
await page.goto(`${WEB}/chat/这个id根本不存在`)
|
||
// 认整句提示,不认"不存在"三个字——会话标题里碰巧有这仨字就会把它蒙对
|
||
const times = await countNotice(page, '该对话不存在')
|
||
await page.waitForURL((u) => !u.pathname.includes(encodeURIComponent('这个id')), { timeout: 5000 }).catch(() => {})
|
||
const landed = new URL(page.url()).pathname
|
||
const body = await page.locator('body').innerText()
|
||
check('指向不存在的对话时不白屏', body.length > 40, `${body.length} 字`)
|
||
check(convs.length ? '落到了一个真实对话' : '没有别的对话:回到首屏',
|
||
convs.length ? convs.some((c) => landed === `/chat/${c.id}`) : landed === '/chat', page.url())
|
||
check('说清楚了为什么换了地方,只说一次', times === 1, `提示 ${times} 次`)
|
||
await page.close()
|
||
}
|
||
|
||
{
|
||
// 数据源在第二波搬出了设置页,自成一页 /data;老链接 /settings/datasources 重定向过去
|
||
const selected = (page) => page.getByRole('tab', { selected: true }).allInnerTexts()
|
||
const direct = await visit('/data/databases')
|
||
check('深链接直达数据库那一屏',
|
||
new URL(direct.page.url()).pathname === '/data/databases'
|
||
&& (await selected(direct.page)).join() === '数据库', direct.page.url())
|
||
await direct.page.close()
|
||
const legacy = await visit('/settings/datasources')
|
||
check('设置页的老链接落到同一屏',
|
||
new URL(legacy.page.url()).pathname === '/data/databases'
|
||
&& (await selected(legacy.page)).join() === '数据库', legacy.page.url())
|
||
await legacy.page.close()
|
||
const bare = await visit('/settings')
|
||
check('不带 tab 时落到第一屏并纠正地址',
|
||
bare.page.url().endsWith('/settings/providers'), bare.page.url())
|
||
await bare.page.close()
|
||
const bogus = await visit('/knowledge/没这个tab')
|
||
check('认不出的 tab 名回落而不是空白',
|
||
bogus.page.url().endsWith('/knowledge/kb'), bogus.page.url())
|
||
await bogus.page.close()
|
||
}
|
||
|
||
{
|
||
// 画布也该能指。这条路最容易出事:前进/后退会绕过选择器里那道
|
||
// "未保存改动"的确认直接换图
|
||
const wfs = await listOf('/workflows')
|
||
if (wfs.length >= 2) {
|
||
const bare = await visit('/studio')
|
||
check('/studio 落到第一张图并纠正地址',
|
||
bare.page.url().endsWith(`/studio/${wfs[0].id}`), bare.page.url())
|
||
await bare.page.close()
|
||
|
||
const { page } = await visit(`/studio/${wfs[1].id}`)
|
||
check('深链接直达那张图', (await page.locator('body').innerText()).includes(wfs[1].name),
|
||
`想要「${wfs[1].name}」`)
|
||
await page.close()
|
||
} else {
|
||
check(`(跳过:库里只有 ${wfs.length} 张工作流)`, true)
|
||
}
|
||
|
||
const { page } = await newPage()
|
||
await page.goto(`${WEB}/studio/根本没这张图`)
|
||
// 说一次就够。这里真弹过三次——effect 在请求回来之前又跑了两遍
|
||
const times = await countNotice(page, '该工作流不存在')
|
||
await page.waitForURL(/\/studio\/[0-9a-f]{8,}$/, { timeout: 5000 }).catch(() => {})
|
||
check('指向不存在的图时说一次并让开', times === 1 && /\/studio\/[0-9a-f]{8,}$/.test(page.url()),
|
||
`提示 ${times} 次,落在 ${page.url()}`)
|
||
await page.close()
|
||
}
|
||
|
||
{
|
||
// 问数据页的「在画布里打开」:建成一张新工作流、送去它自己的地址。
|
||
// 以前只是把节点塞进画布,store 里的 workflow 还是上一张图,⌘S 就把那张
|
||
// 整张覆盖了。要守的是:送到的是新图的地址、图没被别的工作流盖掉、
|
||
// 没弹莫名其妙的"有未保存的改动"。
|
||
//
|
||
// 这个按钮会写库。拦下建图那次 POST,拿它自己发出去的图回一个"已建好",
|
||
// 之后对这张图的读取也由这里答;校验、变量分析只算不写,放行
|
||
const DRAFT = 'c0ffee00c0ffee00c0ffee00c0ffee00'
|
||
let seed = null
|
||
for (const c of convs.slice(0, 8)) {
|
||
const d = await (await fetch(`${API}/conversations/${c.id}`)).json()
|
||
const t = (d.turns || []).find((x) => x.graph?.nodes?.length)
|
||
if (t) { seed = { id: c.id, want: t.graph.nodes.length }; break }
|
||
}
|
||
if (seed) {
|
||
const before = await listOf('/workflows')
|
||
const { page } = await newPage()
|
||
let dialogs = 0
|
||
page.on('dialog', async (d) => { dialogs++; await d.dismiss() })
|
||
let created = null
|
||
await page.route('**/api/workflows**', (route) => {
|
||
const r = route.request()
|
||
const path = new URL(r.url()).pathname
|
||
if (r.method() === 'POST' && path.endsWith('/api/workflows')) {
|
||
const body = r.postDataJSON()
|
||
const now = new Date().toISOString().replace('Z', '')
|
||
created = { id: DRAFT, name: body.name, description: '', graph: body.graph, tags: [],
|
||
version: 1, is_template: false, status: 'draft', published_version: null,
|
||
created_at: now, updated_at: now, run_count: 0 }
|
||
return route.fulfill({ status: 201, json: created })
|
||
}
|
||
if (path.includes(`/workflows/${DRAFT}`)) {
|
||
if (path.endsWith('/versions')) return route.fulfill({ json: [] })
|
||
if (r.method() === 'GET' && created) return route.fulfill({ json: created })
|
||
}
|
||
return route.fallback() // 其余交给上下文那道闸门:读放行、写掐掉
|
||
})
|
||
await page.goto(`${WEB}/chat/${seed.id}`, { waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(900)
|
||
const btn = page.getByRole('button', { name: '在画布里打开' }).first()
|
||
if (await btn.count()) {
|
||
await btn.click()
|
||
await page.waitForURL(`**/studio/${DRAFT}`, { timeout: 5000 }).catch(() => {})
|
||
await page.waitForTimeout(1200)
|
||
check('「在画布里打开」建了新图、送到它自己的地址',
|
||
!!created && page.url().endsWith(`/studio/${DRAFT}`), page.url())
|
||
check('建的是这一轮的图,名字带着问题',
|
||
created?.graph?.nodes?.length === seed.want && created.name.startsWith('问数据:'),
|
||
created?.name)
|
||
check('送过去的草稿图没被第一张工作流盖掉',
|
||
(await page.locator('.react-flow__node').count()) === seed.want,
|
||
`${await page.locator('.react-flow__node').count()} / ${seed.want} 个节点`)
|
||
check('没有弹莫名其妙的"未保存改动"', dialogs === 0, `弹了 ${dialogs} 次`)
|
||
const after = await listOf('/workflows')
|
||
check('检查本身没往库里写图', after.length === before.length,
|
||
`${before.length} → ${after.length}`)
|
||
} else {
|
||
check('(跳过:这一轮没有「在画布里打开」)', true)
|
||
}
|
||
await page.close()
|
||
} else {
|
||
check('(跳过:前 8 个会话里没有带图的轮次)', true)
|
||
}
|
||
}
|
||
|
||
{
|
||
// 单个文档 / 单个工具也要能指。文档那边原来连详情视图都没有——
|
||
// 列表里只有标题和片段数,点不开,而切块结果(重叠、表头续接)
|
||
// 是检索不准时第一个该看的东西
|
||
const docs = await listOf('/kb/documents')
|
||
const ready = docs.find((d) => d.status !== 'processing' && d.chunk_count > 0)
|
||
if (ready) {
|
||
const { page } = await visit(`/knowledge/kb/${ready.id}`)
|
||
const body = await page.locator('body').innerText()
|
||
check('深链接直达那份文档', body.includes(ready.title), ready.title)
|
||
check('看得到切块结果', /片段 \d+/.test(body) && /\d+ 字/.test(body),
|
||
body.slice(0, 120).replace(/\n/g, ' '))
|
||
check('回得去列表', (await page.getByRole('button', { name: /回到知识库/ }).count()) === 1)
|
||
await page.close()
|
||
|
||
const gone = await visit('/knowledge/kb/根本没这份文档')
|
||
const goneBody = await gone.page.locator('body').innerText()
|
||
check('文档不在了要说出来而不是白屏', goneBody.includes('该文档不存在'),
|
||
goneBody.slice(-120).replace(/\n/g, ' '))
|
||
await gone.page.close()
|
||
} else {
|
||
check('(跳过:库里没有切完块的文档)', true)
|
||
}
|
||
|
||
const tools = await listOf('/tools')
|
||
if (tools.length) {
|
||
const t = tools[0]
|
||
const { page } = await visit(`/tools/library/${t.id}`)
|
||
const body = await page.locator('body').innerText()
|
||
check('深链接直达那个工具', body.includes(t.name), t.name)
|
||
check('参数说明跟着出来', body.includes('参数'), '')
|
||
await page.close()
|
||
}
|
||
}
|
||
|
||
{
|
||
// 列表一页 50 条,往后靠「加载更多」按游标翻;一条老运行的链接必须也能打开——
|
||
// 所以详情是按 id 直接取的,不是在已经取回来的那几页里找
|
||
const PAGE = 50
|
||
const all = await listOf('/runs?limit=200')
|
||
if (all.length > PAGE) {
|
||
const old = all[all.length - 1]
|
||
const { page } = await visit(`/runs/${old.id}`)
|
||
const body = await page.locator('body').innerText()
|
||
check('第一页之外的老运行也打得开', body.includes(old.id.slice(0, 6)),
|
||
`第 ${all.length} 条 · run ${old.id.slice(0, 8)}`)
|
||
await page.close()
|
||
} else {
|
||
check(`(跳过:库里只有 ${all.length} 条运行,不足以越过第一页)`, true)
|
||
}
|
||
}
|
||
})
|
||
|
||
await section('样式层叠:工具类盖得过组件类(F1 回归)', async () => {
|
||
// .field / .btn 以前不在任何 @layer 里,而 Tailwind 的工具类在 utilities 层——层外的
|
||
// 规则永远赢,加上去的工具类全被静默吞掉:受管卡的 border-[var(--accent)] 算出来还是
|
||
// --border,两张卡看不出选了哪个;field w-44 占满整行;btn-xs 压根没定义
|
||
const { page, errors } = await visit('/studio')
|
||
const publish = page.getByRole('button', { name: /^发布$/ })
|
||
const ready = await publish.isEnabled().catch(() => false)
|
||
check('发布入口可用(没有未保存的改动)', ready)
|
||
if (ready) {
|
||
await publish.click()
|
||
const governed = page.getByRole('radio', { name: /^受管/ })
|
||
await governed.click()
|
||
for (const theme of ['dark', 'light']) {
|
||
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
|
||
await page.waitForTimeout(400) // 卡片带 transition-colors,等它落定
|
||
const cards = await page.locator('[role=radiogroup][aria-label="发布等级"] [role=radio]').evaluateAll((els) => {
|
||
const probe = document.createElement('i')
|
||
probe.style.borderTop = '1px solid var(--accent)'
|
||
els[0].parentElement.appendChild(probe)
|
||
const accent = getComputedStyle(probe).borderTopColor
|
||
probe.remove()
|
||
return els.map((el) => ({ on: el.getAttribute('aria-checked') === 'true', border: getComputedStyle(el).borderTopColor, accent }))
|
||
})
|
||
const on = cards.find((c) => c.on)
|
||
const off = cards.find((c) => !c.on)
|
||
check(`${theme}:选中「受管」后边框就是 --accent`, !!on && on.border === on.accent,
|
||
`${on?.border} / --accent ${on?.accent}`)
|
||
check(`${theme}:没选中的那张不是 --accent,两张看得出区别`, !!off && off.border !== off.accent, off?.border)
|
||
}
|
||
await page.keyboard.press('Escape')
|
||
}
|
||
|
||
const probe = await page.evaluate(() => {
|
||
// 在 utilities 层里声明一个和 Tailwind 的 w-44 一模一样的宽度:守的是层的先后,
|
||
// 不依赖源码里哪一处碰巧用了 w-44
|
||
const style = document.createElement('style')
|
||
style.textContent = '@layer utilities { .check-ui-w-44 { width: 11rem } }'
|
||
document.head.appendChild(style)
|
||
const field = document.createElement('input')
|
||
field.className = 'field check-ui-w-44'
|
||
const xs = document.createElement('button')
|
||
xs.className = 'btn btn-xs'
|
||
xs.textContent = '展开'
|
||
const base = document.createElement('button')
|
||
base.className = 'btn'
|
||
document.body.append(field, xs, base)
|
||
const f = field.getBoundingClientRect().width
|
||
const s = getComputedStyle(xs)
|
||
const out = { field: f, xs: s.fontSize, pad: `${s.paddingTop} ${s.paddingLeft}`, btn: getComputedStyle(base).fontSize }
|
||
field.remove(); xs.remove(); base.remove(); style.remove()
|
||
return out
|
||
})
|
||
check('field 上的 w-44 生效,窄于 200px', probe.field < 200, `${Math.round(probe.field)}px`)
|
||
check('.btn-xs 是 11px', probe.xs === '11px', `${probe.xs},普通 .btn ${probe.btn}`)
|
||
check('.btn-xs 有自己的内边距,不是按普通按钮渲染', probe.pad === '2px 6px', probe.pad)
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
|
||
// 真页面上挂着宽度工具类的 .field 都按工具类的宽度画(w-N = N × 4px)
|
||
for (const path of ['/tools/sandbox', '/knowledge/memory']) {
|
||
const { page: p } = await visit(path)
|
||
const fields = await p.locator('.field').evaluateAll((els) => els
|
||
.map((el) => ({ w: Number(el.className.match(/(?:^|\s)w-(\d+)(?:\s|$)/)?.[1]), got: el.getBoundingClientRect().width }))
|
||
.filter((x) => x.w))
|
||
const off = fields.filter((x) => Math.abs(x.got - x.w * 4) > 1)
|
||
check(`${path}:.field 上的宽度工具类都生效`, fields.length > 0 && off.length === 0,
|
||
fields.map((x) => `w-${x.w}=${Math.round(x.got)}px`).join(' '))
|
||
await p.close()
|
||
}
|
||
})
|
||
|
||
await section('三处讲的是同一个故事', async () => {
|
||
// 同一次运行,运行页详情和 preview 里的 dense 渲染应该给出同一批步骤。
|
||
// 这是"全站一个翻译层"的实际含义——分两套的话用户没法判断哪个是真的。
|
||
const { page } = await newPage()
|
||
await page.goto(`${WEB}/preview.html?case=loop_approve`, { waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(400)
|
||
const wide = await page.locator('body').innerText()
|
||
const asks = (wide.match(/这条公告可以发吗/g) ?? []).length
|
||
// 宽栏和窄栏各渲染一遍,所以是 3×2
|
||
check('宽窄两栏各三轮审批,一轮不少', asks === 6, `${asks} 处`)
|
||
check('决定折进了同一行而不是另起一行',
|
||
!/^已批准$/m.test(wide) && !/^已驳回$/m.test(wide))
|
||
})
|
||
|
||
await browser.close()
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 界面端到端全部通过')
|
||
process.exit(failed ? 1 : 0)
|