This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/tools/e2e-a2.mjs
T
xcs d788140548 工作成果里摆着别单的资料:「这一单产出的」认本单碰过的行
放行存档(archiveAssetFiles)与在办列单(assetFilesOf)取行都用 taskView(where:{}) ——
那是「正式版全表 + 本单草稿」的合并视图,而资料常落在主数据上,整表里躺着别单放行过的行。
抽 assetRowsOf 两处同用,touched 为必填入参;e2e 补份数断言。
顺手清两笔:wake.test.ts 的 as unknown as(lint 红)、wake 契约的 api-report 快照(api:check 红)。
2026-08-25 15:38:10 +08:00

1105 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A1 / A2 / A3 / A4 端到端跑测 —— 验收口径:全部 released、行动被拒 0。
*
* node tools/e2e-a2.mjs a2 # 只跑 A2 三案例
* node tools/e2e-a2.mjs a3 # 只跑 A3 三案例
* node tools/e2e-a2.mjs a4 # 只跑 A4 三案例(含决策卡代答)
* node tools/e2e-a2.mjs a6 # 只跑 A6 三案例(客户回音代答 + 决策卡,三口各一条)
* node tools/e2e-a2.mjs a7 # 只跑 A7 三案例(归因代答 + 入库口径决策卡,三档各一条)
* node tools/e2e-a2.mjs a1 # 只跑 A1 回归(执行卡零输入 + 提交闸回落)
* node tools/e2e-a2.mjs e04 # 只跑 E04 三案例(两张执行单 + 决策卡审批循环)
* node tools/e2e-a2.mjs i01 # 只跑 I01 三案例(一张执行单 + 初稿审核决策卡 + 发布放行)
* node tools/e2e-a2.mjs # 全跑
* node tools/e2e-a2.mjs a2 1 # 只跑 A2 的第 1 条(0 起)
*
* 前置:./start.sh 起着(api:8787);模型实例已绑密钥。
* 跑完记得:pnpm --filter @app/api reset-runs(清跑出来的,留配置好的)。
*
* 试跑一律用 AI 账号(铁规矩) —— 起单不指派,testbed 默认派给操作者自己。
*
* **执行卡零输入**(2026-08-02 用户定):卡上没有任何表单字段,填写全在工作区的
* 件上(POST /tasks/:id/table,按地址与列名,与真人保存同一条链);交卷载荷
* `values: {}`。A2 逐颗派单:每张卡只把**当前 NOW 那一颗**的行填了再交卷。
* 必填(如 A3 底价、A1 年用量)由提交闸看住 —— 案例里先空手交一次、
* 吃到 400 才算闸真的在(expectGateBlock)。
*/
const BASE = process.env.JCAI_E2E_BASE ?? 'http://localhost:8787';
const PASSWORD = process.env.JCAI_SEED_PASSWORD ?? 'jcai-dev-2026';
/** 每套活动的案例:样例序号 + 人在件上要填的格子(按 pn 对行) */
const SUITES = {
a1: {
activityId: 'quote-intake',
address: 'quote_intake.record',
cases: [
{
sample: 1,
name: '老客户 Itelma · 缺年用量 → 提交闸拦 → 件上补 → 放行(执行卡零输入回归)',
// 缺年用量的行(哪几行缺随模型抽取略有出入)—— 空手交卷必须被服务端的闸拦下
expectGateBlock: true,
edits: [{ pn: 'DAG2400L321504XZB01-R0', eau: '30000' }],
// 兜底:凡年用量还空着的行都补上 —— 闸点名哪行缺,人就补哪行,这里替人扫一遍
fillMissing: { column: 'eau', value: '25000' },
expectPort: 'to-alternative',
},
],
},
a2: {
activityId: 'quote-alternative',
address: 'quote_alternative.list',
// 一张执行单,件里一颗一页:一次把全部待选型行的定案表存,卡交回 values:{}
blankCol: 'alt_pn',
cases: [
{
sample: 0,
name: '流内常路 · 两颗 C 逐颗派单 · 替代无历史 → 转 B 去寻源',
// 定案三格是 NOW 行的必填 —— 空手交卷必须被闸拦(400)
expectGateBlock: true,
edits: [
{
pn: 'BBPY00201209601Y00',
alt_pn: 'PBY201209T-601Y-N',
alt_brand: 'YAGEO',
alt_grade: 'P2P',
alt_diff: '五项全核(封装尺寸/引脚/电气/温度/认证),无差异',
alt_risk: '直换,免改动',
alt_verify: '免打样,首批抽检即可',
alt_note: '客户预授权替代(BOM Analog 列)',
},
{
pn: 'DAG2400L321504XZB01-R0',
alt_pn: 'ANT-2400-KLM3216',
alt_brand: 'Kinsun',
alt_grade: 'FUNC',
alt_diff: '同频段 2.4G,贴片尺寸 3216 同;馈点位置差 0.4mm',
alt_risk: '需微调 layout 馈线;整机方向图需复测',
alt_verify: '打样验证驻波比与方向图',
alt_note: '原厂 EOL,官网规格书 2026-03 版核对',
},
],
expectPort: 'to-sourcing',
},
{
sample: 1,
name: '点名替代 · 替代料库内行情稳 → 转 A 直通定价',
expectGateBlock: true,
edits: [
{
pn: 'TL072CDR',
alt_pn: 'CJTB0101R6N',
alt_brand: 'Jiangsu Changjing',
alt_grade: 'FUNC',
alt_diff: '封装同 SOIC-8,引脚兼容;压摆率低 0.5V/us',
alt_risk: '音频链路无影响;高速采样场景需评估',
alt_verify: '建议打样验证增益带宽与噪声',
alt_note: '国产替代,客户接受国产优先',
},
],
expectPort: 'to-pricing',
},
{
sample: 2,
name: '单飞宽进 · 类型需求归一 → 替代无历史转 B',
expectGateBlock: true,
// 单飞的行是 AI 归一出来的,pn 事先不知道 —— 置空表示「填当前 NOW 那一颗」
edits: [
{
pn: '',
alt_pn: 'GS8558-SR',
alt_brand: 'Gainsil',
alt_grade: 'FUNC',
alt_diff: '供电 2.7-36V 覆盖;AEC-Q100 一级;失调电压略大',
alt_risk: '外围无需改动;精密测量场景需复核失调',
alt_verify: '打样验证失调与温漂',
alt_note: '原厂官网规格书核对,版本 2025-11',
},
],
expectPort: 'to-sourcing',
},
],
},
/*
* A4 成本定价:归一算建议价 → 一张执行单交 BD **定价** → **超限才叫人拍板**(决策卡)
* → 放行。必填列是 `quoted_price`(拟报单价):**这一列 AI 一格都不写**,
* 系统只给 `suggested_price`(建议价)—— 所以三条案例都必然先吃一次 400,
* 闸真不真在这里每一单都验一遍(结构治:不靠模型分辨「这行算得出/算不出」)。
*/
a4: {
activityId: 'quote-pricing',
address: 'quote_pricing.draft',
blankCol: 'quoted_price',
cases: [
{
sample: 0,
name: '寻源转来 · 两条底价齐 · 定的价都在底价线之上(常路,不叫人拍板)',
expectGateBlock: true,
edits: [
{ pn: 'BBPY00201209601Y00', quoted_price: '0.085', price_note: '客户长单,按目标价接' },
{ pn: 'CJTB0101R6N', quoted_price: '0.42', price_note: '照建议价' },
],
expectPort: 'draft',
},
{
sample: 1,
name: '客户压价 · 定的价低于底价线两成以上 → 决策卡叫人拍板(回调到底价线)',
expectGateBlock: true,
edits: [
// 0.06 对底价线 0.0943(成本 0.082 × 1.15)—— 偏离 −36%,门禁必判拦截
{ pn: 'PESD2ETH-XR', quoted_price: '0.06', price_note: '客户拿竞对价压,先照客户价挂着' },
{ pn: 'NE555P', quoted_price: '1.05', price_note: '照建议价' },
],
// 决策卡上选哪一条:回调到底价线 —— 选项值对不上时退回「推荐项 / 第一项」
decide: 'floor',
expectDecision: true,
expectPort: 'draft',
},
{
sample: 2,
name: '单飞宽进 · 库内无基准连建议价都没有 → 价全由人定(空手交卷吃 400)',
expectGateBlock: true,
edits: [
{ pn: '', quoted_price: '14.8', price_note: '按同类电源模块经验价,毛利 18%' },
{ pn: '', quoted_price: '2.35', price_note: '按同类稳压器经验价,毛利 20%' },
],
expectPort: 'draft',
},
],
},
/*
* A6 报价议价:归一排版报价函 → 一张执行单交 BD(发出去 + 把**客户回音**落回来)
* → 逐条定案 → **还价才叫人拍板**(决策卡)→ 放行,三口的去向由谓词算出来。
*
* 必填列是 `reply_type`(客户表态):**这一列 AI 一格都不写** —— 客户回了什么
* 只有收信的人知道。所以三条案例都必然先吃一次 400,闸真不真每一单都验一遍。
* **客户回音就是靠这一手喂进去的**:脚本扮演 BD,把客户的话按地址存进件上
* (`POST /tasks/:id/table`),再零输入交卷 —— 与真人在工作区填格子同一条链。
* 三条案例覆盖三个出口:won / revise(决策卡拍板)/ lost。
*/
a6: {
activityId: 'quote-negotiate',
address: 'quote_negotiate.line',
blankCol: 'reply_type',
cases: [
{
sample: 0,
name: '流内常路 · 客户一轮全接受 → 成交转履约(不叫人拍板)',
expectGateBlock: true,
edits: [
{ pn: 'BBPY00201209601Y00', reply_type: 'ACCEPT', reply_note: '客户回:按这个价走,PO 下周发' },
{ pn: 'CJTB0101R6N', reply_type: 'ACCEPT', reply_note: '客户回:可以,一起下' },
],
expectPort: 'won',
},
{
sample: 1,
name: '客户压价两成 · 让幅超授权 → 决策卡拍板退回定价重批',
expectGateBlock: true,
edits: [
// 报价 0.098 → 客户还 0.078:让幅 20.4%,让步梯度必判 ESCALATE(超授权)
{
pn: 'PESD2ETH-XR',
reply_type: 'COUNTER',
counter_price: '0.078',
reply_note: '客户回:竞对给到 0.078,这个价才做,不然这颗给别家',
},
{ pn: 'NE555P', reply_type: 'ACCEPT', reply_note: '客户回:这颗没问题' },
],
// 决策卡上选哪一条:退回成本定价重批 —— 选项值对不上时退回「推荐项 / 第一项」
decide: 'revise',
expectDecision: true,
expectPort: 'revise',
},
{
sample: 2,
name: '单飞宽进 · D+3/D+7 两轮催问无回复 → 超时定案转情报归档',
expectGateBlock: true,
// 单飞的行是 AI 归一出来的,pn 事先不知道 —— 置空表示「填当前待填那一行」
edits: [
{ pn: '', reply_type: 'NOREPLY', reply_note: 'D+3 已催、D+7 最后一催,客户始终未表态' },
{ pn: '', reply_type: 'NOREPLY', reply_note: 'D+3 已催、D+7 最后一催,客户始终未表态' },
],
// 兜底:凡表态还空着的行都按无回复落 —— 闸点名哪行缺,人就补哪行
fillMissing: { column: 'reply_type', value: 'NOREPLY' },
expectPort: 'lost',
},
],
},
/*
* A7 情报归档:归一情报卡 → 一张执行单交 BD(逐条落**输赢归因**与竞对价)
* → 按败价幅度 A-01 算档 → **入库口径要人拍板**(决策卡)→ 放行,一口 archived。
*
* 必填列是 `cause_tag`(输赢归因):**这一列 AI 一格都不写** —— 客户为什么不下单
* 只有跟单的人知道。所以三条案例都必然先吃一次 400,闸真不真每一单都验一遍。
* 归因落 PRICE 的行还有一道**行级必填**(`competitor_price`),
* 案例一与案例三各验一遍:归因与竞对价一起存,闸才放行。
* 决策卡**每一单都发**(口径是一条对外边界,不由系统自己越过)——
* 三条案例的 `decide` 各选一档:全量 / 脱敏 / 只留内部。
*/
a7: {
activityId: 'quote-archive',
address: 'quote_archive.intel',
blankCol: 'cause_tag',
cases: [
{
sample: 0,
name: '流内失单 · 竞对压两成 → 归因 PRICE + 竞对价 → 脱敏入库',
expectGateBlock: true,
edits: [
// 报价 0.098 → 竞对 0.078:价差 20.4%,败价幅度必判 STRUCTURAL
{
pn: 'PESD2ETH-XR',
cause_tag: 'PRICE',
competitor_price: '0.078',
competitor_name: '深圳华强某代理',
evidence: '客户原话:别家给到 0.078,这个价才做',
},
{
pn: 'NE555P',
cause_tag: 'RELATION',
evidence: '客户原话:这颗一直在别家拿,一起给他了',
},
],
// 决策卡上选哪一档:脱敏入库 —— 选项值对不上时退回「推荐项 / 第一项」
decide: 'masked',
expectDecision: true,
expectPort: 'archived',
},
{
sample: 1,
name: '成交归档 · 胜在交期与既有关系 → 全量入库(竞对价那道行级闸不该拦)',
expectGateBlock: true,
edits: [
{ pn: 'BBPY00201209601Y00', cause_tag: 'LEADTIME', evidence: '客户原话:你们八周能到,别家要十四周' },
{ pn: 'CJTB0101R6N', cause_tag: 'RELATION', evidence: '客户原话:这颗一直用你们的,一起下' },
],
decide: 'full',
expectDecision: true,
expectPort: 'archived',
},
{
sample: 2,
name: '单飞宽进 · 客户始终未表态 → 归因 OTHER · 只留内部',
expectGateBlock: true,
// 单飞的行是 AI 归一出来的,pn 事先不知道 —— 置空表示「填当前待填那一行」
edits: [
{ pn: '', cause_tag: 'OTHER', evidence: 'D+3 已催、D+7 最后一催,客户始终未获反馈' },
{ pn: '', cause_tag: 'OTHER', evidence: 'D+3 已催、D+7 最后一催,客户始终未获反馈' },
],
// 兜底:凡归因还空着的行都按说不清落 —— 闸点名哪行缺,人就补哪行
fillMissing: { column: 'cause_tag', value: 'OTHER' },
decide: 'internal',
expectDecision: true,
expectPort: 'archived',
},
],
},
/*
* E04 关系覆盖与关键人经营 —— 本仓第一个非报价管线的活动,也是第一个
* **一单里有两张不同表的执行单 + 一个决策卡循环**的套件:
* S2 覆盖范围确认(件上改 crm_coverage.candidate,键 customer_code)
* → S4 经营计划审批(决策卡:approve,或先 reject 再 approve 走一轮修订循环)
* → S5 执行回执(件上填 crm_coverage.receipt 四列,键 contact_code —— AI 只写骨架,
* 回执四列一格不写,空手交卷必吃 400,闸真不真每单验一遍)
* → T_release 验收 pass → released,发口 coverage-map。
* 两张执行单表不同 → 用 `stages` 按卡名对号(老套件没有 stages,走 suite 级老路)。
* 回执行数随计划行数走(模型定几位关键人),所以不写死 edits,
* 用 `fillAllBlank` 把回执列扫齐 —— 与真人「逐行填完保存」同一条链。
* 新客户建档路(entry 卡)不进本套件,浏览器走查覆盖它。
*/
e04: {
activityId: 'crm-relationship-coverage',
address: 'crm_coverage.receipt',
blankCol: 'completion_status',
stages: [
{
match: /覆盖范围确认/,
address: 'crm_coverage.candidate',
rowKey: 'customer_code',
// AI 起草的清单必填(编码/名称)本就齐 —— 空手交卷该是 nothing-missing,不设闸断言
expectGateBlock: false,
edits: [],
},
{
match: /执行回执|回执/,
address: 'crm_coverage.receipt',
rowKey: 'contact_code',
blankCol: 'completion_status',
expectGateBlock: true,
edits: [],
fillAllBlank: {
completion_status: 'done',
result_summary: '已完成拜访与交流,关系明显推进,达成下一步动作意向',
customer_feedback: '客户反馈积极,提出了后续技术支持诉求',
key_person_change: 'unchanged',
},
},
],
cases: [
{
// 样例 0 = 满载的那条(测试台落地即预填的也是它)
sample: 0,
name: '事件驱动 · 重大商机点名 CU-0142 · 人在确认卡上加一家 · 点名驳回后通过',
/*
* 第一张审批卡以**点名意见**驳回(复刻 2026-08-24 柴浩实测:「测试111建议动作
* 改成拜访CTO」被改到了 Itelma 头上)—— 意见点的是人加的那家客户,断言改动
* 落在**被点名那一行**(expectRevised),不是落到别家。
*/
decideSeq: ['custom:端到端新增客户的建议动作改成约见CTO并建立联系', 'approve'],
expectDecision: true,
expectPort: 'coverage-map',
expectRevised: { address: 'crm_coverage.plan', where: 'customer_code', equals: 'CU-E2E-ADD', column: 'action', contains: 'CTO' },
/*
* 查无联系人的新客户 → S3 先发数据录入卡收第一位关键人(2026-08-24 用户裁定:
* 计划里不许出现占位人名 —— 从前那两行「待识别」是模型把占位句式泛化出来的)。
*/
entryValues: { name: '测试CTO王工', title: 'CTO', decision_node: 'bt-02' },
forbidInPlan: ['待识别', '-00'],
/*
* 台账更新确认那一屏补录联系人(2026-08-24 用户定):编码留空 —— 自增列不问人,
* 由基座按「客户编码-两位序号」续号;断言草稿里真长出了带号的这一行。
*/
acceptEdits: {
address: 'customer.contact',
rows: [
{
customer_code: 'CU-E2E-ADD',
contact_code: '',
name: '测试副总李工',
title: '副总经理',
decision_node: 'bt-03',
influence: 'high',
relationship: '2',
key_person: 'true',
},
],
minted: { column: 'contact_code', prefix: 'CU-E2E-ADD-' },
},
/*
* **复刻 2026-08-24 柴浩的实测**:覆盖范围确认那张卡上人加一家台账里不存在的
* 客户 —— 从前它落进了台账,而计划/回执/收口把它从头无视到尾,released 照发。
* 现在覆盖闭合(计划客户数 = 候选行数)钉在 S3 收步/S4 哨兵/放行 L2 三处,
* 这一家没进计划这一单就走不下去;expectPlanned 再显式对一遍。
*/
adds: {
address: 'crm_coverage.candidate',
rows: [
{
customer_code: 'CU-E2E-ADD',
customer_name: '端到端新增客户',
cust_type: '组装厂',
bt_level: 'bt-03',
depth: 'standard',
basis: '人工核对时额外纳入(e2e)',
},
],
},
expectPlanned: { address: 'crm_coverage.plan', column: 'customer_code', value: 'CU-E2E-ADD' },
},
{
sample: 1,
name: '月度例行 · AI 自选候选 · 计划一次通过',
decide: 'approve',
expectPort: 'coverage-map',
},
{
sample: 4,
name: '攻坚项目 T04 · 决策卡先驳回再通过(修订循环)',
decideSeq: ['reject', 'approve'],
expectDecision: true,
expectPort: 'coverage-map',
},
],
},
/*
* I01 品牌形象与销售工具包管理 —— 内容生产型:一张执行单(需求受理)+ 两道验收
* (内容初稿审核走决策卡循环、发布确认走终局放行)。没有线下执行环节,
* 所以只有一张执行单,且**人不必在件上填什么** —— AI 识别的需求清单本就齐,
* 人的活是「查漏补缺」:这里替他补一格「适用场景」,证明就地改这条链是通的。
*/
i01: {
activityId: 'brand-content-toolkit',
address: 'content_brand.requirement',
stages: [
{
match: /需求受理|内容需求/,
address: 'content_brand.requirement',
rowKey: 'req_seq',
// 内容类型是 AI 写的必填,本就不缺 —— 空手交卷该是 nothing-missing,不设闸断言
expectGateBlock: false,
edits: [],
// 人的查漏补缺:凡场景还空着的行补一个(与真人在件上改同一条链)
fillAllBlank: { scene: '攻坚' },
blankCol: 'scene',
},
],
/*
* **这一单必须真产出资料文件**(2026-08-23 用户定):每一行内容资产都要能
* 按 `域.对象/主键` 取回一份文件,并且渲得出来 —— 渲不出来就不是文件,
* 是一行标题。四种格式各自读回来的形态在 `assertAssetFiles` 里逐条对。
*/
assetFiles: { address: 'content.asset', keyCol: 'content_id', formatCol: 'file_format' },
cases: [
{
sample: 0,
name: '被动触发 · E04 请求定向素材 · 初稿一次通过',
decide: 'approve',
expectPort: 'content-published',
},
{
sample: 1,
name: '主动触发 · 月度定期内容更新 · 初稿一次通过',
decide: 'approve',
expectPort: 'content-published',
},
{
sample: 2,
name: '进入新市场(俄罗斯)· 初稿先驳回再通过(修订循环)',
decideSeq: ['reject', 'approve'],
expectDecision: true,
expectPort: 'content-published',
},
{
sample: 3,
name: '对照表 / 落地页 / 速记 —— 三种形态各挑各的格式',
decide: 'approve',
expectPort: 'content-published',
// 这一单要的就是格式分得开:一封需求里三种形态,不许一律 docx
expectFormats: 2,
},
],
},
a3: {
activityId: 'quote-sourcing',
address: 'quote_sourcing.quote_list',
cases: [
{
sample: 0,
name: '选型转来 · 替代料问价(A 行历史价预填)',
edits: [{ pn: 'BBPY00201209601Y00', supplier: '深圳华强渠道', base_price: '0.058', moq: '3000', source_lead_time: '6周', note: '两家比价取低' }],
expectPort: 'quotation',
},
{
sample: 1,
name: '受理直达 · 两颗 B 类问价',
edits: [
{ pn: 'PESD2ETH-XR', supplier: 'Nexperia 代理商', base_price: '0.112', moq: '10000', source_lead_time: '8周', note: '' },
{ pn: 'NE555P', supplier: 'TI 现货商', base_price: '0.85', moq: '2500', source_lead_time: '4周', note: '现货实拍已核' },
],
expectPort: 'quotation',
},
{
sample: 2,
name: '单飞宽进 · 正文列料问底价(空手交卷先吃 400 —— 闸在)',
// 底价是必填列:没填之前交卷必须被拦;填齐两行再交
expectGateBlock: true,
edits: [
{ pn: '', supplier: '现货商甲', base_price: '0.05', moq: '3000', source_lead_time: '现货', note: '库存实拍已核' },
{ pn: '', supplier: '现货商乙', base_price: '0.21', moq: '1000', source_lead_time: '现货', note: '库存实拍已核' },
],
expectPort: 'quotation',
},
],
},
};
let cookie = '';
async function api(path, init = {}) {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { 'content-type': 'application/json', cookie, ...(init.headers ?? {}) },
});
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = text;
}
if (!res.ok) {
const err = new Error(`${init.method ?? 'GET'} ${path} → ${res.status}: ${text.slice(0, 300)}`);
err.status = res.status;
throw err;
}
return { res, body };
}
async function login() {
const res = await fetch(`${BASE}/api/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ account: 'AI', password: PASSWORD }),
});
if (!res.ok) throw new Error(`登录失败:${res.status} ${await res.text()}`);
const raw = res.headers.get('set-cookie') ?? '';
cookie = raw.split(';')[0] ?? '';
if (!cookie.includes('jcai_session')) throw new Error(`没拿到会话 cookie:${raw}`);
}
async function loadSample(activityId, index) {
const { readFile } = await import('node:fs/promises');
const manifest = JSON.parse(await readFile(new URL(`../activities/${activityId}/dist/manifest.json`, import.meta.url), 'utf8'));
const sample = manifest.samples[index];
if (!sample) throw new Error(`${activityId} 没有第 ${index} 条样例`);
return sample;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** 被拒的行动 —— 轨迹里认「行动被拒」的痕(标题形状照 turn-runner 的拒回) */
function rejectedCount(trail) {
return trail.filter((t) => t.kind === 'action-rejected' || /行动被拒|被拒[::]|拒绝执行/.test(t.title ?? '')).length;
}
/** 草稿层的行(带主键),按台账地址取 */
function draftRows(drafts, address) {
return (drafts ?? []).filter((d) => d.address === address).map((d) => ({ ...d.key, ...d.patch }));
}
/** 交卷执行卡 —— 载荷零字段(执行卡上没有任何输入,填写全在件上) */
async function submitExecution(cardId) {
await api('/api/desk/work-orders/reply', {
method: 'POST',
body: JSON.stringify({ reply: { mode: 'execution', workOrderId: cardId, values: {} } }),
});
}
/** 空手交卷,期望被提交闸拦下(400)—— 闸不在才是要报的错 */
/**
* 空手交卷该不该被拦 —— 返回 `'blocked'` / `'nothing-missing'` / `'leaked'`。
*
* **闸只在真有缺项时才该出手**(2026-08-03 实测逼出来的区分):
* 这条断言从前写成「必须吃 400」,于是模型碰巧把该填的格子全填上的那些轮
* (A1 把缺的年用量补齐了、A2 客户点名的替代料型号本来就在来件里)一律被判成
* 「提交闸失效」—— 而闸好端端的,拿一单直接空手交卷实测吃的就是 400。
* **断言要分清「闸没拦」与「压根没东西可拦」**,否则它报的失败是假的,
* 真失效那天反而没人信它。
*/
async function expectGateBlocks(cardId, taskId, stage) {
try {
await submitExecution(cardId);
} catch (error) {
if (error.status === 400) return 'blocked';
throw error;
}
/*
* 没被拦:去数**这一单该填的那一列**还空着几格(用与 buildEdits 同一份草稿、
* 同一个列名 —— 两处认的必须是同一件事,否则这条断言自己就会说谎)。
* 一格都不空 = 模型这一轮把该填的全填上了,闸无需出手;还空着却放过去 = 真失效。
*/
const column = stage.blankCol ?? stage.fillMissing?.column;
if (column === undefined) return 'nothing-missing';
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
const rows = draftRows(body.drafts ?? [], stage.address);
const blank = rows.filter((r) => String(r[column] ?? '').trim() === '').length;
return blank > 0 ? 'leaked' : 'nothing-missing';
}
/**
* 决策卡 —— 它是**对话卡**,不在试跑观测面的工单摞里(那一摞只有执行/验收),
* 要从工作台的任务详情取:`chat` 里 `k === 'decision'` 且还没 `chosen` 的那几张。
*
* 选项按案例声明的 value 认;认不上退「推荐项 → 第一项」——
* 代答脚本不该因为模型换了个选项名就判活动失败。
*/
async function answerDecisions(taskId, kase, answered) {
const { body } = await api(`/api/desk/tasks/${taskId}`);
let count = 0;
for (const card of body.chat ?? []) {
if (card.k !== 'decision' || card.chosen !== undefined || answered.has(card.id)) continue;
const options = card.options ?? [];
if (options.length === 0) continue;
/*
* `decideSeq` 按张走(E04 审批循环:第一张驳回、修订后的第二张通过);
* 序列走完取末项兜底。没有 decideSeq 的老套件仍走单值 `decide`。
*/
const want = kase.decideSeq === undefined ? kase.decide : kase.decideSeq[Math.min(answered.size, kase.decideSeq.length - 1)];
// 'custom:…' = 写具体意见驳回(与人在输入框里写一句话同一条链)
const custom = typeof want === 'string' && want.startsWith('custom:');
const picked = custom
? { value: want, label: `意见:${want.slice(7)}` }
: (options.find((o) => o.value === want) ?? options.find((o) => o.recommended === true) ?? options[0]);
await api('/api/desk/chat-cards/reply', {
method: 'POST',
body: JSON.stringify({ reply: { k: 'decision', cardId: card.id, value: picked.value, note: custom ? want.slice(7) : '试跑代答' } }),
});
answered.add(card.id);
count += 1;
console.log(` 决策卡拍板:${card.ask?.slice(0, 40) ?? ''} → ${picked.label ?? picked.value}`);
}
return count;
}
/**
* 数据录入卡代答(E04 查无联系人的候选:S3 先收第一位关键人再排计划)。
* 人确认那一下基座直写草稿 —— 这里只按案例声明的 entryValues 把表单交回去。
*/
async function answerEntries(taskId, kase, answered) {
if (!kase.entryValues) return 0;
const { body } = await api(`/api/desk/tasks/${taskId}`);
let count = 0;
for (const card of body.chat ?? []) {
if (card.k !== 'entry' || card.chosen !== undefined || answered.has(card.id)) continue;
await api('/api/desk/chat-cards/reply', {
method: 'POST',
body: JSON.stringify({ reply: { k: 'entry', cardId: card.id, values: kase.entryValues } }),
});
answered.add(card.id);
count += 1;
console.log(` 录入卡代答:${(card.ask ?? '').slice(0, 40)} → ${JSON.stringify(kase.entryValues)}`);
}
return count;
}
/**
* 把案例声明的格子对齐成保存载荷。行键随套件走(`rowKey`,老套件默认 `pn`):
* 键为空串 = 按序发给待填行;`fillMissing` 单列兜底扫空;
* `fillAllBlank` 整行兜底扫空(回执这类「几行由模型定、每行要填一把」的表用它)。
*/
function buildEdits(stage, drafts) {
const rows = draftRows(drafts, stage.address);
const rowKey = stage.rowKey ?? 'pn';
const blankCol = stage.blankCol ?? 'base_price';
const pending = rows.filter((r) => String(r[blankCol] ?? '') === '');
const lines = [];
const taken = new Set();
for (const [i, edit] of (stage.edits ?? []).entries()) {
const { [rowKey]: keyed, ...cells } = edit;
const target = keyed !== '' ? rows.find((r) => String(r[rowKey]) === keyed) : pending[i];
if (!target) {
console.log(` ⚠️ 找不到要填的行(${rowKey}=${keyed || `待填第${i}行`});现有行:${rows.map((r) => r[rowKey]).join(', ')}`);
continue;
}
taken.add(String(target[rowKey]));
lines.push({ [rowKey]: String(target[rowKey]), ...cells });
}
if (stage.fillMissing) {
const { column, value } = stage.fillMissing;
for (const r of rows) {
if (String(r[column] ?? '') === '' && !taken.has(String(r[rowKey]))) {
lines.push({ [rowKey]: String(r[rowKey]), [column]: value });
}
}
}
if (stage.fillAllBlank) {
for (const r of rows) {
if (String(r[blankCol] ?? '') === '' && !taken.has(String(r[rowKey]))) {
lines.push({ [rowKey]: String(r[rowKey]), ...stage.fillAllBlank });
}
}
}
return lines;
}
/**
* **资料文件真产出了没有** —— 逐行按台账地址取回来,看它渲成什么。
*
* 断的是三件事,少一件都算没做到:
* ① 每一行都取得回一份文件(取不回 = 那一份根本不存在);
* ② 格式列说的是什么,读回来就该是什么形态(docx→word / xlsx→excel / md→markdown / html→html);
* ③ 里头**真有内容**(word 有块、excel 有行、md/html 有字)——
* 一份空壳也能"渲染成功",那种成功没有意义。
*/
async function assertAssetFiles(taskId, spec) {
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
const rows = draftRows(body.drafts ?? [], spec.address);
if (rows.length === 0) return { ok: false, note: '一行内容资产都没写下 —— 谈不上产出资料' };
const KIND = { docx: 'word', xlsx: 'excel', md: 'markdown', html: 'html' };
const seen = new Set();
for (const row of rows) {
const key = String(row[spec.keyCol] ?? '');
const format = String(row[spec.formatCol] ?? '');
if (key === '') return { ok: false, note: `有一行没有主键(${spec.keyCol} 空着),件上点不开` };
if (!(format in KIND)) return { ok: false, note: `${key} 的格式是「${format}」—— 不在 md/html/docx/xlsx 里` };
let got;
try {
got = await api(`/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(spec.address)}&key=${encodeURIComponent(key)}`);
} catch (error) {
return { ok: false, note: `${key}(${format})取不回来:${String(error.message).slice(0, 120)}` };
}
const doc = got.body.file;
if (doc?.kind !== KIND[format]) return { ok: false, note: `${key} 说好是 ${format},读回来却是 ${doc?.kind}` };
const empty =
(doc.kind === 'word' && (doc.blocks ?? []).length === 0) ||
(doc.kind === 'excel' && (doc.rows ?? []).length === 0) ||
(doc.kind === 'markdown' && (doc.md ?? '').trim().length < 20) ||
(doc.kind === 'html' && (doc.html ?? '').length < 200);
if (empty) return { ok: false, note: `${key}(${format})是一份空壳 —— 渲得出形状,里头没内容` };
// 原字节也得取得到:下载到的是真 .docx / .xlsx
const raw = await fetch(`${BASE}/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(spec.address)}&key=${encodeURIComponent(key)}&raw=1`, { headers: { cookie } });
const bytes = new Uint8Array(await raw.arrayBuffer());
if (!raw.ok || bytes.length < 40) return { ok: false, note: `${key} 的原字节取不到(${raw.status})` };
if ((format === 'docx' || format === 'xlsx') && !(bytes[0] === 0x50 && bytes[1] === 0x4b)) {
return { ok: false, note: `${key} 的 .${format} 不是 ZIP 包(Word/Excel 打不开)` };
}
seen.add(format);
}
return { ok: true, formats: seen, count: rows.length, note: `${rows.length} 份资料全部取得回、渲得出(格式:${[...seen].join(' / ')})` };
}
/**
* 收工之后再看一眼:那几份资料**存档了没有、点得开吗**。
* 查的是工作台任务详情里那一摞(`assetFiles`,工作成果那一屏摆的就是它),
* 逐份按同一条路取回来 —— 与人在界面上点它是同一个接口。
*
* **份数也得对上**(2026-08-25 柴浩:「工作成果怎么有 4 个资料,这单只会产出一个」)——
* 资料落在主数据上,整表里躺着历次跑出来的资产;从前存档拿的是整表,
* 于是这一摞里混着别单的成果。多一份也是错:签字的人会以为那是他交出去的东西。
*/
async function assertArchived(taskId, expected) {
const { body } = await api(`/api/desk/tasks/${taskId}`);
const files = body.assetFiles ?? [];
if (files.length === 0) return { ok: false, note: '工作成果里一份资料都没有 —— 放行时没存档' };
const live = files.filter((f) => !f.archived);
if (live.length > 0) return { ok: false, note: `${live.length} 份还标着「在办中」—— 放行那一刻没冻住` };
if (expected !== undefined && files.length !== expected) {
return {
ok: false,
note: `工作成果里 ${files.length} 份,而这一单只产出 ${expected} 份 —— 多出来的是别单的成果(${files.map((f) => f.title).join(' / ')})`,
};
}
for (const f of files) {
const url = `/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(f.address)}&key=${encodeURIComponent(f.key)}`;
let got;
try {
got = await api(url);
} catch (error) {
return { ok: false, note: `${f.name} 存档里取不回来:${String(error.message).slice(0, 100)}` };
}
if (got.body.file?.kind === undefined) return { ok: false, note: `${f.name} 取回来是个空壳` };
const raw = await fetch(`${BASE}${url}&raw=1`, { headers: { cookie } });
const bytes = new Uint8Array(await raw.arrayBuffer());
if (!raw.ok || bytes.length < 40) return { ok: false, note: `${f.name} 的原件取不到(${raw.status} / ${bytes.length}B)` };
if ((f.format === 'docx' || f.format === 'xlsx') && !(bytes[0] === 0x50 && bytes[1] === 0x4b)) {
return { ok: false, note: `${f.name} 存档里那份不是 ZIP 包` };
}
}
return { ok: true, note: `${files.length} 份都已存档、逐份点得开(工作成果那一摞)` };
}
async function runCase(suite, kase) {
const sample = await loadSample(suite.activityId, kase.sample);
console.log(`\n▶ ${suite.activityId} · ${kase.name}`);
const { body: started } = await api('/api/gov/testbed/run-activity', {
method: 'POST',
/*
* **e2e 显式关掉「落真库」** —— 起单接口默认是开的(人手点那条路要看见数据真变),
* 而这道保护正是为 e2e 设的:A1 的「新客户来函」样例一旦把客户真写进主数据,
* 第二次跑那条路就成了老客户,用例从此测的不是它要测的东西。
* **要保护的那个调用方自己说出来**,别指望一个默认值替所有人做决定。
*/
body: JSON.stringify({
activityId: suite.activityId,
data: sample.input,
message: sample.message ?? '',
ownerId: '',
landMaster: false,
}),
});
const taskId = started.taskId;
console.log(` 起单 ${taskId}`);
const replied = new Set();
const retries = new Map();
const decided = new Set();
// 录入卡的已答名单单独记 —— 与 decided 共用会把 decideSeq 按 size 取的序号顶掉(实测:点名驳回被跳过)
const entered = new Set();
let gateBlocked = false;
/** 资料文件那一次检查的结论(在办中验,见下面那段注释) */
let fileCheck;
let gateLeaked = false;
const gateChecked = new Set();
let decisions = 0;
/** 人加行只加一次;加的那家进没进计划,在办中盯(放行会把草稿消费掉,收工再看就晚了) */
let addedRows = false;
let planned = false;
let revised = false;
let acceptEdited = false;
let minted = false;
let forbidHit = false;
let bailed = false;
let receiptChecked = false;
let receiptMismatch = false;
const deadline = Date.now() + 20 * 60 * 1000;
let last;
/*
* 这张执行卡对应哪一套「表 + 键 + 该填什么」—— 一单里有两张不同表的执行单
* (E04:候选清单 / 执行回执)按卡名对号;老套件没有 stages,整单一套。
*/
const stageOf = (card) => {
if (suite.stages === undefined) {
return {
address: suite.address,
rowKey: suite.rowKey,
blankCol: kase.blankCol ?? suite.blankCol,
edits: kase.edits ?? [],
fillMissing: kase.fillMissing,
expectGateBlock: kase.expectGateBlock === true,
};
}
const hit = suite.stages.find((s) => s.match.test(card.title ?? ''));
if (hit === undefined) console.log(` ⚠️ 认不出这张执行卡属于哪一段:${card.title}`);
return hit;
};
while (Date.now() < deadline && !bailed) {
await sleep(3000);
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
last = body;
if (body.terminal) break;
// 熔断卡 = 这一条案例已经失败 —— 别去答它,留着轨迹好查
const stalled = (body.cards ?? []).find((c) => !c.done && /推不动了/.test(c.title ?? ''));
if (stalled) {
console.log(` ✗ 熔断交人:${stalled.title}`);
last = body;
break;
}
/*
* **资料文件要在「人正在审」那一刻验** —— 而不是放行之后。
*
* 放行那一下草稿被消费掉,而 `content.asset` 是主数据:**试跑一律不落正式表**
* (免得试跑往品牌内容库里塞真资产),于是收工再去找,一行都找不着 ——
* 第一版就是这么误报"一行内容资产都没写下"的,而那一单资料写得好好的。
* 人点资料名恰恰发生在审核那一屏,这里验的就是他那一刻点得开点不开。
*/
/*
* 时机:**等到控制权真的交回人手上那一刻**(有卡等着答),而不是「一见到有行就查」——
* 后者会撞上模型还在逐行写的中途,查到 2 份就收工,而这一单其实产了 3 份
* (实测撞见过一次)。人点资料名恰恰发生在有卡等他的时候,查的就该是那一刻。
*/
const handedOff =
(body.cards ?? []).some((c) => !c.done) ||
((await api(`/api/desk/tasks/${taskId}`)).body.chat ?? []).some((c) => c.k === 'decision' && c.chosen === undefined);
if (suite.assetFiles && fileCheck === undefined && handedOff && (body.drafts ?? []).some((d) => d.address === suite.assetFiles.address)) {
fileCheck = await assertAssetFiles(taskId, suite.assetFiles);
console.log(` 资料文件:${fileCheck.note}`);
}
if (kase.expectPlanned && !planned) {
const { address, column, value } = kase.expectPlanned;
planned = draftRows(body.drafts ?? [], address).some((r) => String(r[column] ?? '') === value);
}
if (kase.expectRevised && !revised) {
const { address, where, equals, column, contains } = kase.expectRevised;
revised = draftRows(body.drafts ?? [], address).some(
(r) => String(r[where] ?? '') === equals && String(r[column] ?? '').includes(contains),
);
}
// 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的
decisions += await answerDecisions(taskId, kase, decided);
await answerEntries(taskId, kase, entered);
// 计划里不许出现的字样(占位人名等)—— 一出现就记下,收工判失败
if (kase.forbidInPlan && !forbidHit) {
const planRows = draftRows(body.drafts ?? [], 'crm_coverage.plan');
forbidHit = planRows.some((r) => Object.values(r).some((v) => kase.forbidInPlan.some((w) => String(v ?? '').includes(w))));
}
for (const card of body.cards ?? []) {
if (card.done || replied.has(card.id)) continue;
if (card.mode === 'execution') {
/*
* 「需要你判断下一步」是引擎的兜底卡(模型两次输出不成形 / 决断不了)——
* 它不属于任何一段,也不会自己好起来;从前每轮打一行警告空转到 20 分钟才超时。
* 认出来就当场收工判失败,把轨迹末尾摆出来尸检。
*/
if (/需要你判断下一步/.test(card.title ?? '')) {
console.log(` ⚠️ 模型交回兜底卡(${card.title})—— 这一单到此判失败`);
bailed = true;
break;
}
const stage = stageOf(card);
if (stage === undefined) continue;
// 回执单派出那一刻,骨架必须与计划一一对应(2026-08-24:计划 7 回执 6 的实测账)
if (stage.address === 'crm_coverage.receipt' && !receiptChecked) {
receiptChecked = true;
const planN = draftRows(body.drafts ?? [], 'crm_coverage.plan').length;
const rcptN = draftRows(body.drafts ?? [], 'crm_coverage.receipt').length;
if (planN !== rcptN) {
console.log(` ⚠️ 回执骨架 ${rcptN} 行 ≠ 计划 ${planN} 行 —— 派单前没对齐,判失败`);
receiptMismatch = true;
} else console.log(` 回执骨架与计划一一对应(${planN} 行)`);
}
if (stage.expectGateBlock === true && !gateChecked.has(stage.address)) {
gateChecked.add(stage.address);
const verdict = await expectGateBlocks(card.id, taskId, stage);
if (verdict === 'leaked') gateLeaked = true;
else gateBlocked = true;
console.log(
verdict === 'blocked'
? ` 闸拦住空手交卷(400)—— 如设计`
: verdict === 'nothing-missing'
? ` 这一轮该填的格子模型全填上了,闸无需出手(不是失效)`
: ` ⚠️ 缺着必填还放过去了:提交闸失效`,
);
}
const lines = buildEdits(stage, body.drafts ?? []);
// 人加行:与真人在件上「加一行」同一条链 —— 新主键的行落进保存载荷即是新增
if (kase.adds && kase.adds.address === stage.address && !addedRows) {
lines.push(...kase.adds.rows);
addedRows = true;
console.log(` 件上加 ${kase.adds.rows.length} 行(${kase.adds.rows.map((r) => Object.values(r)[0]).join('、')})`);
}
if (lines.length > 0) {
await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST',
body: JSON.stringify({ tables: { [stage.address]: lines } }),
});
console.log(` 件上保存 ${lines.length} 行(${card.title})`);
}
try {
await submitExecution(card.id);
} catch (error) {
if (error.status !== 400) throw error;
/*
* **「已回传」不是「闸拦着」** —— 两种 400 长得一样,处置正相反。
* 这一单交过了(卡还挂在开着的名单里多半是投影慢了一拍),该记下交过、往下走;
* 当成闸拦着就会原样再交一遍,再吃一个 400,三次判失败 ——
* 实测把一单 released 的 A1 记成了失败(2026-08-03)。
*/
if (/already-answered|已回传/.test(String(error.message))) {
console.log(` 这张卡已经交过了,接着往下走(${card.title})`);
replied.add(card.id);
continue;
}
// 闸还拦着(保存后仍有缺)—— 下一轮按最新草稿再补再交,连拦三次判失败
retries.set(card.id, (retries.get(card.id) ?? 0) + 1);
console.log(` 闸仍拦着(第 ${retries.get(card.id)} 次):${String(error.message).slice(0, 160)}`);
if ((retries.get(card.id) ?? 0) >= 3) throw error;
continue;
}
console.log(` 交卷执行卡:${card.title}`);
replied.add(card.id);
} else if (card.mode === 'acceptance') {
if (kase.acceptEdits && !acceptEdited) {
acceptEdited = true;
const { address, rows, minted: mintSpec } = kase.acceptEdits;
await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST',
body: JSON.stringify({ tables: { [address]: rows } }),
});
const after = await api(`/api/gov/testbed/activity/${taskId}`);
/*
* 按**我们填的姓名**找行,不能只看编码前缀:错配覆盖的那次(2026-08-24),
* 模型自己建的 CU-E2E-ADD-01 也满足前缀 —— 断言过了,而人加的行其实把它顶掉了。
* 名对上 + 号续上,才是「真加了一行」。
*/
const mineRow = rows[0] ?? {};
const got = draftRows(after.body.drafts ?? [], address).find(
(r) => String(r['name'] ?? '') === String(mineRow['name'] ?? '') && String(r[mintSpec.column] ?? '').startsWith(mintSpec.prefix),
);
minted = got !== undefined;
console.log(
minted
? ` 确认屏上补一行联系人 → 基座续号 ${got[mintSpec.column]}`
: ` ⚠️ 确认屏上补的联系人行没落下(或编码没续上)`,
);
}
await api('/api/desk/work-orders/reply', {
method: 'POST',
body: JSON.stringify({ reply: { mode: 'acceptance', workOrderId: card.id, verdict: 'pass', values: {} } }),
});
console.log(` 验收通过:${card.title}`);
replied.add(card.id);
}
}
}
const rejects = rejectedCount(last?.trail ?? []);
const fired = (last?.trail ?? []).find((t) => t.kind === 'release-verdict' && t.detail?.ports);
const ports = fired?.detail?.ports ?? [];
let ok = !bailed && last?.terminal === 'released' && rejects === 0;
console.log(
` → ${last?.terminal ?? '超时'}${last?.reason ? `(${last.reason})` : ''} · 被拒 ${rejects} · ` +
`${last?.used?.steps ?? '?'} 拍 / ${Math.round((last?.used?.tokens ?? 0) / 1000)}k token · ` +
`决策卡 ${decisions} 张 · 发口 [${ports.join(', ')}]`,
);
if (gateLeaked || (kase.expectGateBlock && !gateBlocked)) {
console.log(' ⚠️ 提交闸没拦住空手交卷 —— 判失败');
ok = false;
}
// 「超限才叫人」是 A4 的要害:该叫的那一单没叫,等于底价门禁是摆设
if (kase.expectDecision && decisions === 0) {
console.log(' ⚠️ 超限条目没走决策卡 —— 判失败');
ok = false;
}
if (kase.expectPort && !ports.includes(kase.expectPort)) {
console.log(` ⚠️ 期望口 ${kase.expectPort} 未在发射名单里`);
}
if (kase.expectPlanned && !planned) {
console.log(` ⚠️ 人加的那家(${kase.expectPlanned.value})没进经营计划 —— 新增行又被无视了,判失败`);
ok = false;
}
if (kase.expectRevised && !revised) {
console.log(` ⚠️ 点名驳回的改动没落在被点名那一行(${kase.expectRevised.equals})—— 归属又认错了,判失败`);
ok = false;
}
if (kase.acceptEdits && !minted) {
console.log(' ⚠️ 确认屏补录的联系人没拿到基座续的号 —— 判失败');
ok = false;
}
if (receiptMismatch) ok = false;
if (forbidHit) {
console.log(` ⚠️ 计划里出现了不该有的字样(${(kase.forbidInPlan ?? []).join('/')})—— 占位人名又回来了,判失败`);
ok = false;
}
if (suite.assetFiles) {
if (fileCheck === undefined) {
console.log(' ⚠️ 整单跑完都没等到一行内容资产 —— 这一步没产出资料');
ok = false;
} else if (!fileCheck.ok) {
ok = false;
} else if (kase.expectFormats && (fileCheck.formats?.size ?? 0) < kase.expectFormats) {
console.log(` ⚠️ 一封需求里三种形态,却只出了 ${[...(fileCheck.formats ?? [])].join('/')} —— 格式没分开`);
ok = false;
}
/*
* **放行之后还得在** —— 工作成果恰恰是收工才去看的地方。
* 资料落在主数据上,而试跑单放行时主数据不落正式表:不存档的话这一刻全是 404
* (这正是 2026-08-23 之前的实情)。所以这条断言查的是**存档**,不是台账。
*/
const archived = await assertArchived(taskId, fileCheck?.count);
console.log(` 资料存档:${archived.note}`);
if (!archived.ok) ok = false;
}
if (!ok) {
console.log(' —— 轨迹末 12 条 ——');
for (const t of (last?.trail ?? []).slice(-12)) console.log(` [${t.kind}] ${t.title}`);
}
return ok && (!kase.expectPort || ports.includes(kase.expectPort));
}
async function main() {
const which = process.argv[2] ?? 'all';
const only = process.argv[3] === undefined ? undefined : Number(process.argv[3]);
await login();
const suites = which === 'all' || which === 'both' ? ['a1', 'a2', 'a3', 'a4', 'a6', 'a7', 'e04', 'i01'] : [which];
let pass = 0;
let total = 0;
for (const key of suites) {
const suite = SUITES[key];
if (!suite) throw new Error(`没有这套:${key}`);
for (const [i, kase] of suite.cases.entries()) {
if (only !== undefined && i !== only) continue;
total += 1;
if (await runCase(suite, kase)) pass += 1;
}
}
console.log(`\n${pass}/${total} 通过;记得 pnpm --filter @app/api reset-runs 清运行数据`);
process.exit(pass === total ? 0 : 1);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});