mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:11:59 +00:00
464d1c0741
- W4R-06:后台系统通知点开时,口径召回(不属于应用、挂在召回运行上)和使用者(进不了记录页)一律去 /apps/inbox/{id}。原来
没有 app_id 的都去 /runs/{run_id}:使用者被送回应用首页,搭建者落到召回运行的详情页,那里没有这张待办卡。
- W4R-07:recall 待办里「重新出具的正式运行」只给搭建者链到记录页,使用者写「正式运行的记录由搭建者在记录页查看」(同问数据
轮次下的出具结果)。
- W4R-12:版本历史里旧版还有没算的组却不能接着算时,照服务端给的 continue_reason 写「不再接着算:……」,不给按钮。
- decode 认 ledger.feedback(对账运行时间线上一条一行:谁给了哪一种反馈;认不出的不猜)。
检查:check-inbox 加使用者 / 搭建者看重新出具过的待办、系统通知点开去待办页两段(通知换成记录器,点它就是调 onclick);
check-publish 加之后又发布了更新版本的旧版不给接着算;check-decode 加 ledger.feedback 一段。去掉修复回跑,新加的几项都不过;
加上修复 decode、inbox、publish 三项 1110 项全过。tsc、check-copy 通过。
631 lines
45 KiB
JavaScript
631 lines
45 KiB
JavaScript
// 待办箱与待办卡(/apps?tab=inbox、/apps/inbox/:taskId,run/TaskCard)的回归检查。
|
||
//
|
||
// 守的是这些「看着没坏、其实不对」的地方:
|
||
// - 会签进度:派给了谁、谁同意了、按策略还差几人(全体同意 / 至少 m 人),撤回的派单淡显在最后;
|
||
// - 时限与升级:剩多久、超了多久(红)、升级过几次;悬停写明超时只会提醒、升级或驳回,不会自动批准;
|
||
// - 动作确认的哈希卡:遮罩后的预览、内容哈希只写前 8 位(完整的在悬停里)、环境(生产环境醒目);确认时把哈希原样
|
||
// 带回(payload_hash 一字不差),内容不能在这里改;缺哈希时不让确认;
|
||
// - 核实待办:三个选项(已生效 + 外部单号 / 未生效重新发送 / 未生效放弃),没选不提交,选了已生效不填单号不提交;
|
||
// 提交的结论和单号照约定交给待办接口;
|
||
// - 等外部系统(signal):只有说明和关联键,没有批准、驳回;
|
||
// - 不是派给我的(can_respond 为假):只读,写明原因;同意过、还在等别人的写「你已处理:同意」,不写「没有派给你」;
|
||
// - 升级之后:升级加派的那一份不算进会签人数,另写升级后的规则(升级给谁、其中一人说了算),名单里标「升级」;
|
||
// 待办箱一行写所属实例,同标题的分得开;
|
||
// - 补充输入带表单的:本地校验,服务端 422 的错误挂回字段;表单里的选人控件按这条待办的候选池取候选(使用者读不到
|
||
// 整份名录),不在池里的服务端 422(code=principal)挂回那个字段(05-已知遗留 (b));
|
||
// - 关登录时签批人一行写本机署名;会签只记下一票(resolved 为假)时提示「等待其他人处理」;
|
||
// - 审核个人应用(kind=review,payload 同服务端 blueprints/personal.review_payload):写提交人(显示名)、目的、说明;没有
|
||
// 「同意」——「取副本,去发布」调 POST /api/blueprints/{id}/copy 后进副本蓝图;取过的给「打开我的副本」(只认自己取的那
|
||
// 一份,别人取的不算);「退回」可以写原因,走待办接口 decision=reject。
|
||
// - 口径召回(kind=recall,payload 同服务端 ledger/recall.task_payload,next-round 02 做法 7):旧口径、新口径、两版之差
|
||
// 一行一个指标,每个数点得开出处(召回卡的指标链);每个答案当时的值也点得开(正式运行的口径卡、问数回答的查询快照);
|
||
// 问数回答链回那段对话;「按新版重新出具」decision=reissue、「知道了」decision=ack;服务端说不能重新出具时按钮停用、
|
||
// 写原因;使用者看到的遮罩值写「已遮罩」、点不开;目录有变那一种只有「知道了」,列出变了的目录项和新出现的问题。
|
||
//
|
||
// 接口全部在浏览器层答掉(/api/tasks/**),数据是这里虚构的;写请求记在探针里。
|
||
//
|
||
// node scripts/check-inbox.mjs
|
||
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-inbox.mjs
|
||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||
|
||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||
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 ONLY = (process.env.CHECK_ONLY ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
||
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 ago = (ms) => new Date(Date.now() - ms).toISOString()
|
||
const HASH = '9c1e44ab07d2f3a1c4b5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b'
|
||
|
||
const assignment = (id, principal, status, decision = null, extra = {}) => ({
|
||
id, principal_kind: 'user', principal, source: 'slot:finance', status, responded_by: status === 'responded' ? principal : null,
|
||
responded_at: status === 'responded' ? ago(600_000) : null, decision, verified: false, ...extra,
|
||
})
|
||
|
||
const base = {
|
||
approval_id: null, run_id: 'run_claim_1', node_id: 'finance', app_id: 'app_claim', app_name: '报销审批', app_version: 2,
|
||
workflow_name: '报销流程', form: null, policy: { kind: 'any' }, status: 'pending', due_at: null, escalation_level: 0,
|
||
payload: {}, assignments: [assignment('a0', 'zhangsan', 'pending')], can_respond: true, outcome: null, resolved_by: null,
|
||
resolved_at: null, created_at: ago(1800_000),
|
||
}
|
||
|
||
const TASKS = {
|
||
signoff: {
|
||
...base, id: 'signoff', approval_id: 'ap1', kind: 'approve', title: '财务会签', policy: { kind: 'quorum', m: 2 },
|
||
due_at: ago(2 * 3600_000), escalation_level: 1, node_label: '财务审批',
|
||
payload: { kind: 'human_node', message: '张三报销差旅 1,200 元' },
|
||
assignments: [
|
||
assignment('a1', 'lisi', 'responded', 'approve'),
|
||
assignment('a2', 'wangwu', 'pending'),
|
||
assignment('a3', 'zhaoliu', 'pending'),
|
||
assignment('a4', 'qianqi', 'withdrawn'),
|
||
],
|
||
},
|
||
confirm: {
|
||
...base, id: 'confirm', approval_id: 'ap2', kind: 'action_confirm', title: '确认付款', node_id: 'pay',
|
||
payload: { kind: 'action_confirm', capability: 'erp_pay', tool: 'erp_pay', environment: 'prod', payload_hash: HASH, attempt: 1,
|
||
preview: { 收款人: '张三', 金额: 1200, 银行账号: '已遮罩' } },
|
||
},
|
||
nohash: {
|
||
...base, id: 'nohash', approval_id: 'ap3', kind: 'action_confirm', title: '确认付款(缺哈希)', node_id: 'pay',
|
||
payload: { kind: 'action_confirm', capability: 'erp_pay', environment: 'test', preview: {} },
|
||
},
|
||
reconcile: {
|
||
...base, id: 'reconcile', kind: 'reconcile', title: '核实写入结果:erp_pay', node_id: 'pay',
|
||
payload: { kind: 'reconcile', action_id: 'act1', capability: 'erp_pay', environment: 'prod', payload_hash: HASH, attempt: 1,
|
||
idem_key: `${HASH.slice(0, 16)}:1`, state: 'unknown', error: '请求超时', request_at: ago(3600_000), preview: { 金额: 1200 } },
|
||
},
|
||
signal: {
|
||
...base, id: 'signal', kind: 'signal', title: '等付款回调', can_respond: false,
|
||
assignments: [{ ...assignment('h1', 'paid', 'pending'), principal_kind: 'hook' }],
|
||
payload: { kind: 'signal', correlation: 'order-8812', hook: 'paid' },
|
||
},
|
||
readonly: {
|
||
...base, id: 'readonly', approval_id: 'ap4', kind: 'approve', title: '总监审批', can_respond: false,
|
||
payload: { kind: 'human_node', message: '金额超过一万,需要总监批准' },
|
||
},
|
||
// 同意过、还在等别人:不能再回复,写「你已处理:同意」,不写「没有派给你」
|
||
voted: {
|
||
...base, id: 'voted', approval_id: 'ap6', kind: 'approve', title: '财务会签(已同意)', policy: { kind: 'all' },
|
||
can_respond: false, my_response: { decision: 'approve', at: ago(600_000), verified: true },
|
||
assignments: [assignment('v1', 'zhangsan', 'responded', 'approve', { verified: true }), assignment('v2', 'wangwu', 'pending')],
|
||
payload: { kind: 'human_node', message: '钱七报销 1,280.5 元' },
|
||
},
|
||
// 超时升级之后:升级加派的那一份不算进会签人数(还是 0/2),另写升级给了谁、其中一人说了算
|
||
escalated: {
|
||
...base, id: 'escalated', approval_id: 'ap7', kind: 'approve', title: '报销审批:钱七 · 300 元', policy: { kind: 'all' },
|
||
due_at: ago(600_000), escalation_level: 1, instance_title: '报销审批·钱七·第二笔', node_label: '财务会签',
|
||
assignments: [assignment('e1', 'zhangsan', 'pending'), assignment('e2', 'wangwu', 'pending'),
|
||
assignment('e3', 'zhaoliu', 'pending', null, { source: 'escalation' })],
|
||
progress: { policy: 'all', voters: 2, need: 2, agreed: 0, rejected: 0,
|
||
escalation: { slot: 'director', assignees: [{ kind: 'user', name: 'zhaoliu' }], decided: null, by: null } },
|
||
payload: { kind: 'human_node', message: '钱七报销 300 元' },
|
||
},
|
||
// 审核个人应用:不挂在中断上(node_id 为空),派给处理审核的人;payload 同服务端 blueprints/personal.review_payload
|
||
review: {
|
||
...base, id: 'review', kind: 'review', title: '审核个人应用「我的退款周报」', node_id: null, app_id: 'app_mine',
|
||
app_name: '我的退款周报', app_version: null, workflow_name: null, run_id: 'run_p1',
|
||
assignments: [assignment('r1', '审批人甲', 'pending', null, { source: 'review' })],
|
||
payload: { kind: 'review', blueprint_id: 'bp_mine', app_id: 'app_mine', name: '我的退款周报', purpose: '每周看自己门店的退款',
|
||
template_id: 'periodic_board', submitted_by: 'zhangsan', note: '想给门店组的人都用', copies: [] },
|
||
people: { zhangsan: '张三', 审批人甲: '审批人甲' },
|
||
},
|
||
// 已经取过副本:自己取的那一份给「打开我的副本」;别的处理人取的不算
|
||
reviewed: {
|
||
...base, id: 'reviewed', kind: 'review', title: '审核个人应用「门店日报」', node_id: null, app_id: 'app_daily',
|
||
app_name: '门店日报', app_version: null, workflow_name: null, run_id: 'run_p2',
|
||
assignments: [assignment('r2', '审批人甲', 'pending', null, { source: 'review' })],
|
||
payload: { kind: 'review', blueprint_id: 'bp_daily', app_id: 'app_daily', name: '门店日报', purpose: '每天看门店的销售',
|
||
template_id: 'periodic_board', submitted_by: 'zhangsan', note: '', copies: [
|
||
{ id: 'bp_daily_other', owner: 'lisi', status: 'drafting', app_id: null },
|
||
{ id: 'bp_daily_mine', owner: '审批人甲', status: 'trialed', app_id: null }] },
|
||
people: { zhangsan: '张三' },
|
||
},
|
||
// 口径召回:口径升到 v2,这一组答案的数变了(payload 同服务端 ledger/recall.task_payload)
|
||
recall: {
|
||
...base, id: 'recall', kind: 'recall', title: '口径「周销售」升到 v2,答案的数变了', node_id: null, app_id: null,
|
||
app_name: null, app_version: null, workflow_name: '口径召回:周销售 v1 → v2', run_id: 'rc_1',
|
||
assignments: [assignment('rc1', '审批人甲', 'pending', null, { source: 'recall' })],
|
||
payload: { kind: 'recall', reason: 'caliber', recall_run: 'rc_1', workflow_id: 'wf_week', workflow_name: '周销售',
|
||
from_version: 1, to_version: 2,
|
||
binding: [{ field: 'end', value: '2026-09-08', masked: false }, { field: 'start', value: '2026-09-01', masked: false }],
|
||
metrics: [{ k: 0, name: '销售额', unit: '元', changed: true, card: 'card', metric: 'gmv',
|
||
old: { value: 170.5, text: '170.50元', masked: false, metric: 'old_0' },
|
||
new: { value: 208.5, text: '208.50元', masked: false, metric: 'new_0' },
|
||
diff: { value: 38, text: '38.00元', masked: false, metric: 'diff_0' } }],
|
||
answers: [
|
||
{ kind: 'answer', kind_text: '问数回答', run_id: 'run_ans_1', version: 1, conversation_id: 'conv_1', turn_id: 'turn_1',
|
||
at: ago(86400_000), original: [{ k: 0, value: 170.5, text: '170.50元', masked: false, query: 'snap_1', column: '销售额' }] },
|
||
{ kind: 'schedule', kind_text: '定时任务', run_id: 'run_fm_1', version: 1, schedule_id: 'sch_1', at: ago(3 * 86400_000),
|
||
original: [{ k: 0, value: 165, text: '165.00元', masked: false, node: 'card', metric: 'gmv' }] },
|
||
],
|
||
actions: [{ decision: 'reissue', available: true }, { decision: 'ack', available: true }] },
|
||
},
|
||
// 使用者看的:遮罩值写「已遮罩」、点不开;没有被他能用的应用固定,服务端说不能重新出具
|
||
recallUser: {
|
||
...base, id: 'recallUser', kind: 'recall', title: '口径「客户分层」升到 v3,答案的数变了', node_id: null, app_id: null,
|
||
app_name: null, app_version: null, workflow_name: '口径召回:客户分层 v2 → v3', run_id: 'rc_2',
|
||
assignments: [assignment('rc2', 'zhangsan', 'pending', null, { source: 'recall' })],
|
||
payload: { kind: 'recall', reason: 'caliber', recall_run: 'rc_2', workflow_id: 'wf_tier', workflow_name: '客户分层',
|
||
from_version: 2, to_version: 3, binding: [{ field: 'phone', value: null, masked: true }],
|
||
metrics: [{ k: 0, name: '消费额', unit: '元', changed: true, card: 'card', metric: 'spend',
|
||
old: { value: null, text: '已遮罩', masked: true, metric: 'old_0' },
|
||
new: { value: 90, text: '90元', masked: false, metric: 'new_0' },
|
||
diff: { value: null, text: '已遮罩', masked: true, metric: 'diff_0' } }],
|
||
answers: [{ kind: 'answer', kind_text: '问数回答', run_id: 'run_ans_2', version: 2, conversation_id: 'conv_2',
|
||
original: [{ k: 0, value: null, text: null, masked: false, missing: '原答案里没有单独的这个数' }] }],
|
||
actions: [{ decision: 'reissue', available: false,
|
||
reason: '这份口径没有被你能使用的应用固定,不能由你按口径出具,请搭建者出具' }, { decision: 'ack', available: true }] },
|
||
},
|
||
// 已经按新版重新出具过的(处理完的):出具的那次正式运行搭建者点得开,使用者只写一句(进不了记录页)
|
||
recallIssued: {
|
||
...base, id: 'recallIssued', kind: 'recall', title: '口径「客户分层」升到 v3,答案的数变了', node_id: null, app_id: null,
|
||
app_name: null, app_version: null, workflow_name: '口径召回:客户分层 v2 → v3', run_id: 'rc_3', status: 'resolved',
|
||
resolved_by: 'zhangsan', resolved_at: ago(60_000), can_respond: false,
|
||
outcome: { decision: 'reissue', note: '', issued_run: 'run_issued_9' },
|
||
assignments: [assignment('rc4', 'zhangsan', 'responded', 'reissue', { source: 'recall' })],
|
||
payload: { kind: 'recall', reason: 'caliber', recall_run: 'rc_3', workflow_id: 'wf_tier', workflow_name: '客户分层',
|
||
from_version: 2, to_version: 3, binding: [], metrics: [], answers: [],
|
||
outcome: { decision: 'reissue', note: '', issued_run: 'run_issued_9' },
|
||
actions: [{ decision: 'reissue', available: false, issued_run: 'run_issued_9' }, { decision: 'ack', available: false }] },
|
||
},
|
||
// 答案依据的目录有变、按新目录检查出了新的错误级问题:只有「知道了」
|
||
recallCatalog: {
|
||
...base, id: 'recallCatalog', kind: 'recall', title: '答案依据的数据目录有变:按新目录检查出了新的问题', node_id: null,
|
||
app_id: null, app_name: null, app_version: null, workflow_name: '', run_id: 'run_ans_3',
|
||
assignments: [assignment('rc3', '审批人甲', 'pending', null, { source: 'recall' })],
|
||
payload: { kind: 'recall', reason: 'catalog', recall_run: 'run_ans_3',
|
||
basis: { kind: 'catalog', source: 'shop', table: 'orders', new_errors: 1,
|
||
items: [{ path: 'columns.amount.measure', label: '列 amount 的度量类型', change: 'changed' }],
|
||
checks: [{ code: 'stock_summed', message: '对存量列 amount 求和,分组里没有业务日期' }] },
|
||
actions: [{ decision: 'ack', available: true }] },
|
||
},
|
||
// 补充输入里选复核人(选人控件):使用者按这条待办的候选池选
|
||
pick: {
|
||
...base, id: 'pick', approval_id: 'ap8', kind: 'input', title: '指定复核人',
|
||
form: { format: 'agentlab.form/1', type: 'object', required: ['reviewer'], properties: {
|
||
reviewer: { type: 'string', title: '复核人', 'x-widget': 'principal' } } },
|
||
payload: { kind: 'human_node', message: '请指定复核人' },
|
||
},
|
||
input: {
|
||
...base, id: 'input', approval_id: 'ap5', kind: 'input', title: '补填发票信息',
|
||
form: { format: 'agentlab.form/1', type: 'object', required: ['invoice'], properties: {
|
||
invoice: { type: 'string', title: '发票号', maxLength: 20 }, date: { type: 'string', title: '开票日期', format: 'date' } } },
|
||
payload: { kind: 'human_node', message: '请补填发票号' },
|
||
},
|
||
}
|
||
|
||
const browser = await chromium.launch({ executablePath: CHROME })
|
||
|
||
async function open(state) {
|
||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
|
||
opened.add(ctx)
|
||
const page = await ctx.newPage()
|
||
page.errors = []
|
||
page.on('pageerror', (e) => page.errors.push(e.message))
|
||
await page.addInitScript(() => localStorage.setItem('agentlab_actor', '审批人甲'))
|
||
if (state.notify) {
|
||
// 系统通知换成记录器:开了后台提醒、已授权;记下每一条通知(点它就是调它的 onclick),页面一直在后台
|
||
await page.addInitScript(() => {
|
||
localStorage.setItem('agentlab.notify', '1')
|
||
window.__notes = []
|
||
class FakeNotification {
|
||
static permission = 'granted'
|
||
static async requestPermission() { return 'granted' }
|
||
constructor(title, opts) { this.title = title; this.tag = opts?.tag; window.__notes.push(this) }
|
||
close() {}
|
||
}
|
||
window.Notification = FakeNotification
|
||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true })
|
||
})
|
||
}
|
||
await page.route((url) => url.pathname.startsWith('/api/'), (route) => {
|
||
const r = route.request()
|
||
const p = new URL(r.url()).pathname
|
||
if (r.method() !== 'GET') {
|
||
const body = r.postData() ? JSON.parse(r.postData()) : null
|
||
state.writes.push({ method: r.method(), path: p, body })
|
||
const m = p.match(/^\/api\/tasks\/([^/]+)\/respond$/)
|
||
if (m) {
|
||
if (m[1] === 'input' && state.reject422) {
|
||
return route.fulfill({ status: 422, json: { detail: '表单没有通过校验', code: 'form_invalid', errors: [
|
||
{ path: 'invoice', code: 'max_length', message: '最多 20 个字' }] } })
|
||
}
|
||
if (m[1] === 'pick' && body?.values?.reviewer !== 'fin') {
|
||
return route.fulfill({ status: 422, json: { detail: '表单没有通过校验', code: 'form_invalid', errors: [
|
||
{ path: 'reviewer', code: 'principal', message: `「${body?.values?.reviewer}」不在可选的人里,请从候选中选择` }] } })
|
||
}
|
||
const t = TASKS[m[1]]
|
||
// 会签:这一票记下了,还没凑齐
|
||
const resolved = m[1] !== 'signoff'
|
||
return route.fulfill({ json: { task: { ...t, status: resolved ? 'resolved' : 'pending' }, resolved } })
|
||
}
|
||
// 审核待办「取副本,去发布」:服务端建一份归处理人的共享副本,交回副本蓝图
|
||
if (p === '/api/blueprints/bp_mine/copy') return route.fulfill({ json: { id: 'bp_mine_copy', scope: 'shared', owner: '审批人甲' } })
|
||
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
|
||
}
|
||
if (p === '/api/auth/me') return route.fulfill({ json: state.me ?? { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } })
|
||
if (p === '/api/directory/candidates') {
|
||
const q = new URL(r.url()).searchParams
|
||
;(state.candidateCalls ??= []).push(Object.fromEntries(q))
|
||
const people = [{ username: 'fin', display_name: '樊宁' }, { username: 'fay', display_name: '费伊' }]
|
||
return route.fulfill({ json: { users: people.filter((u) => u.username.startsWith(q.get('q') ?? '')), groups: [], truncated: false } })
|
||
}
|
||
if (p === '/api/directory') {
|
||
state.directoryHits = (state.directoryHits ?? 0) + 1
|
||
return route.fulfill({ status: 403, json: { detail: '需要搭建者权限' } })
|
||
}
|
||
// 进了副本蓝图之后的页面不在这里查:蓝图一律答「不存在」,不打到真实服务端
|
||
if (p.startsWith('/api/blueprints/')) return route.fulfill({ status: 404, json: { detail: '蓝图不存在或已被删除' } })
|
||
// state.live:派给我的待处理待办由这一段自己换(外壳的待办通知看数和最新一条的时间变没变)
|
||
if (p === '/api/tasks/count' && state.live) {
|
||
return route.fulfill({ json: { mine: state.live.length, apps: 0, other: state.live.length,
|
||
latest: state.live.map((t) => t.created_at).sort().at(-1) ?? null } })
|
||
}
|
||
if (p === '/api/tasks' && state.live) return route.fulfill({ json: { items: state.live } })
|
||
if (p === '/api/approvals' && state.live) return route.fulfill({ json: [] })
|
||
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: Object.keys(TASKS).length, apps: Object.keys(TASKS).length, other: 0 } })
|
||
if (p === '/api/tasks') return route.fulfill({ json: { items: Object.values(TASKS) } })
|
||
const m = p.match(/^\/api\/tasks\/([^/]+)$/)
|
||
if (m && TASKS[m[1]]) return route.fulfill({ json: TASKS[m[1]] })
|
||
return route.continue()
|
||
})
|
||
return page
|
||
}
|
||
|
||
const card = (page) => page.locator('[data-task]').first()
|
||
|
||
await section('会签进度、时限、升级', async () => {
|
||
const state = { writes: [] }
|
||
const page = await open(state)
|
||
await page.goto(`${WEB}/apps/inbox/signoff`)
|
||
await card(page).waitFor({ timeout: 15000 })
|
||
const s = page.locator('[data-signoffs]')
|
||
check('至少 2 人同意:进度写 1/2', (await s.innerText()).includes('1/2 已同意') && (await s.getAttribute('data-signoffs-need')) === '2')
|
||
check('策略写明', (await s.innerText()).includes('需 2 人同意'))
|
||
check('每个派单一行,撤回的在最后', (await s.locator('li').count()) === 4
|
||
&& (await s.locator('li').last().getAttribute('data-assignment')) === 'withdrawn')
|
||
const due = page.locator('[data-task-due]')
|
||
check('超时写「已超时」并标红', (await due.getAttribute('data-task-due')) === 'overdue' && /已超时/.test(await due.innerText()))
|
||
check('悬停写明不会自动批准', /不会自动批准/.test(await due.getAttribute('title')))
|
||
check('升级过写明', (await page.locator('[data-task-escalated]').innerText()).includes('已升级'))
|
||
check('卡头写节点名', (await card(page).innerText()).includes('节点「财务审批」'))
|
||
check('关登录时签批人写本机署名', (await page.locator('[data-task-signer="actor"]').innerText()).includes('审批人甲'))
|
||
await page.locator('[data-task-approve]').click()
|
||
await page.waitForTimeout(300)
|
||
const sent = state.writes.find((w) => w.path === '/api/tasks/signoff/respond')
|
||
check('批准走待办接口,decision 是 approve', sent?.body?.decision === 'approve', JSON.stringify(sent?.body))
|
||
check('只记下一票时提示等待其他人', await page.getByText('已记下你的意见,等待其他人处理').waitFor({ timeout: 3000 }).then(() => true, () => false))
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
})
|
||
|
||
await section('动作确认的哈希卡', async () => {
|
||
const state = { writes: [] }
|
||
const page = await open(state)
|
||
await page.goto(`${WEB}/apps/inbox/confirm`)
|
||
const box = page.locator('[data-action-confirm]')
|
||
await box.waitFor({ timeout: 15000 })
|
||
check('哈希只写前 8 位', (await box.locator('[data-action-hash]').innerText()) === HASH.slice(0, 8))
|
||
check('完整哈希在悬停里', (await box.locator('[data-action-hash]').getAttribute('title')).includes(HASH))
|
||
check('环境:生产环境', (await box.locator('[data-action-env]').innerText()) === '生产环境')
|
||
check('遮罩后的预览照写', (await box.locator('[data-action-preview]').innerText()).includes('已遮罩'))
|
||
check('内容不能在这里改:没有输入框改参数', (await box.locator('textarea').count()) === 0)
|
||
await box.locator('[data-action-confirm-send]').click()
|
||
await page.waitForTimeout(300)
|
||
const sent = state.writes.find((w) => w.path === '/api/tasks/confirm/respond')
|
||
check('确认时哈希原样带回', sent?.body?.payload_hash === HASH && sent?.body?.decision === 'approve', JSON.stringify(sent?.body))
|
||
})
|
||
|
||
await section('动作确认:缺哈希不让确认', async () => {
|
||
const page = await open({ writes: [] })
|
||
await page.goto(`${WEB}/apps/inbox/nohash`)
|
||
const box = page.locator('[data-action-confirm]')
|
||
await box.waitFor({ timeout: 15000 })
|
||
check('写明缺少内容哈希', (await box.innerText()).includes('缺少内容哈希'))
|
||
check('确认按钮停用', await box.locator('[data-action-confirm-send]').isDisabled())
|
||
})
|
||
|
||
await section('核实待办:三个选项', async () => {
|
||
const state = { writes: [] }
|
||
const page = await open(state)
|
||
await page.goto(`${WEB}/apps/inbox/reconcile`)
|
||
const box = page.locator('[data-reconcile]')
|
||
await box.waitFor({ timeout: 15000 })
|
||
check('三个选项', (await box.locator('[data-reconcile-option]').count()) === 3)
|
||
check('写明不会自动重发', (await box.innerText()).includes('不会自动重发'))
|
||
await box.locator('[data-reconcile-submit]').click()
|
||
check('没选不提交', !state.writes.length)
|
||
await box.locator('[data-reconcile-option="confirmed"] input').check()
|
||
await box.locator('[data-reconcile-submit]').click()
|
||
check('选了已生效不填单号不提交', !state.writes.length)
|
||
await box.locator('[data-reconcile-external]').fill('ERP-20261014-0001')
|
||
await box.locator('[data-reconcile-submit]').click()
|
||
await page.waitForTimeout(300)
|
||
const sent = state.writes.find((w) => w.path === '/api/tasks/reconcile/respond')
|
||
check('已生效 + 外部单号', sent?.body?.decision === 'confirmed' && sent?.body?.external_id === 'ERP-20261014-0001', JSON.stringify(sent?.body))
|
||
})
|
||
|
||
await section('等外部系统、只读', async () => {
|
||
const page = await open({ writes: [] })
|
||
await page.goto(`${WEB}/apps/inbox/signal`)
|
||
await page.locator('[data-task-signal]').waitFor({ timeout: 15000 })
|
||
check('写明由外部系统完成,带关联键', (await page.locator('[data-task-signal]').innerText()).includes('order-8812'))
|
||
check('没有批准、驳回', (await page.locator('[data-task-approve], [data-task-reject]').count()) === 0)
|
||
await page.goto(`${WEB}/apps/inbox/readonly`)
|
||
await page.locator('[data-task-readonly]').waitFor({ timeout: 15000 })
|
||
check('不是派给我的:只读并写明', (await page.locator('[data-task-readonly]').innerText()).includes('没有派给你'))
|
||
check('没有批准、驳回', (await page.locator('[data-task-approve], [data-task-reject]').count()) === 0)
|
||
await page.goto(`${WEB}/apps/inbox/voted`)
|
||
await page.locator('[data-task-readonly="responded"]').waitFor({ timeout: 15000 })
|
||
const voted = await page.locator('[data-task-readonly]').innerText()
|
||
check('同意过的人再打开:写「你已处理:同意」,不写「没有派给你」', voted.includes('你已处理:同意') && !voted.includes('没有派给你'), voted)
|
||
})
|
||
|
||
await section('升级之后的会签进度', async () => {
|
||
const page = await open({ writes: [] })
|
||
await page.goto(`${WEB}/apps/inbox/escalated`)
|
||
const s = page.locator('[data-signoffs]')
|
||
await s.waitFor({ timeout: 15000 })
|
||
const text = await s.innerText()
|
||
check('升级加派的不算进会签人数:0/2,不是 0/3', text.includes('0/2 已同意') && (await s.getAttribute('data-signoffs-need')) === '2', text)
|
||
check('另写升级后的规则:升级给谁、其中一人说了算',
|
||
(await s.locator('[data-signoffs-escalation="open"]').innerText()).includes('已升级给岗位「director」:其中任一人同意即通过'))
|
||
check('升级加派的那一份在名单里标「升级」', (await s.locator('li', { hasText: 'zhaoliu' }).innerText()).includes('升级'))
|
||
await page.goto(`${WEB}/apps?tab=inbox`)
|
||
const row = page.locator('[data-inbox-task="escalated"]')
|
||
await row.waitFor({ timeout: 15000 })
|
||
check('待办箱里也是 0/2', (await row.locator('[data-inbox-signoffs]').innerText()).includes('0/2 已同意'))
|
||
check('待办一行写所属实例(同标题的分得开)', (await row.locator('[data-inbox-meta]').innerText()).includes('报销审批·钱七·第二笔'))
|
||
})
|
||
|
||
await section('补充输入:表单校验、422 挂回字段', async () => {
|
||
const state = { writes: [] }
|
||
const page = await open(state)
|
||
await page.goto(`${WEB}/apps/inbox/input`)
|
||
const form = page.locator('[data-schema-form]')
|
||
await form.waitFor({ timeout: 15000 })
|
||
await page.locator('[data-task-approve]').click()
|
||
check('必填没填本地拦下', !state.writes.length && (await form.locator('[data-field="invoice"] [data-field-error="required"]').count()) === 1)
|
||
await form.locator('[data-field="invoice"] input').fill('FP-001')
|
||
await form.locator('[data-field="date"] input').fill('2026-10-14')
|
||
state.reject422 = true
|
||
await page.locator('[data-task-approve]').click()
|
||
await form.locator('[data-field-error="max_length"]').waitFor({ timeout: 3000 }).catch(() => {})
|
||
check('服务端 422 挂回字段', (await form.locator('[data-field="invoice"] [data-field-error="max_length"]').count()) === 1)
|
||
const sent = state.writes.find((w) => w.path === '/api/tasks/input/respond')
|
||
check('提交的是表单的值,decision 是 submit', sent?.body?.decision === 'submit' && sent?.body?.values?.invoice === 'FP-001', JSON.stringify(sent?.body))
|
||
})
|
||
|
||
await section('补充输入里选人:按待办的候选池取候选,不在池里 422 挂回字段(05-已知遗留 (b))', async () => {
|
||
const state = { writes: [], me: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true } }
|
||
const page = await open(state)
|
||
await page.goto(`${WEB}/apps/inbox/pick`)
|
||
const field = page.locator('[data-schema-form] [data-field="reviewer"]')
|
||
await field.waitFor({ timeout: 15000 })
|
||
const input = field.locator('input.field')
|
||
await input.fill('f')
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-field="reviewer"] datalist option').length > 1, null,
|
||
{ timeout: 5000 }).catch(() => {})
|
||
const asked = (state.candidateCalls ?? []).at(-1) ?? {}
|
||
check('按这条待办、这个字段问候选(purpose=form、task_id)', asked.purpose === 'form' && asked.task_id === 'pick'
|
||
&& asked.field === 'reviewer' && !asked.app_id, JSON.stringify(asked))
|
||
check('……列出池里的人(只有名字),不去取整份名录', (await field.locator('datalist option').count()) === 2 && !state.directoryHits)
|
||
// 候选接口一时取不到时先收下(提交时服务端判):这里绕过前端的提前核对,直接看服务端 422 怎么挂回来
|
||
await page.route('**/api/directory/candidates**', (route) => route.abort())
|
||
await input.fill('wangwu')
|
||
await input.press('Enter')
|
||
await page.locator('[data-task-approve]').click()
|
||
await field.locator('[data-field-error="principal"]').waitFor({ timeout: 5000 }).catch(() => {})
|
||
check('服务端 422(code=principal)挂回选人字段', (await field.locator('[data-field-error="principal"]').count()) === 1
|
||
&& (await field.locator('[data-field-error="principal"]').innerText()).includes('不在可选的人里'))
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
})
|
||
|
||
await section('审核个人应用:取副本去发布、打开我的副本、退回', async () => {
|
||
const state = { writes: [] }
|
||
const page = await open(state)
|
||
await page.goto(`${WEB}/apps/inbox/review`)
|
||
const box = page.locator('[data-task-review]')
|
||
await box.waitFor({ timeout: 15000 })
|
||
const text = await box.innerText()
|
||
check('写提交人(显示名)、目的、提交说明', text.includes('提交人:张三') && text.includes('每周看自己门店的退款')
|
||
&& text.includes('想给门店组的人都用'), text.replace(/\s+/g, ' '))
|
||
check('同意不在这里点:没有「同意」「驳回」按钮,也不写「没有派给你」',
|
||
await page.locator('[data-task-approve], [data-task-reject]').count() === 0 && !(await card(page).innerText()).includes('没有派给你'))
|
||
check('没取过副本:给「取副本,去发布」,没有「打开我的副本」', await box.locator('[data-task-review-copy]').count() === 1
|
||
&& await box.locator('[data-task-review-open]').count() === 0)
|
||
// 待办挂在提交人那次个人应用的刷新上,只有拥有者读得到:不给「打开实例」「打开运行记录」(链出去只会 404 / 403)
|
||
const links = await card(page).locator('a[href]').evaluateAll((as) => as.map((a) => a.getAttribute('href')))
|
||
check('不给打不开的链接:没有指向提交人个人应用的实例页、也没有运行记录页', !links.some((h) => /\/apps\/app_mine\/i\/|^\/runs\//.test(h ?? ''))
|
||
&& !/查看实例|查看运行/.test(await card(page).innerText()), links.join(' , '))
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
await box.locator('[data-task-review-copy]').click()
|
||
check('取副本:POST /api/blueprints/{原蓝图}/copy', await page.waitForURL(/\/blueprints\/bp_mine_copy$/, { timeout: 5000 }).then(() => true, () => false)
|
||
&& state.writes.some((w) => w.method === 'POST' && w.path === '/api/blueprints/bp_mine/copy'), page.url())
|
||
check('……然后进副本蓝图(在副本上试运行、走发布单)', /\/blueprints\/bp_mine_copy$/.test(page.url()), page.url())
|
||
check('取副本不回复待办(待办等发布单做完自动记为已发布)', !state.writes.some((w) => w.path === '/api/tasks/review/respond'))
|
||
|
||
// 已经取过副本:只认自己取的那一份
|
||
const p2 = await open({ writes: [] })
|
||
await p2.goto(`${WEB}/apps/inbox/reviewed`)
|
||
const box2 = p2.locator('[data-task-review]')
|
||
await box2.waitFor({ timeout: 15000 })
|
||
const openCopy = box2.locator('[data-task-review-open]')
|
||
check('取过副本:给「打开我的副本」,链到自己取的那一份', await openCopy.count() === 1
|
||
&& (await openCopy.getAttribute('href')) === '/blueprints/bp_daily_mine', String(await openCopy.getAttribute('href').catch(() => '')))
|
||
check('……别的处理人取的副本不算,也不再给「取副本」', await box2.locator('[data-task-review-copy]').count() === 0
|
||
&& !(await box2.innerHTML()).includes('bp_daily_other'))
|
||
|
||
// 退回:可以写原因,走待办接口
|
||
const s3 = { writes: [] }
|
||
const p3 = await open(s3)
|
||
await p3.goto(`${WEB}/apps/inbox/review`)
|
||
const box3 = p3.locator('[data-task-review]')
|
||
await box3.waitFor({ timeout: 15000 })
|
||
await box3.getByRole('textbox', { name: '退回的原因(选填)' }).fill('口径和财务的对不上')
|
||
await box3.locator('[data-task-review-reject]').click()
|
||
await p3.waitForTimeout(300)
|
||
const sent = s3.writes.find((w) => w.path === '/api/tasks/review/respond')
|
||
check('退回:decision 是 reject,带上原因', sent?.body?.decision === 'reject' && sent?.body?.note === '口径和财务的对不上', JSON.stringify(sent?.body))
|
||
check('退回后提示「已退回」', await p3.getByText('已退回').first().waitFor({ timeout: 3000 }).then(() => true, () => false))
|
||
check('退回不取副本', !s3.writes.some((w) => w.path.endsWith('/copy')))
|
||
})
|
||
|
||
await section('口径召回:三个值和差值、原答案当时的值、两个动作', async () => {
|
||
const state = { writes: [] }
|
||
const page = await open(state)
|
||
const asked = []
|
||
page.on('request', (r) => { if (r.url().includes('/api/')) asked.push(new URL(r.url()).pathname) })
|
||
await page.goto(`${WEB}/apps/inbox/recall`)
|
||
const box = page.locator('[data-task-recall="caliber"]')
|
||
await box.waitFor({ timeout: 15000 })
|
||
check('写明口径从 v1 升到 v2、同一份数据上两版各算一次', (await box.innerText()).includes('口径「周销售」从 v1 升到了 v2')
|
||
&& (await box.innerText()).includes('同一份数据'), (await box.innerText()).slice(0, 80))
|
||
check('这一组的参数', (await box.locator('[data-recall-binding-field="start"]').innerText()) === 'start=2026-09-01')
|
||
const row = box.locator('[data-recall-metric="gmv"]')
|
||
check('旧口径、新口径、两版之差一行,标「变了」', await row.getAttribute('data-changed') === 'true'
|
||
&& (await row.locator('[data-recall-cell="old"]').innerText()) === '170.50元'
|
||
&& (await row.locator('[data-recall-cell="new"]').innerText()) === '208.50元'
|
||
&& (await row.locator('[data-recall-cell="diff"]').innerText()) === '38.00元', (await row.innerText()).replace(/\s+/g, ' '))
|
||
const head = await box.locator('[data-recall-metrics] thead').innerText()
|
||
check('表头写旧口径 v1、新口径 v2、两版之差', head.includes('旧口径 v1') && head.includes('新口径 v2') && head.includes('两版之差'), head)
|
||
check('两个答案各一行,写原答案当时的值', await box.locator('[data-recall-answer]').count() === 2
|
||
&& (await box.locator('[data-recall-answer="run_ans_1"] [data-recall-original="0"]').innerText()) === '170.50元'
|
||
&& (await box.locator('[data-recall-answer="run_fm_1"] [data-recall-original="0"]').innerText()) === '165.00元')
|
||
check('问数回答链回那段对话', (await box.locator('[data-recall-answer="run_ans_1"] [data-recall-answer-link="chat"]')
|
||
.getAttribute('href')) === '/chat/conv_1')
|
||
check('正式运行(搭建者)链到记录页', (await box.locator('[data-recall-answer="run_fm_1"] [data-recall-answer-link="run"]')
|
||
.getAttribute('href')) === '/runs/run_fm_1')
|
||
const wait = (part) => page.waitForRequest((r) => r.url().includes(part), { timeout: 5000 }).then(() => true, () => false)
|
||
let hit = wait('/api/runs/rc_1/evidence/metrics/recall/diff_0')
|
||
await row.locator('[data-recall-cell="diff"]').click()
|
||
check('点差值:取召回卡上这个指标的出处链', await hit, asked.slice(-3).join(' , '))
|
||
await page.keyboard.press('Escape')
|
||
hit = wait('/api/runs/run_fm_1/evidence/metrics/card/gmv')
|
||
await box.locator('[data-recall-answer="run_fm_1"] [data-recall-original="0"]').click()
|
||
check('点定时任务那次当时的值:取那次正式运行口径卡的指标链', await hit, asked.slice(-3).join(' , '))
|
||
await page.keyboard.press('Escape')
|
||
hit = wait('snap_1')
|
||
await box.locator('[data-recall-answer="run_ans_1"] [data-recall-original="0"]').click()
|
||
check('点问数回答当时的值:打开那条查询快照', await hit, asked.slice(-3).join(' , '))
|
||
await page.keyboard.press('Escape')
|
||
await page.locator('[data-recall-reissue]').click()
|
||
await page.waitForTimeout(300)
|
||
const sent = state.writes.find((w) => w.path === '/api/tasks/recall/respond')
|
||
check('「按新版重新出具」走待办接口,decision 是 reissue', sent?.body?.decision === 'reissue', JSON.stringify(sent?.body))
|
||
check('……提示已按新版发起正式出具', await page.getByText('已按新版发起正式出具').first().waitFor({ timeout: 3000 }).then(() => true, () => false))
|
||
check('不给审批那一套按钮', await page.locator('[data-task-approve], [data-task-reject]').count() === 0)
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
|
||
const s2 = { writes: [] }
|
||
const p2 = await open(s2)
|
||
await p2.goto(`${WEB}/apps/inbox/recall`)
|
||
await p2.locator('[data-recall-ack]').waitFor({ timeout: 15000 })
|
||
await p2.locator('[data-recall-ack]').click()
|
||
await p2.waitForTimeout(300)
|
||
check('「知道了」decision 是 ack', s2.writes.find((w) => w.path === '/api/tasks/recall/respond')?.body?.decision === 'ack',
|
||
JSON.stringify(s2.writes))
|
||
})
|
||
|
||
await section('口径召回:使用者看到的遮罩值、不能重新出具的原因;目录有变那一种', async () => {
|
||
const state = { writes: [], me: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true } }
|
||
const page = await open(state)
|
||
await page.goto(`${WEB}/apps/inbox/recallUser`)
|
||
const box = page.locator('[data-task-recall="caliber"]')
|
||
await box.waitFor({ timeout: 15000 })
|
||
const row = box.locator('[data-recall-metric="spend"]')
|
||
check('遮罩值写「已遮罩」、点不开', (await row.locator('[data-recall-cell="old"]').innerText()) === '已遮罩'
|
||
&& await row.locator('[data-recall-cell="old"]').isDisabled() && (await row.locator('[data-recall-cell="new"]').innerText()) === '90元')
|
||
check('参数里的遮罩值同样遮住', (await box.locator('[data-recall-binding-field="phone"]').innerText()) === 'phone=已遮罩')
|
||
check('原答案里没有单独这个数:写「—」不给链接', (await box.locator('[data-recall-answer="run_ans_2"]').innerText()).includes('—')
|
||
&& await box.locator('[data-recall-answer="run_ans_2"] [data-recall-original]').count() === 0)
|
||
check('不能重新出具:按钮停用,写服务端给的原因', await page.locator('[data-recall-reissue]').isDisabled()
|
||
&& (await page.locator('[data-recall-reissue-reason]').innerText()).includes('请搭建者出具'))
|
||
check('使用者不给记录页的链接', await box.locator('[data-recall-answer-link="run"]').count() === 0)
|
||
|
||
const p3 = await open({ writes: [], me: state.me })
|
||
await p3.goto(`${WEB}/apps/inbox/recallIssued`)
|
||
const done = p3.locator('[data-recall-outcome="reissue"]')
|
||
await done.waitFor({ timeout: 15000 })
|
||
check('使用者重新出具过:只写已出具、正式运行由搭建者在记录页查看,不给点了回不来的记录页链接',
|
||
(await done.innerText()).includes('正式运行的记录由搭建者在记录页查看')
|
||
&& await p3.locator('[data-recall-issued]').count() === 0, (await done.innerText()).replace(/\s+/g, ' '))
|
||
const p4 = await open({ writes: [] })
|
||
await p4.goto(`${WEB}/apps/inbox/recallIssued`)
|
||
await p4.locator('[data-recall-outcome="reissue"]').waitFor({ timeout: 15000 })
|
||
check('……搭建者照样链到那次正式运行', (await p4.locator('[data-recall-outcome] [data-recall-issued]').getAttribute('href'))
|
||
=== '/runs/run_issued_9' && await p4.locator('[data-recall-issued-note]').count() === 0)
|
||
|
||
const p2 = await open({ writes: [] })
|
||
await p2.goto(`${WEB}/apps/inbox/recallCatalog`)
|
||
const basis = p2.locator('[data-recall-basis="catalog"]')
|
||
await basis.waitFor({ timeout: 15000 })
|
||
const text = await basis.innerText()
|
||
check('目录有变:写变了的目录项和新出现的问题', text.includes('列 amount 的度量类型(改了)') && text.includes('新出现的错误级问题 1 条')
|
||
&& text.includes('分组里没有业务日期'), text.replace(/\s+/g, ' '))
|
||
check('……只有「知道了」', await p2.locator('[data-recall-ack]').count() === 1 && await p2.locator('[data-recall-reissue]').count() === 0)
|
||
check('没有未捕获的运行时错误', [page, p2, p3, p4].every((x) => x.errors.length === 0),
|
||
[page, p2, p3, p4].flatMap((x) => x.errors)[0] ?? '')
|
||
})
|
||
|
||
await section('口径召回:后台的系统通知点开去待办页(不去记录页)', async () => {
|
||
// recall 待办不属于应用(app_id 为空),挂在召回运行上:记录页里没有这张卡,使用者也进不了记录页。点通知一律去待办页
|
||
const users = {
|
||
builder: null,
|
||
user: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true },
|
||
}
|
||
for (const [who, me] of Object.entries(users)) {
|
||
const state = { writes: [], notify: true, live: [], me: me ?? undefined }
|
||
const page = await open(state)
|
||
await page.goto(`${WEB}/apps`)
|
||
await page.locator('nav[aria-label="主导航"]').waitFor({ timeout: 15000 })
|
||
await page.waitForTimeout(1200)
|
||
state.live = [{ ...TASKS.recall, created_at: new Date().toISOString() }]
|
||
const noted = await page.waitForFunction(() => window.__notes.some((n) => n.tag === 'task:recall'), null, { timeout: 15000 })
|
||
.then(() => true, () => false)
|
||
check(`${who === 'user' ? '使用者' : '搭建者'}:来了 recall 待办,发系统通知`, noted,
|
||
(await page.evaluate(() => window.__notes.map((n) => n.tag))).join(','))
|
||
if (!noted) continue
|
||
await page.evaluate(() => window.__notes.find((n) => n.tag === 'task:recall').onclick())
|
||
const there = await page.waitForURL((u) => u.pathname === '/apps/inbox/recall', { timeout: 8000 }).then(() => true, () => false)
|
||
check('……点通知去这条待办的待办页', there, new URL(page.url()).pathname)
|
||
check('……待办页画出召回卡', await page.locator('[data-task-recall="caliber"]').waitFor({ timeout: 8000 })
|
||
.then(() => true, () => false))
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
}
|
||
})
|
||
|
||
await section('待办箱:列表和徽标', async () => {
|
||
const page = await open({ writes: [] })
|
||
await page.goto(`${WEB}/apps?tab=inbox`)
|
||
await page.locator('[data-inbox-task]').first().waitFor({ timeout: 15000 })
|
||
check('每条待办一行,链到详情', (await page.locator('[data-inbox-task]').count()) === Object.keys(TASKS).length)
|
||
check('审核个人应用的一行写种类「审核个人应用」', (await page.locator('[data-inbox-task="review"]').innerText()).includes('审核个人应用')
|
||
&& (await page.locator('[data-inbox-task="review"]').getAttribute('data-task-kind')) === 'review')
|
||
check('口径召回的一行写种类「口径召回」', (await page.locator('[data-inbox-task="recall"]').innerText()).includes('口径召回')
|
||
&& (await page.locator('[data-inbox-task="recall"]').getAttribute('data-task-kind')) === 'recall')
|
||
check('「应用」导航上的徽标数应用里的待办', (await page.locator('[data-apps-badge]').innerText()).trim() === String(Object.keys(TASKS).length))
|
||
check('页签上带数', /\d/.test(await page.getByRole('tab', { name: /待办/ }).innerText()))
|
||
})
|
||
|
||
await browser.close()
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
|
||
process.exit(failed ? 1 : 0)
|