mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:02:00 +00:00
335dc99c3e
为什么:前几个提交改了驳回的终态、选人控件的候选、自由形蓝图的「谁来做」,浏览器检查里的伪造数据和断言要守住新说法 (05-已知遗留 (a)(b)(d))。check-decode 的一节已在驳回前端那个提交里。 做了什么: - check-apps:伪造接口加 /api/directory/candidates(按段里给的池答)和 /api/directory(使用者 403);新段「入口:使用者选人」 ——边输入边按入口和字段取候选、列表里是池里的人、不去取整份名录、不在池里当场说清、选中的写显示名悬停给用户名;新段「实例页: 被驳回」——状态照服务端写「「付款」未确认(被驳回)」、不写失败、那一步写未确认(被驳回)。 - check-inbox:新待办「指定复核人」(补充输入里有选人控件);按 task_id 和字段问候选、不取整份名录;服务端 422 code=principal 挂回选人字段。 - check-personal:提交审核选人按 purpose=review 取候选,不是搭建者的名字当场说清。 - check-blueprint:自由形模板的 cards 加 people;新段「自由形:谁来做」——按 derived_people 摆三行(岗位、核实人、只读的发起人)、 没绑的标缺、问题归在这张卡片上、只读行没有修改、不能加减步骤、选人发 bp_set 到那一行的 path。 验证:check-all --stacks 1 跑 decode、apps、inbox、personal、blueprint(1030 项)和 ui-kit、chat、runs、stream(1982 项),全部通过。
844 lines
61 KiB
JavaScript
844 lines
61 KiB
JavaScript
// 应用使用面(/apps、/apps/:appId、/apps/:appId/p/:pageId、/apps/:appId/new/:entryId、/apps/:appId/i/:runId)的回归检查。
|
||
//
|
||
// 守的是这些「看着没坏、其实不对」的地方:
|
||
// - 应用首页三个页签(待办 / 应用 / 我发起的)记在地址上;关登录时挂一行说明(所有人都能用全部功能,签批人是自报署名);
|
||
// 需升级的应用照实标出来;
|
||
// - 应用页按视图模型渲染:印章四项(截至 / 口径 / 出具档位 / 来源)和服务端标出的错位照写;指标块的数是按钮,点开走
|
||
// 指标链接口(GET /runs/{id}/evidence/metrics/{node}/{metric}),面板里有指标步骤;没有出处的数照实标「无证据」;
|
||
// 趋势是自绘 SVG,升版不可比的地方画断开线并写明;「查看数据」给出表格视图;表格点表头排序、遮罩列写「已遮罩」;
|
||
// 不认识的组件写「此组件需要更新前端」,不报错、不空白;服务端解析出错的组件(problems)列在页面顶上;
|
||
// 有视图在生成时自动重取;
|
||
// - 入口页:必填没填本地拦下、错误挂到字段下面;服务端 422 的 {errors:[{path, code, message}]} 按 path 挂回字段,
|
||
// 对不上字段的列在表单顶上;⌘/Ctrl+Enter 发起;成功后进实例页;需升级时不让发起;使用者在选人控件里边输入边按
|
||
// 候选池取候选(GET /api/directory/candidates,只有名字),不在池里的当场说清,不去取整份名录(05-已知遗留 (b));
|
||
// - 实例页:状态(服务端给的说法:结果未知写「等待核实写入结果」)、印章、进度(结果未知的那一步不写「完成」)、
|
||
// 本实例的待办卡(就地处理);不是处理人的待办表单只画这一步的状态、不去取待办;动作状态(哈希前 8 位、环境;
|
||
// 结果未知的经过记一笔,不当失败原因);被驳回的实例照服务端的说法写「「付款」未确认(被驳回)」,不写失败(05-已知遗留 (a));
|
||
// - 窄屏(390px)不出横向滚动;
|
||
// - 页面上直接改(normal-user-apps N6):有 manage 的人点「编辑」进编辑状态(地址记 edit=1),按草稿解析;点组件是选中、
|
||
// 不打开出处;小工具条改文字只给目录标了 x-text 的属性;隐藏、上移、删除都自动存草稿(PATCH 带整份草稿)后按草稿
|
||
// 重新解析;草稿里隐藏的组件画成半透明,服务端没画出来的列在页底能取消隐藏;「加一张数据卡片」按类别分组、候选 id
|
||
// 撞了换新的;顶部写「有未发布的改动」,「发布」走应用发布;应用出自蓝图时给「改取数」,链到蓝图页。
|
||
//
|
||
// 页面是真页面(走真的应用外壳),/api/apps/**、/api/tasks/**、指标链接口在浏览器层答掉,数据是这里虚构的
|
||
// (「门店周报」「报销审批」);其余 GET 放行到服务端(外壳要它),写请求一律拦下、记在探针里,不碰真实写接口。
|
||
//
|
||
// node scripts/check-apps.mjs
|
||
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-apps.mjs
|
||
// CHECK_ONLY=首页,入口 只跑段名里含这些字的段。
|
||
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 RUN = 'run_week_14'
|
||
|
||
// ---------------------------------------------------------------- 假数据
|
||
|
||
const APPS = [
|
||
{ id: 'app_weekly', name: '门店周报', description: '每周一看各门店的销售概况', icon: 'app', status: 'active',
|
||
published_version: 3, level: 'governed', needs_upgrade: false, pending_tasks: 0, my_role: 'manage', updated_at: ago(3600_000) },
|
||
{ id: 'app_claim', name: '报销审批', description: '员工发起报销,财务会签后付款', icon: 'app', status: 'active',
|
||
published_version: 2, level: 'published', needs_upgrade: true, pending_tasks: 1, my_role: 'use', updated_at: ago(7200_000) },
|
||
]
|
||
|
||
const STAMP = {
|
||
as_of: ago(86400_000), as_of_text: '截至 2026-10-13 10:05;统计期 week 2026-W41', pin: '周销售 v7', graph_hash: '3fa2c1d0',
|
||
calibers: [{ name: '周销售口径', version: 'v3' }], tier: 'formal', tier_text: '完整出具',
|
||
from: '定时任务「周一 10 点」第 14 次', sealed: true, notes: ['定时任务已改为运行 v8,本应用固定在 v7'],
|
||
}
|
||
|
||
const metricEv = (metric) => ({ run_id: RUN, kind: 'metric', node: 'kpi', metric, eid: `ev:metric:${metric}` })
|
||
const point = (i, value) => ({
|
||
run_id: `run_w${i}`, as_of: new Date(Date.UTC(2026, 8, 1 + i * 7)).toISOString(), value, text: `${value} 万元`,
|
||
evidence: { run_id: `run_w${i}`, kind: 'metric', node: 'kpi', metric: 'gmv', eid: `ev:${i}` },
|
||
})
|
||
|
||
const HOME = {
|
||
format: 'agentlab.view/1',
|
||
app: { id: 'app_weekly', name: '门店周报', version: 3, level: 'governed', needs_upgrade: false },
|
||
page: { id: 'home', title: '本周概览' },
|
||
pages: [{ id: 'home', title: '本周概览' }, { id: 'detail', title: '门店明细' }],
|
||
sources: { wk: { state: 'ok', run_id: RUN, stamp: STAMP } },
|
||
tree: [
|
||
{ id: 'stamp', type: 'stamp', props: {}, data: { view: 'wk', state: 'ok', stamp: STAMP } },
|
||
{ id: 'kpis', type: 'columns', props: { count: 2 }, children: [
|
||
// 绑定指标:服务端交来的字已经按口径卡带着单位(render_metric),data.unit 说明是哪个单位;派生时属性里也写了单位
|
||
{ id: 'gmv', type: 'metric', props: { label: '销售额', unit: '万元' },
|
||
data: { state: 'ok', text: '45.68 万元', name: '销售额', unit: '万元', evidence: metricEv('gmv') } },
|
||
{ id: 'orders', type: 'metric', props: { label: '订单数' }, data: { state: 'ok', text: '1,284 单', name: '订单数' } },
|
||
// 绑定单元格:字不带单位(render_cell),单位是搭建者在属性里填的
|
||
{ id: 'shops', type: 'metric', props: { label: '门店数', unit: '家' },
|
||
data: { state: 'ok', text: '128', evidence: { run_id: RUN, kind: 'cell', node: 'q1', artifact: 'art_q1', eid: 'ev:cell:shops' } } },
|
||
// 绑定指标、口径卡没写单位:服务端照样带 unit(空串),字里没有单位,写搭建者在属性里填的
|
||
{ id: 'aov', type: 'metric', props: { label: '客单价', unit: '元' },
|
||
data: { state: 'ok', text: '356.2', name: '客单价', unit: '', evidence: metricEv('aov') } },
|
||
] },
|
||
{ id: 'trend', type: 'series', props: { label: '销售额趋势' }, data: {
|
||
state: 'ok', mode: 'incomparable', unit: '万元',
|
||
series: [
|
||
{ version: 6, label: 'v6', points: [point(0, 40.1), point(1, 41.3), point(2, 39.8)] },
|
||
{ version: 7, label: 'v7', points: [point(3, 44.2), point(4, 45.68)] },
|
||
],
|
||
breaks: [{ after_run: 'run_w2', from: 6, to: 7, text: '口径从 v6 升到 v7,前后不可比' }],
|
||
} },
|
||
{ id: 'stores', type: 'table', props: { title: '门店排行' }, data: {
|
||
state: 'ok', columns: ['门店', '销售额', '店长手机'], masked: ['店长手机'], total_rows: 3, truncated: false,
|
||
rows: [['南山店', 120.5, '已遮罩'], ['福田店', 98.2, '已遮罩'], ['宝安店', 130.1, '已遮罩']],
|
||
evidence: { run_id: RUN, kind: 'query', node: 'q1', artifact: 'art_q1' },
|
||
} },
|
||
{ id: 'future', type: 'gauge3d', props: {} },
|
||
{ id: 'broken', type: 'metric', props: { label: '毛利率' }, data: { state: 'error' } },
|
||
],
|
||
forms: {},
|
||
problems: [{ component: 'broken', code: 'view.resolve_failed', message: '解析出错:KeyError' }],
|
||
}
|
||
|
||
const CLAIM_HOME = {
|
||
format: 'agentlab.view/1',
|
||
app: { id: 'app_claim', name: '报销审批', version: 2, level: 'published', needs_upgrade: false },
|
||
page: { id: 'home', title: '报销' },
|
||
pages: [{ id: 'home', title: '报销' }],
|
||
sources: {},
|
||
tree: [
|
||
{ id: 'new', type: 'entry_button', props: { entry: 'apply' }, data: { entry: 'apply', label: '发起报销', disabled: false } },
|
||
{ id: 'mine', type: 'instance_list', props: { entry: 'apply', who: 'mine' }, data: { who: 'mine', entry: 'apply', items: [] } },
|
||
],
|
||
forms: {
|
||
apply: {
|
||
format: 'agentlab.form/1', type: 'object', required: ['amount', 'reason'], 'x-order': ['amount', 'reason', 'kind'],
|
||
properties: {
|
||
amount: { type: 'number', title: '金额(元)', minimum: 0 },
|
||
reason: { type: 'string', title: '事由', 'x-widget': 'textarea', maxLength: 500 },
|
||
kind: { type: 'string', title: '类别', enum: ['travel', 'meal'], 'x-enum-labels': ['差旅', '餐费'] },
|
||
},
|
||
},
|
||
},
|
||
problems: [],
|
||
}
|
||
|
||
const TASK = {
|
||
id: 'task_fin', approval_id: 'ap_fin', run_id: 'run_claim_1', node_id: 'finance', app_id: 'app_claim', app_name: '报销审批',
|
||
app_version: 2, workflow_name: '报销流程', instance_title: '张三 的报销', kind: 'approve', title: '财务审批', form: null,
|
||
policy: { kind: 'all' },
|
||
status: 'pending', due_at: new Date(Date.now() + 3600_000).toISOString(), escalation_level: 0,
|
||
payload: { kind: 'human_node', message: '张三报销差旅 **1,200 元**,请审批' },
|
||
assignments: [
|
||
{ id: 'as1', principal_kind: 'user', principal: 'lisi', source: 'slot:finance', status: 'responded', responded_by: 'lisi',
|
||
responded_at: ago(600_000), decision: 'approve', verified: false },
|
||
{ id: 'as2', principal_kind: 'user', principal: 'wangwu', source: 'slot:finance', status: 'pending', responded_by: null,
|
||
responded_at: null, decision: null, verified: false },
|
||
],
|
||
can_respond: true, outcome: null, resolved_by: null, resolved_at: null, created_at: ago(1800_000),
|
||
}
|
||
|
||
/** 会签组件和待办表单绑同一条待办:页面解析给的数据一样,只有待办表单带 signoffs_on_page */
|
||
function withSignoffsData(instance) {
|
||
const tree = instance.view.tree
|
||
const form = tree.find((n) => n.id === 'review')
|
||
const { signoffs_on_page: _, ...data } = form.data
|
||
tree.find((n) => n.id === 'signs').data = data
|
||
return instance
|
||
}
|
||
|
||
const INSTANCE = withSignoffsData({
|
||
run_id: 'run_claim_1', title: '张三 的报销', entry_id: 'apply', status: 'interrupted', started_by: '张三',
|
||
created_at: ago(1800_000), finished_at: null, pending_tasks: 1, tier: null,
|
||
// 给人看的状态服务端说(apps/resolve.instance_state):结果未知时是在等核实,不是「等待审批」
|
||
status_code: 'waiting', status_text: '等待核实写入结果',
|
||
view: {
|
||
format: 'agentlab.view/1',
|
||
app: { id: 'app_claim', name: '报销审批', version: 2, level: 'published', needs_upgrade: false },
|
||
page: { id: 'instance', title: '实例' }, pages: [{ id: 'home', title: '报销' }, { id: 'instance', title: '实例' }],
|
||
sources: { inst: { state: 'running', run_id: 'run_claim_1', stamp: null, message: '等待核实写入结果,流程走完后自动显示' } },
|
||
tree: [
|
||
{ id: 'inst_header', type: 'instance_header', props: {}, data: {
|
||
state: 'ok', run_id: 'run_claim_1', title: '张三 的报销', status: 'interrupted', status_code: 'waiting',
|
||
status_text: '等待核实写入结果', started_by: '张三', created_at: ago(1800_000), finished_at: null, pending_tasks: 1,
|
||
stamp: null } },
|
||
{ id: 'inst_timeline', type: 'instance_timeline', props: {}, data: {
|
||
state: 'ok', status: 'interrupted', status_code: 'waiting', status_text: '等待核实写入结果', steps: [
|
||
{ node: 'input', title: '报销单', state: 'done', text: '完成', at: ago(1790_000) },
|
||
{ node: 'finance', title: '财务审批', state: 'waiting', text: '等待审批', at: ago(1700_000) },
|
||
{ node: 'pay', title: '付款', state: 'unknown', text: '结果未知,等待核实', at: ago(600_000) },
|
||
] } },
|
||
// 发起人不是这条待办的处理人:服务端解析时判定(handler 为假),只给这一步的状态,界面不去取待办。同一页上另有绑
|
||
// 同一条待办的会签组件(signs):服务端说一声(signoffs_on_page),待办表单不再画会签进度。人名服务端给显示名(people)
|
||
{ id: 'review', type: 'task_form', props: {}, bind: { task: 'review' }, data: {
|
||
state: 'ok', handler: false, task_ui: {}, signoffs_on_page: true,
|
||
people: { lisi: '李四', wangwu: '王五', zhaoliu: '赵六' },
|
||
task: { id: 'task_other', run_id: 'run_claim_1', node_id: 'review', kind: 'approve', title: '复核', status: 'pending',
|
||
due_at: new Date(Date.now() + 3600_000).toISOString(), policy: { kind: 'all' }, escalation_level: 1,
|
||
created_at: ago(1700_000), instance_title: '张三 的报销', form: null },
|
||
assignments: [
|
||
{ id: 'r1', principal_kind: 'user', principal: 'lisi', source: 'slot:finance', status: 'responded', responded_by: 'lisi',
|
||
responded_at: ago(900_000), decision: 'approve', verified: true },
|
||
{ id: 'r2', principal_kind: 'user', principal: 'wangwu', source: 'slot:finance', status: 'pending', responded_by: null,
|
||
responded_at: null, decision: null, verified: false },
|
||
{ id: 'r3', principal_kind: 'user', principal: 'zhaoliu', source: 'escalation', status: 'pending', responded_by: null,
|
||
responded_at: null, decision: null, verified: false },
|
||
],
|
||
progress: { policy: 'all', voters: 2, need: 2, agreed: 1, rejected: 0,
|
||
escalation: { slot: 'director', assignees: [{ kind: 'user', name: 'zhaoliu' }], decided: null, by: null } },
|
||
my_response: null } },
|
||
{ id: 'signs', type: 'signoffs', props: {}, bind: { task: 'review' }, data: null },
|
||
{ id: 'inst_tasks', type: 'task_list', props: { scope: 'instance' }, data: { scope: 'instance', items: [] } },
|
||
{ id: 'pay', type: 'action_status', props: { label: '付款' }, data: { state: 'ok', node: 'pay', records: [
|
||
{ id: 'act1', state: 'unknown', environment: 'prod', attempt: 1, payload_hash: '9c1e44ab',
|
||
note: '发送时没有拿到确定的结果(请求超时),等待核实:在外部系统里可能已经生效,不会自动重发' },
|
||
] } },
|
||
],
|
||
forms: {}, problems: [],
|
||
},
|
||
})
|
||
|
||
/**
|
||
* 指标链照服务端的形状(api/evidence 的 _metric_chain)。销售额按位置取数据整形的结果(vars.q.rows[0][0]),整形节点不是
|
||
* 原样解析,出处追到它为止:untraced 写明停在哪个节点。复算照样一致——「有出处」和「追到原始数据」是两件事
|
||
*/
|
||
const UNTRACED_INPUT = { path: 'vars.q.rows[0][0]', value: 456800, node_id: 'parse', via: 'transform', field: 'rows[0][0]', status: 'ok' }
|
||
const METRIC_CHAIN = {
|
||
chain: [
|
||
{ step: 'metric', node_id: 'kpi', metric: 'gmv', name: '销售额', value: 456800, expression: 'vars.q.rows[0][0]',
|
||
substituted: '456800', recompute_ok: true, inputs: [UNTRACED_INPUT],
|
||
untraced: [{ path: 'vars.q.rows[0][0]', node_id: 'parse', label: '解析查询结果', type: 'transform' }] },
|
||
{ step: 'input', ...UNTRACED_INPUT },
|
||
],
|
||
seal: { sealed: true, ok: true, covered: true },
|
||
redacted: { columns: [] },
|
||
}
|
||
/** 同一个指标,整形节点原样解析查询结果:按位置取的格卡上记成单元格取数,穿过整形节点接上查询快照 */
|
||
const TRACED_INPUT = { path: 'vars.q.rows[0][0]', value: 456800, node_id: 'fetch', via: 'tool_cell', through: 'parse',
|
||
through_label: '解析查询结果', artifact: 'art_q1', locator: { row: 0, column: 'gmv' }, eid: 'ev:cell:gmv', status: 'verified',
|
||
query: 'Q1', cell: 'Q1.r0.gmv' }
|
||
const METRIC_CHAIN_TRACED = {
|
||
...METRIC_CHAIN,
|
||
chain: [{ ...METRIC_CHAIN.chain[0], inputs: [TRACED_INPUT], untraced: [] }, { step: 'input', ...TRACED_INPUT }],
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 页面上直接改(N6)的假数据
|
||
|
||
/** 门店周报的草稿:和 HOME 的组件一一对应;stores 被隐藏(服务端按草稿解析照样下发、带 hidden),old_note 也被隐藏
|
||
* 但服务端没画出来(编辑层列在页底) */
|
||
const DRAFT = {
|
||
format: 'agentlab.app/1', name: '门店周报', description: '每周一看各门店的销售概况',
|
||
pins: [{ alias: 'wk', workflow_id: 'wf_week', version: 7, graph_hash: '3fa2c1d0', level: 'governed', use: 'view' }],
|
||
entries: [], views: { wk: { source: { kind: 'latest_sealed', pin: 'wk', from: 'formal' } } }, forms: {}, slots: {}, task_ui: {},
|
||
pages: [{ id: 'home', title: '本周概览', body: [
|
||
{ id: 'stamp', type: 'stamp', props: {}, bind: { view: 'wk', run: 'status' } },
|
||
{ id: 'kpis', type: 'columns', props: { count: 2 }, children: [
|
||
{ id: 'gmv', type: 'metric', props: { label: '销售额', unit: '万元', conv: '万' }, bind: { view: 'wk', metric: 'kpi.gmv' } },
|
||
{ id: 'orders', type: 'metric', props: { label: '订单数' }, bind: { view: 'wk', metric: 'kpi.orders' } },
|
||
{ id: 'shops', type: 'metric', props: { label: '门店数', unit: '家' }, bind: { view: 'wk', cell: { node: 'q1', row: 0, col: 'n' } } },
|
||
{ id: 'aov', type: 'metric', props: { label: '客单价', unit: '元' }, bind: { view: 'wk', metric: 'kpi.aov' } },
|
||
] },
|
||
{ id: 'trend', type: 'series', props: { label: '销售额趋势' }, bind: { view: 'wk', series: 'kpi.gmv', last: 12 } },
|
||
{ id: 'stores', type: 'table', props: { title: '门店排行' }, hidden: true, bind: { view: 'wk', query: { node: 'q1' } } },
|
||
{ id: 'old_note', type: 'notice', props: { text: '旧的说明' }, hidden: true },
|
||
{ id: 'future', type: 'gauge3d', props: {} },
|
||
{ id: 'broken', type: 'metric', props: { label: '毛利率' }, bind: { view: 'wk', metric: 'kpi.margin' } },
|
||
] }, { id: 'detail', title: '门店明细', body: [] }],
|
||
}
|
||
const DETAIL = { ...APPS[0], blueprint_id: 'bp_week', draft: DRAFT, draft_bindings: { slots: {} },
|
||
published: { version: 3, pages: [{ id: 'home', title: '本周概览' }, { id: 'detail', title: '门店明细' }], entries: [] } }
|
||
/** 按草稿解析:stores 带 hidden 下发,old_note 不下发 */
|
||
const DRAFT_HOME = { ...HOME, tree: HOME.tree.map((n) => (n.id === 'stores' ? { ...n, hidden: true } : n)) }
|
||
const CATALOG = { version: 1, categories: { layout: '布局', display: '展示' }, components: [
|
||
{ name: 'columns', label: '分栏', category: 'layout', version: 1, children: true, bindings: [],
|
||
props: { type: 'object', properties: { count: { type: 'integer', title: '列数' } } } },
|
||
{ name: 'stamp', label: '印章', category: 'display', version: 1, children: false, bindings: ['run'],
|
||
props: { type: 'object', properties: { compact: { type: 'boolean', title: '紧凑显示' } } } },
|
||
{ name: 'metric', label: '指标块', category: 'display', version: 1, children: false, bindings: ['metric', 'cell'],
|
||
props: { type: 'object', properties: {
|
||
label: { type: 'string', maxLength: 200, title: '名称', 'x-text': true },
|
||
unit: { type: 'string', maxLength: 200, title: '单位', 'x-text': true },
|
||
conv: { type: 'string', maxLength: 200, title: '换算' } } } },
|
||
{ name: 'series', label: '趋势', category: 'display', version: 1, children: false, bindings: ['series'],
|
||
props: { type: 'object', properties: { label: { type: 'string', maxLength: 200, title: '名称', 'x-text': true } } } },
|
||
{ name: 'table', label: '表格', category: 'display', version: 1, children: false, bindings: ['query'],
|
||
props: { type: 'object', properties: { title: { type: 'string', maxLength: 200, title: '标题', 'x-text': true } } } },
|
||
{ name: 'notice', label: '提示', category: 'display', version: 1, children: false, bindings: [],
|
||
props: { type: 'object', properties: { text: { type: 'string', maxLength: 4000, title: '提示文字', 'x-text': true } } } },
|
||
] }
|
||
/** 「加一张数据卡片」的候选:口径卡指标两个(其中一个 id 和页面上的 gmv 撞了)、趋势一个 */
|
||
const CANDIDATES = { items: [
|
||
{ kind: 'metric', label: '退款率', group: '口径卡「周销售」', component: { id: 'refund', type: 'metric', props: { label: '退款率' },
|
||
bind: { view: 'wk', metric: 'kpi.refund' } } },
|
||
{ kind: 'metric', label: '销售额', group: '口径卡「周销售」', component: { id: 'gmv', type: 'metric', props: { label: '销售额' },
|
||
bind: { view: 'wk', metric: 'kpi.gmv' } } },
|
||
{ kind: 'series', label: '订单数趋势', group: '口径卡「周销售」', component: { id: 'orders_trend', type: 'series',
|
||
props: { label: '订单数趋势' }, bind: { view: 'wk', series: 'kpi.orders', last: 12 } } },
|
||
] }
|
||
|
||
/** 草稿里按 id 找组件 */
|
||
const findIn = (spec, id) => {
|
||
const walk = (list) => { for (const c of list ?? []) { if (c.id === id) return c; const hit = walk(c.children); if (hit) return hit } return null }
|
||
for (const pg of spec?.pages ?? []) { const hit = walk(pg.body); if (hit) return hit }
|
||
return null
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 浏览器
|
||
|
||
const browser = await chromium.launch({ executablePath: CHROME })
|
||
|
||
async function open({ width = 1280, height = 900, state }) {
|
||
const ctx = await browser.newContext({ viewport: { width, height } })
|
||
opened.add(ctx)
|
||
const page = await ctx.newPage()
|
||
page.errors = []
|
||
page.on('pageerror', (e) => page.errors.push(e.message))
|
||
await page.route((url) => url.pathname.startsWith('/api/'), (route) => {
|
||
const r = route.request()
|
||
const url = new URL(r.url())
|
||
const p = url.pathname
|
||
if (r.method() === 'GET' && p.startsWith('/api/tasks/')) (state.taskReads ??= []).push(p)
|
||
if (r.method() !== 'GET') {
|
||
state.writes.push({ method: r.method(), path: p, body: r.postData() })
|
||
if (p === '/api/apps/app_claim/entries/apply') {
|
||
const body = JSON.parse(r.postData() || '{}')
|
||
if (state.reject422) {
|
||
return route.fulfill({ status: 422, json: { detail: '表单没有通过校验', errors: [
|
||
{ path: 'amount', code: 'maximum', message: '不能大于 5000' },
|
||
{ path: 'approver', code: 'required', message: '缺少审批人' },
|
||
] } })
|
||
}
|
||
return route.fulfill({ status: 201, json: { run_id: 'run_claim_1', title: `${body.values?.reason ?? ''}` } })
|
||
}
|
||
if (p.startsWith('/api/tasks/') && p.endsWith('/respond')) {
|
||
return route.fulfill({ json: { task: { ...TASK, status: 'resolved', can_respond: false }, resolved: true } })
|
||
}
|
||
// 页面上直接改:存草稿回详情(带上存进去的草稿),发布回新版本
|
||
if (state.detail && r.method() === 'PATCH' && p === '/api/apps/app_weekly') {
|
||
const body = JSON.parse(r.postData() || '{}')
|
||
state.patches = [...(state.patches ?? []), body]
|
||
state.detail = { ...state.detail, ...(body.draft ? { draft: body.draft } : {}) }
|
||
return route.fulfill({ json: state.detail })
|
||
}
|
||
if (state.detail && p === '/api/apps/app_weekly/publish') {
|
||
return route.fulfill({ json: { ok: true, version: 4, level: 'governed', spec_hash: 'h4', issues: [] } })
|
||
}
|
||
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 } })
|
||
// 使用者选人的候选池(05-已知遗留 (b)):按段里给的 pool 答;整份名录只给搭建者,使用者取了就是 403
|
||
if (p === '/api/directory/candidates') {
|
||
(state.candidateCalls ??= []).push(Object.fromEntries(url.searchParams))
|
||
return route.fulfill({ json: state.pool ? state.pool(url.searchParams.get('q') ?? '') : { users: [], groups: [], truncated: false } })
|
||
}
|
||
if (p === '/api/directory') {
|
||
state.directoryHits = (state.directoryHits ?? 0) + 1
|
||
return route.fulfill({ status: 403, json: { detail: '需要搭建者权限' } })
|
||
}
|
||
if (p === '/api/apps') return route.fulfill({ json: { items: APPS } })
|
||
// 应用详情:默认不给草稿(不进编辑状态);页面上直接改的段落给 manage 的详情
|
||
if (p === '/api/apps/app_weekly') return route.fulfill({ json: state.detail ?? { ...APPS[0], published: DETAIL.published } })
|
||
if (p === '/api/apps/app_claim') return route.fulfill({ json: { ...APPS[1], published: { version: 2, pages: [{ id: 'home', title: '报销' }], entries: [] } } })
|
||
if (p === '/api/apps/catalog') return route.fulfill({ json: CATALOG })
|
||
if (p === '/api/apps/app_weekly/candidates') {
|
||
state.candidateHits = (state.candidateHits ?? 0) + 1
|
||
return route.fulfill({ json: CANDIDATES })
|
||
}
|
||
if (p === '/api/apps/app_weekly/pages/home' && url.searchParams.get('draft') === '1') {
|
||
state.draftHits = (state.draftHits ?? 0) + 1
|
||
return route.fulfill({ json: state.draftHome ?? DRAFT_HOME })
|
||
}
|
||
if (p === '/api/apps/app_weekly/pages/home') {
|
||
state.pageHits = (state.pageHits ?? 0) + 1
|
||
return route.fulfill({ json: state.home ?? HOME })
|
||
}
|
||
if (p === '/api/apps/app_weekly/pages/detail') return route.fulfill({ json: { ...HOME, page: { id: 'detail', title: '门店明细' }, tree: [] } })
|
||
if (p === '/api/apps/app_claim/pages/home') return route.fulfill({ json: state.claimHome ?? CLAIM_HOME })
|
||
if (/^\/api\/apps\/[^/]+\/instances$/.test(p)) return route.fulfill({ json: { items: p.includes('app_claim') ? [INSTANCE] : [] } })
|
||
if (p === '/api/apps/app_claim/instances/run_claim_1') return route.fulfill({ json: state.instance ?? INSTANCE })
|
||
if (p === `/api/runs/${RUN}/evidence/metrics/kpi/gmv`) {
|
||
state.chainHits = (state.chainHits ?? 0) + 1
|
||
return route.fulfill({ json: state.traced ? METRIC_CHAIN_TRACED : METRIC_CHAIN })
|
||
}
|
||
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 1, apps: 1, other: 0 } })
|
||
if (p === '/api/tasks') return route.fulfill({ json: { items: url.searchParams.get('status') === 'resolved' ? [] : [TASK] } })
|
||
if (p === '/api/tasks/task_fin') return route.fulfill({ json: TASK })
|
||
return route.continue()
|
||
})
|
||
return { ctx, page }
|
||
}
|
||
|
||
const noHScroll = (page) => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)
|
||
|
||
// ---------------------------------------------------------------- 首页
|
||
|
||
await section('首页:三个页签、关登录说明、需升级', async () => {
|
||
const state = { writes: [] }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps`)
|
||
const tabs = page.getByRole('tablist', { name: '应用' })
|
||
await tabs.waitFor({ timeout: 15000 })
|
||
check('三个页签:待办、应用、我发起的', (await tabs.getByRole('tab').allInnerTexts()).map((t) => t.replace(/\d+/g, '').trim()).join(',') === '待办,应用,我发起的')
|
||
check('默认是待办页签', (await page.locator('[data-apps-home]').getAttribute('data-apps-home')) === 'inbox')
|
||
check('关登录时挂一行说明(自报署名)', /未开启登录/.test(await page.locator('[data-auth-off]').innerText()))
|
||
check('待办按应用分组', (await page.locator('[data-inbox-group="app_claim"]').count()) === 1)
|
||
check('会签的待办写进度', /1\/2 已同意/.test(await page.locator('[data-inbox-signoffs]').innerText()))
|
||
check('待办一行写所属实例和开出的时间(同标题的分得开)',
|
||
/张三 的报销 · .*发起/.test(await page.locator('[data-inbox-meta]').first().innerText()),
|
||
await page.locator('[data-inbox-meta]').first().innerText())
|
||
await tabs.getByRole('tab', { name: /应用/ }).click()
|
||
check('页签记在地址上', /tab=apps/.test(page.url()), page.url())
|
||
await page.locator('[data-app]').first().waitFor({ timeout: 5000 })
|
||
check('两个应用卡', (await page.locator('[data-app]').count()) === 2)
|
||
check('需升级的应用照实标出', (await page.locator('[data-app="app_claim"] [data-app-needs-upgrade]').count()) === 1)
|
||
check('能管理的应用有「编辑」,只能用的没有',
|
||
(await page.locator('[data-app="app_weekly"] a[href$="/edit"]').count()) === 1
|
||
&& (await page.locator('[data-app="app_claim"] a[href$="/edit"]').count()) === 0)
|
||
// 新建应用(normal-user-apps §4.1):搭建者及以上在页头看得到(关登录时所有人等同管理员),点进去是三个起点的新建页,
|
||
// 建的是共享蓝图。起点和蓝图页本身在 check-blueprint 里查
|
||
const create = page.locator('header [data-app-create]')
|
||
check('页头有「新建应用」,链到新建页(三个起点)', await create.count() === 1
|
||
&& (await create.innerText()).includes('新建应用') && await create.getAttribute('href') === '/apps/create'
|
||
&& await create.getAttribute('data-app-create') === 'shared',
|
||
`${await create.count()} | ${await create.getAttribute('href').catch(() => '')}`)
|
||
await page.getByRole('tab', { name: '我发起的' }).click()
|
||
await page.locator('[data-mine] [data-instance]').first().waitFor({ timeout: 5000 })
|
||
check('我发起的:合各应用的实例,链到实例页',
|
||
(await page.locator('[data-mine] a[href="/apps/app_claim/i/run_claim_1"]').count()) === 1)
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
|
||
// 开了登录的使用者、管理员没开个人应用:没有「新建应用」(开了的情形在 check-blueprint 里查)
|
||
const user = { writes: [], me: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true } }
|
||
const u = await open({ state: user })
|
||
await u.page.goto(`${WEB}/apps?tab=apps`)
|
||
await u.page.locator('[data-app]').first().waitFor({ timeout: 15000 })
|
||
check('使用者的应用首页没有「新建应用」', await u.page.locator('[data-app-create], [data-app-create-empty]').count() === 0)
|
||
check('……没有未捕获的运行时错误', u.page.errors.length === 0, u.page.errors[0] ?? '')
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 应用页
|
||
|
||
await section('应用页:印章、指标、出处、趋势、表格、未知组件', async () => {
|
||
const state = { writes: [] }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_weekly`)
|
||
await page.locator('[data-view-renderer]').waitFor({ timeout: 15000 })
|
||
const stamp = page.locator('[data-stamp]').first()
|
||
const stampText = await stamp.innerText()
|
||
check('印章四项:截至、口径、出具档位、来源', ['截至', '口径', '出具档位', '来源'].every((k) => stampText.includes(k)), stampText.replace(/\n/g, ' '))
|
||
// 截至是服务端给的整句(自己带着「截至」):完整、紧凑两种印章都只写一次,不写成「截至 截至 …」
|
||
const stampTexts = await page.locator('[data-stamp]').allInnerTexts()
|
||
check('印章里「截至」只出现一次(完整和紧凑两种)', stampTexts.length > 0 && stampTexts.every((t) => t.split('截至').length === 2),
|
||
stampTexts.map((t) => t.replace(/\s+/g, ' ').slice(0, 40)).join(' | '))
|
||
check('印章写口径名和哈希前 8 位', stampText.includes('周销售 v7 · 3fa2c1d0') && stampText.includes('周销售口径 v3'))
|
||
check('服务端标出的错位照写', (await stamp.locator('[data-stamp-note]').innerText()).includes('固定在 v7'))
|
||
check('页面有两页:页签', (await page.getByRole('tab').count()) >= 2)
|
||
|
||
const gmv = page.locator('[data-node="gmv"] button[data-evidence="metric"]')
|
||
check('指标块的数是按钮', (await gmv.innerText()).includes('45.68 万元'))
|
||
const gmvText = await page.locator('[data-node="gmv"] [data-component="metric"]').innerText()
|
||
check('绑定指标:单位只写在数里,下面不再重复一行', gmvText.split('万元').length === 2, gmvText.replace(/\s+/g, ' '))
|
||
const shops = page.locator('[data-node="shops"] [data-component="metric"]')
|
||
check('绑定单元格:数不带单位,单位写在下面', (await shops.innerText()).replace(/\s+/g, ' ').trim() === '门店数 128 家',
|
||
(await shops.innerText()).replace(/\s+/g, ' '))
|
||
const aov = (await page.locator('[data-node="aov"] [data-component="metric"]').innerText()).replace(/\s+/g, ' ').trim()
|
||
check('绑定指标、口径卡没写单位:写搭建者在属性里填的单位,不吞掉', aov === '客单价 356.2 元', aov)
|
||
check('没有出处的数照实标「无证据」', (await page.locator('[data-node="orders"] [data-evidence="none"]').innerText()).includes('无证据'))
|
||
await gmv.click()
|
||
const panel = page.locator('[data-evidence-panel]')
|
||
check('点数字打开出处面板', await panel.waitFor({ timeout: 5000 }).then(() => true, () => false))
|
||
check('走的是指标链接口', state.chainHits === 1, `${state.chainHits ?? 0} 次`)
|
||
// 有出处(口径卡确定性复算一致)和追到原始数据分开说:追不到的不能只写「有出处」
|
||
const origin = panel.locator('[data-ev-origin]')
|
||
check('复算一致照写', (await panel.locator('[data-ev-recompute]').getAttribute('data-ev-recompute')) === 'ok')
|
||
check('输入追不到原始数据:照实说追到哪个节点为止', await origin.getAttribute('data-ev-origin') === 'untraced'
|
||
&& (await origin.innerText()).includes('vars.q.rows[0][0] 的出处追到「解析查询结果」(数据整形)为止,没追到原始数据'),
|
||
await origin.innerText().catch(() => ''))
|
||
check('……指标那一行另挂「未追到原始数据」', (await panel.locator('[data-ev-untraced-chip]').innerText()) === '未追到原始数据')
|
||
await page.keyboard.press('Escape')
|
||
|
||
const series = page.locator('[data-component="series"]')
|
||
check('趋势是 SVG', (await series.locator('svg[role="img"]').count()) === 1)
|
||
check('升版不可比处画断开线', (await series.locator('[data-plot-break]').count()) === 1)
|
||
check('升版说明写在图下', (await series.locator('[data-series-note]').innerText()).includes('前后不可比'))
|
||
check('两条线有图例', (await series.locator('ul li').count()) === 2)
|
||
await series.getByRole('button', { name: '查看数据' }).click()
|
||
check('「查看数据」给出表格视图', (await series.locator('table tbody tr').count()) === 5)
|
||
|
||
const table = page.locator('[data-component="table"]')
|
||
const firstCell = () => table.locator('tbody tr').first().locator('td').first().innerText()
|
||
check('表格按服务端给的顺序', (await firstCell()) === '南山店')
|
||
await table.getByRole('button', { name: /^销售额/ }).click()
|
||
check('点表头升序', (await firstCell()) === '福田店', await firstCell())
|
||
await table.getByRole('button', { name: /^销售额/ }).click()
|
||
check('再点降序', (await firstCell()) === '宝安店', await firstCell())
|
||
check('遮罩列写「已遮罩」', (await table.locator('td[data-masked]').count()) === 3)
|
||
|
||
check('不认识的组件写「此组件需要更新前端」', (await page.locator('[data-unknown-component="gauge3d"]').innerText()).includes('此组件需要更新前端'))
|
||
check('服务端解析出错的组件列在顶上', (await page.locator('[data-view-problems]').innerText()).includes('解析出错'))
|
||
check('出错的组件就地说明', (await page.locator('[data-node="broken"] [data-block-state="error"]').count()) === 1)
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
|
||
// 整形节点原样解析查询结果:按位置取的格穿过它接上查询快照,面板写明经过哪个节点、输入都追到了原始数据
|
||
const traced = { writes: [], traced: true }
|
||
const t = await open({ state: traced })
|
||
await t.page.goto(`${WEB}/apps/app_weekly`)
|
||
await t.page.locator('[data-node="gmv"] button[data-evidence="metric"]').click()
|
||
const tp = t.page.locator('[data-evidence-panel]')
|
||
await tp.locator('[data-ev-origin]').waitFor({ timeout: 5000 })
|
||
check('穿过原样解析的整形节点:输入都追到了原始数据,不挂「未追到」', await tp.locator('[data-ev-origin]').getAttribute('data-ev-origin') === 'traced'
|
||
&& (await tp.locator('[data-ev-origin]').innerText()).includes('输入都追到了原始数据')
|
||
&& await tp.locator('[data-ev-untraced-chip]').count() === 0)
|
||
const source = tp.locator('[data-ev-source="vars.q.rows[0][0]"]')
|
||
check('……输入来源写明经过哪个整形节点、落在哪一格', (await source.innerText()).includes('经「解析查询结果」原样解析')
|
||
&& (await source.innerText()).includes('与快照一致'), (await source.innerText().catch(() => '')).replace(/\s+/g, ' '))
|
||
check('……没有未捕获的运行时错误', t.page.errors.length === 0, t.page.errors[0] ?? '')
|
||
})
|
||
|
||
await section('应用页:视图生成中自动重取、需升级提示', async () => {
|
||
const running = { ...HOME, app: { ...HOME.app, needs_upgrade: true },
|
||
sources: { wk: { state: 'running', run_id: RUN, stamp: null, message: '正在运行,完成后自动刷新' } },
|
||
tree: [{ id: 'stamp', type: 'stamp', props: {}, data: { view: 'wk', state: 'running', stamp: null, message: '正在运行,完成后自动刷新' } }] }
|
||
const state = { writes: [], home: running }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_weekly`)
|
||
await page.locator('[data-view-renderer]').waitFor({ timeout: 15000 })
|
||
check('需升级照实提示', (await page.locator('[data-app-needs-upgrade]').count()) === 1)
|
||
check('生成中写明在等', (await page.locator('[data-block-state="running"]').count()) === 1)
|
||
const before = state.pageHits
|
||
state.home = HOME
|
||
await page.waitForTimeout(6000)
|
||
check('有视图在生成时 5 秒后自动重取', state.pageHits > before, `${before} → ${state.pageHits}`)
|
||
check('生成完换上数据', (await page.locator('[data-node="gmv"]').count()) === 1)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 入口
|
||
|
||
await section('入口:本地校验、422 挂回字段、发起后进实例页', async () => {
|
||
const state = { writes: [] }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_claim/new/apply`)
|
||
const form = page.locator('[data-schema-form]')
|
||
await form.waitFor({ timeout: 15000 })
|
||
check('标题用发起按钮上的名字', (await page.locator('h2').first().innerText()) === '发起报销')
|
||
check('必填有星号', (await form.locator('[data-field="amount"] label span[aria-hidden]').innerText()) === '*')
|
||
check('字段按 x-order 排', (await form.locator('[data-field]').evaluateAll((els) => els.map((e) => e.getAttribute('data-field')))).join(',') === 'amount,reason,kind')
|
||
check('枚举画成下拉,显示中文', (await form.locator('[data-field="kind"] option').allInnerTexts()).includes('差旅'))
|
||
await page.locator('[data-entry-submit]').click()
|
||
check('必填没填本地拦下,不发请求', !state.writes.some((w) => w.path.endsWith('/entries/apply')))
|
||
check('错误挂在字段下面', (await form.locator('[data-field="amount"] [data-field-error="required"]').count()) === 1)
|
||
await form.locator('[data-field="amount"] input').fill('1.')
|
||
check('数字框能打出小数点', (await form.locator('[data-field="amount"] input').inputValue()) === '1.')
|
||
await form.locator('[data-field="amount"] input').fill('-5')
|
||
await form.locator('[data-field="reason"] textarea').fill('出差深圳')
|
||
await page.locator('[data-entry-submit]').click()
|
||
check('金额低于下限(minimum)本地拦下,不发请求',
|
||
(await form.locator('[data-field="amount"] [data-field-error="minimum"]').count()) === 1
|
||
&& !state.writes.some((w) => w.path.endsWith('/entries/apply')))
|
||
await form.locator('[data-field="amount"] input').fill('9000')
|
||
state.reject422 = true
|
||
await page.locator('[data-entry-submit]').click()
|
||
await form.locator('[data-field="amount"] [data-field-error="maximum"]').waitFor({ timeout: 5000 }).catch(() => {})
|
||
check('服务端 422 按 path 挂回字段', (await form.locator('[data-field="amount"] [data-field-error="maximum"]').innerText()).includes('5000'))
|
||
check('对不上字段的错误列在表单顶上', (await form.locator('[data-form-errors]').innerText()).includes('缺少审批人'))
|
||
const sent = JSON.parse(state.writes.find((w) => w.path.endsWith('/entries/apply')).body)
|
||
check('交上去的金额是数,不是字符串', sent.values.amount === 9000, JSON.stringify(sent.values))
|
||
state.reject422 = false
|
||
await form.locator('[data-field="amount"] input').fill('1200')
|
||
await form.locator('[data-field="reason"] textarea').press(process.platform === 'darwin' ? 'Meta+Enter' : 'Control+Enter')
|
||
check('⌘/Ctrl+Enter 发起,成功后进实例页', await page.waitForURL(/\/apps\/app_claim\/i\/run_claim_1/, { timeout: 5000 }).then(() => true, () => false), page.url())
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
})
|
||
|
||
await section('入口:使用者选人只出候选池里的人,不在池里当场说清(05-已知遗留 (b))', async () => {
|
||
const people = [{ username: 'fay', display_name: '费伊' }, { username: 'fin', display_name: '樊宁' }]
|
||
const apply = CLAIM_HOME.forms.apply
|
||
const state = {
|
||
writes: [],
|
||
me: { mode: 'local', username: 'zhang', display_name: '张三', role: 'user', groups: [], verified: true },
|
||
claimHome: { ...CLAIM_HOME, forms: { apply: { ...apply, 'x-order': [...apply['x-order'], 'approver'],
|
||
properties: { ...apply.properties, approver: { type: 'string', title: '审批人', 'x-widget': 'principal' } } } } },
|
||
pool: (q) => ({ users: people.filter((u) => !q || u.username.startsWith(q) || u.display_name.startsWith(q)),
|
||
groups: [], truncated: false }),
|
||
}
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_claim/new/apply`)
|
||
const field = page.locator('[data-schema-form] [data-field="approver"]')
|
||
await field.waitFor({ timeout: 15000 })
|
||
const input = field.locator('input.field')
|
||
await input.fill('费')
|
||
await page.waitForFunction(() => !!document.querySelector('[data-field="approver"] datalist option'), null, { timeout: 5000 })
|
||
.catch(() => {})
|
||
const options = await field.locator('datalist option').evaluateAll((els) => els.map((e) => `${e.getAttribute('value')}|${e.textContent}`))
|
||
check('边输入边按开头取候选:列表里是候选池给的人,显示名作说明', options.join(',') === 'fay|费伊', options.join(','))
|
||
const asked = (state.candidateCalls ?? []).at(-1) ?? {}
|
||
check('……按这个入口、这个字段问(purpose=form)', asked.purpose === 'form' && asked.app_id === 'app_claim'
|
||
&& asked.entry === 'apply' && asked.field === 'approver' && asked.q === '费', JSON.stringify(asked))
|
||
check('……不去取整份名录(使用者读不到)', !state.directoryHits, String(state.directoryHits ?? 0))
|
||
await input.fill('wangwu')
|
||
await input.press('Enter')
|
||
const refused = field.locator('[data-principal-refused="hard"]')
|
||
await refused.waitFor({ timeout: 5000 }).catch(() => {})
|
||
check('不在池里的名字当场说清,不收', (await refused.count()) === 1
|
||
&& (await refused.innerText()).includes('「wangwu」不在可选的人里') && (await field.locator('[data-principal]').count()) === 0)
|
||
await input.fill('fay')
|
||
await input.press('Enter')
|
||
const chip = field.locator('[data-principal="user"] span[title]')
|
||
await chip.waitFor({ timeout: 5000 }).catch(() => {})
|
||
check('选中的人写显示名,悬停给用户名', (await chip.count()) === 1 && (await chip.innerText()) === '费伊'
|
||
&& (await chip.getAttribute('title')) === 'fay')
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
})
|
||
|
||
await section('入口:需升级时不让发起', async () => {
|
||
const state = { writes: [], claimHome: { ...CLAIM_HOME, app: { ...CLAIM_HOME.app, needs_upgrade: true } } }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_claim/new/apply`)
|
||
await page.locator('[data-schema-form]').waitFor({ timeout: 15000 })
|
||
check('写明需升级', (await page.locator('[data-app-needs-upgrade]').count()) === 1)
|
||
check('发起按钮停用', await page.locator('[data-entry-submit]').isDisabled())
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 实例
|
||
|
||
await section('实例页:被驳回写人的决定,不写失败(05-已知遗留 (a))', async () => {
|
||
// 服务端:动作确认被驳回,运行收成 cancelled + end_reason=rejected;实例状态、进度的说法都是服务端给的(instance_state)
|
||
const said = { status: 'cancelled', status_code: 'cancelled', status_text: '「付款」未确认(被驳回)' }
|
||
const view = { ...INSTANCE.view, sources: {}, tree: [
|
||
{ id: 'inst_header', type: 'instance_header', props: {}, data: { ...INSTANCE.view.tree[0].data, ...said, pending_tasks: 0,
|
||
finished_at: ago(60_000) } },
|
||
{ id: 'inst_timeline', type: 'instance_timeline', props: {}, data: { state: 'ok', ...said, steps: [
|
||
{ node: 'input', title: '报销单', state: 'done', text: '完成', at: ago(1790_000) },
|
||
{ node: 'pay', title: '付款', state: 'rejected', text: '未确认(被驳回)', at: ago(60_000) },
|
||
] } },
|
||
] }
|
||
const state = { writes: [], instance: { ...INSTANCE, ...said, pending_tasks: 0, finished_at: ago(60_000), view } }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_claim/i/run_claim_1`)
|
||
await page.locator('[data-view-renderer]').waitFor({ timeout: 15000 })
|
||
const header = page.locator('[data-component="instance_header"]')
|
||
check('实例状态写「「付款」未确认(被驳回)」', (await header.innerText()).includes('「付款」未确认(被驳回)'),
|
||
(await header.innerText()).replace(/\n/g, ' '))
|
||
check('……不写「失败」', !(await page.locator('[data-instance-page]').innerText()).includes('失败'))
|
||
check('被驳回的那一步写未确认(被驳回)', (await page.locator('[data-timeline-step="rejected"]').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/app_claim/i/run_claim_1`)
|
||
await page.locator('[data-view-renderer]').waitFor({ timeout: 15000 })
|
||
check('写出发起人', (await page.locator('[data-instance-page]').innerText()).includes('由「张三」发起'))
|
||
check('实例状态按服务端的说法写(结果未知:等待核实写入结果,不是「等待审批」)',
|
||
(await page.locator('[data-component="instance_header"] span[data-status]').first().innerText()).includes('等待核实写入结果')
|
||
&& !(await page.locator('[data-component="instance_header"]').innerText()).includes('等待审批'))
|
||
check('进度按节点列出', (await page.locator('[data-timeline-step]').count()) === 3)
|
||
check('结果未知的那一步写「结果未知」,不写「完成」',
|
||
(await page.locator('[data-timeline-step="unknown"]').innerText()).includes('结果未知'))
|
||
const other = page.locator('[data-component="task_form"][data-task-handler="no"]')
|
||
check('不是处理人:只画这一步的状态(在等谁),不出红框',
|
||
(await other.locator('[data-task-state="pending"]').count()) === 1 && (await other.locator('[role="alert"]').count()) === 0
|
||
&& (await other.innerText()).includes('等待 王五、赵六 处理'), (await other.innerText()).replace(/\n/g, ' '))
|
||
check('……等待名单写显示名,悬停给用户名', (await other.locator('[data-task-waiting-on]').getAttribute('title')) === 'wangwu、zhaoliu')
|
||
const signs = page.locator('[data-component="signoffs"]')
|
||
check('同一页上有会签组件:待办表单不再画会签进度,会签进度只画一遍', (await other.locator('[data-signoff-head]').count()) === 0
|
||
&& (await signs.locator('[data-signoff-head]').count()) === 1 && (await signs.innerText()).includes('1/2 已同意')
|
||
&& (await signs.innerText()).includes('已升级给岗位「director」'), (await signs.innerText()).replace(/\n/g, ' '))
|
||
const lisi = signs.locator('[data-assignee]').first()
|
||
check('……会签名单写显示名,悬停给用户名', (await lisi.innerText()) === '李四' && (await lisi.getAttribute('title')) === 'lisi'
|
||
&& (await signs.innerText()).includes('赵六'), (await signs.innerText()).replace(/\n/g, ' '))
|
||
check('……也不去取那条待办', !(state.taskReads ?? []).includes('/api/tasks/task_other'), JSON.stringify(state.taskReads))
|
||
await page.locator('[data-component="task_list"] [data-task]').waitFor({ timeout: 5000 })
|
||
check('本实例的待办就地处理(完整的待办卡)', (await page.locator('[data-component="task_list"] [data-task-approve]').count()) === 1)
|
||
check('动作状态写结果未知和环境', /结果未知/.test(await page.locator('[data-action="unknown"]').innerText())
|
||
&& /生产环境/.test(await page.locator('[data-action="unknown"]').innerText()))
|
||
check('结果未知的经过记一笔,不当「失败原因」',
|
||
(await page.locator('[data-action="unknown"] [data-action-error]').count()) === 0
|
||
&& /不会自动重发/.test(await page.locator('[data-action="unknown"] [data-action-note]').innerText()))
|
||
check('关登录(等同管理员)时接运行流', (await page.locator('[data-instance-follow]').getAttribute('data-instance-follow')) === 'stream')
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 页面上直接改(N6)
|
||
|
||
await section('应用页:编辑状态、改文字、隐藏、上下挪、删除、加一张数据卡片、发布', async () => {
|
||
const state = { writes: [], detail: structuredClone(DETAIL) }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_weekly`)
|
||
await page.locator('[data-view-renderer]').waitFor({ timeout: 15000 })
|
||
const start = page.locator('[data-page-edit-start]')
|
||
check('有 manage 的人有「编辑」', await start.waitFor({ timeout: 5000 }).then(() => true, () => false)
|
||
&& (await start.innerText()).includes('编辑'))
|
||
await start.click()
|
||
await page.locator('[data-page-edit-layer]').waitFor({ timeout: 8000 })
|
||
check('编辑状态记在地址上(edit=1)', /[?&]edit=1/.test(page.url()), page.url())
|
||
check('按草稿解析(draft=1)', (state.draftHits ?? 0) >= 1, `${state.draftHits ?? 0} 次`)
|
||
check('顶部的编辑条:写「编辑中」,给「发布」', /编辑中/.test(await page.locator('[data-page-edit-bar]').innerText())
|
||
&& await page.locator('[data-page-edit-publish]').count() === 1)
|
||
const changeData = page.locator('[data-page-edit-change-data]')
|
||
check('应用出自蓝图:给「改取数」,链到蓝图页', await changeData.count() === 1
|
||
&& await changeData.getAttribute('href') === '/blueprints/bp_week', await changeData.getAttribute('href').catch(() => ''))
|
||
|
||
// 隐藏:服务端带 hidden 下发的画成半透明、标「已隐藏」;没下发的列在页底
|
||
const stores = page.locator('[data-node="stores"]')
|
||
check('隐藏的组件画成半透明', await stores.getAttribute('data-node-hidden') === ''
|
||
&& /opacity-45/.test(await stores.getAttribute('class') ?? ''), await stores.getAttribute('class').catch(() => ''))
|
||
check('……旁边标「已隐藏」', await page.locator('[data-hidden-tag]').count() >= 1)
|
||
const hiddenList = page.locator('[data-page-edit-hidden-list]')
|
||
check('服务端没画出来的隐藏组件列在页底,能取消隐藏', await hiddenList.getAttribute('data-page-edit-hidden-list') === '1'
|
||
&& await hiddenList.locator('[data-page-edit-unhide="old_note"]').count() === 1)
|
||
|
||
// 点组件是选中,不打开出处
|
||
await page.locator('[data-node="gmv"] button[data-evidence="metric"]').click()
|
||
const toolbar = page.locator('[data-page-edit-toolbar="gmv"]')
|
||
check('点组件出小工具条(选中)', await toolbar.waitFor({ timeout: 3000 }).then(() => true, () => false))
|
||
check('编辑状态下点数字不打开出处', await page.locator('[data-evidence-panel]').count() === 0 && !state.chainHits)
|
||
const ops = await toolbar.locator('[data-page-edit-op]').evaluateAll((els) => els.map((e) => e.getAttribute('data-page-edit-op')))
|
||
check('小工具条五样:改文字、隐藏、上移、下移、删除', ops.join(',') === 'text,hide,up,down,remove', ops.join(','))
|
||
|
||
// 改文字:只给标了 x-text 的属性(名称、单位),不给换算
|
||
await toolbar.locator('[data-page-edit-op="text"]').click()
|
||
const dlg = page.getByRole('dialog')
|
||
await dlg.locator('[data-page-edit-text]').waitFor({ timeout: 3000 })
|
||
const fields = await dlg.locator('[data-page-edit-text-field]').evaluateAll((els) => els.map((e) => e.getAttribute('data-page-edit-text-field')))
|
||
check('改文字只列给人看的文字(名称、单位),不列换算', fields.join(',') === 'label,unit', fields.join(','))
|
||
await dlg.locator('[data-page-edit-text-field="label"] input').fill('本周销售额')
|
||
const before = state.draftHits ?? 0
|
||
await dlg.locator('[data-page-edit-text-save]').click()
|
||
await page.waitForFunction(() => document.querySelector('[data-page-edit-save]')?.getAttribute('data-page-edit-save') === 'saved', null, { timeout: 5000 }).catch(() => {})
|
||
check('改文字自动存草稿(PATCH 带整份草稿)', findIn(state.patches?.at(-1)?.draft, 'gmv')?.props?.label === '本周销售额',
|
||
JSON.stringify(findIn(state.patches?.at(-1)?.draft, 'gmv')?.props ?? {}))
|
||
check('……存完按草稿重新解析', (state.draftHits ?? 0) > before, `${before} → ${state.draftHits ?? 0}`)
|
||
check('……顶部写「有未发布的改动」', (await page.locator('[data-page-edit-status]').innerText()).includes('有未发布的改动'))
|
||
|
||
// 隐藏
|
||
await page.locator('[data-node="gmv"]').click()
|
||
await page.locator('[data-page-edit-toolbar="gmv"] [data-page-edit-op="hide"]').click()
|
||
await page.waitForTimeout(400)
|
||
check('隐藏:草稿里这个组件 hidden 为真', findIn(state.patches?.at(-1)?.draft, 'gmv')?.hidden === true)
|
||
|
||
// 同一层里下移一格
|
||
await page.locator('[data-node="orders"]').click()
|
||
await page.locator('[data-page-edit-toolbar="orders"] [data-page-edit-op="down"]').click()
|
||
await page.waitForTimeout(400)
|
||
const order = findIn(state.patches?.at(-1)?.draft, 'kpis')?.children?.map((c) => c.id).join(',')
|
||
check('下移:同一层里和下一个换位置', order === 'gmv,shops,orders,aov', order)
|
||
check('第一个组件不能上移', await page.locator('[data-node="stamp"]').click().then(async () =>
|
||
page.locator('[data-page-edit-toolbar="stamp"] [data-page-edit-op="up"]').isDisabled()))
|
||
|
||
// 删除要确认
|
||
await page.locator('[data-node="broken"]').click()
|
||
await page.locator('[data-page-edit-toolbar="broken"] [data-page-edit-op="remove"]').click()
|
||
const confirm = page.locator('[role="dialog"], [role="alertdialog"]').filter({ hasText: '删除「毛利率」?' }).first()
|
||
check('删除先确认、写明后果', await confirm.waitFor({ timeout: 3000 }).then(() => true, () => false))
|
||
await confirm.getByRole('button', { name: '删除组件' }).click()
|
||
await page.waitForTimeout(400)
|
||
check('删除:草稿里没有这个组件了', !!state.patches?.at(-1)?.draft && findIn(state.patches.at(-1).draft, 'broken') === null)
|
||
|
||
// 取消隐藏页底列出的
|
||
await page.locator('[data-page-edit-unhide="old_note"]').click()
|
||
await page.waitForTimeout(400)
|
||
check('页底的「取消隐藏」:草稿里去掉 hidden', findIn(state.patches?.at(-1)?.draft, 'old_note') && !('hidden' in findIn(state.patches.at(-1).draft, 'old_note')))
|
||
|
||
// 加一张数据卡片:候选按类别分组,id 撞了换新的
|
||
await page.locator('[data-page-edit-add-card]').click()
|
||
const cand = page.getByRole('dialog')
|
||
await cand.locator('[data-page-edit-candidates]').waitFor({ timeout: 3000 })
|
||
const groups = await cand.locator('[data-candidate-group]').evaluateAll((els) => els.map((e) => e.getAttribute('data-candidate-group')))
|
||
check('候选按类别分组(口径卡指标、趋势)', groups.join(',') === 'metric,series', groups.join(','))
|
||
check('……组名写中文', (await cand.locator('[data-candidate-group="metric"] h3').innerText()) === '口径卡指标')
|
||
await cand.locator('[data-candidate="gmv"] [data-candidate-add]').click()
|
||
await page.waitForTimeout(400)
|
||
const home = state.patches?.at(-1)?.draft?.pages?.[0]?.body ?? []
|
||
check('加上的卡片落在页尾,id 和已有的撞了换成新的', home.at(-1)?.id === 'gmv_2' && home.at(-1)?.bind?.metric === 'kpi.gmv',
|
||
JSON.stringify(home.at(-1) ?? {}))
|
||
|
||
// 发布
|
||
const writesBefore = state.writes.length
|
||
await page.locator('[data-page-edit-publish]').click()
|
||
const pub = page.locator('[role="dialog"], [role="alertdialog"]').filter({ hasText: '发布这些改动?' }).first()
|
||
await pub.waitFor({ timeout: 3000 })
|
||
await pub.getByRole('button', { name: '发布', exact: true }).click()
|
||
await page.waitForTimeout(500)
|
||
check('「发布」走应用发布', state.writes.slice(writesBefore).some((w) => w.method === 'POST' && w.path === '/api/apps/app_weekly/publish'))
|
||
check('……发布后不再写「有未发布的改动」', !(await page.locator('[data-page-edit-status]').innerText().catch(() => '')).includes('有未发布的改动'))
|
||
|
||
await page.locator('[data-page-edit-done]').click()
|
||
await page.locator('[data-page-edit-start]').waitFor({ timeout: 5000 })
|
||
check('完成编辑:回到使用者看到的样子(地址上去掉 edit)', !/[?&]edit=1/.test(page.url()), page.url())
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
})
|
||
|
||
await section('应用页:只能用的人没有「编辑」,编辑地址也不进编辑状态', async () => {
|
||
const state = { writes: [] }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_claim?edit=1`)
|
||
await page.locator('[data-view-renderer]').waitFor({ timeout: 15000 })
|
||
await page.waitForTimeout(500)
|
||
check('使用者没有「编辑」', await page.locator('[data-page-edit-start]').count() === 0)
|
||
check('带着 edit=1 也不进编辑状态', await page.locator('[data-page-edit-layer]').count() === 0)
|
||
})
|
||
|
||
await section('窄屏:390px 不出横向滚动', async () => {
|
||
const state = { writes: [] }
|
||
const { page } = await open({ width: 390, height: 844, state })
|
||
for (const path of ['/apps', '/apps/app_weekly', '/apps/app_claim/new/apply', '/apps/app_claim/i/run_claim_1']) {
|
||
await page.goto(`${WEB}${path}`)
|
||
await page.waitForTimeout(800)
|
||
check(`${path} 没有横向滚动`, await noHScroll(page))
|
||
}
|
||
})
|
||
|
||
// 推演、请模型判断只给搭建者及以上(接口同样只给):使用者的出处面板里不挂这些按钮,不让人点了再被拒(V1-93)。
|
||
// 同一个面板、关登录时照常有「推演」,两头对照
|
||
await section('使用者的出处面板:不给推演,遮罩说明按使用者说', async () => {
|
||
// 指标链带一列遮罩:面板底下的遮罩说明对谁说什么
|
||
const masked = (page) => page.route(`**/api/runs/${RUN}/evidence/metrics/kpi/gmv`,
|
||
(route) => route.fulfill({ json: { ...METRIC_CHAIN, redacted: { columns: ['phone'] } } }))
|
||
const off = await open({ state: { writes: [] } })
|
||
await masked(off.page)
|
||
await off.page.goto(`${WEB}/apps/app_weekly`)
|
||
await off.page.locator('[data-node="gmv"] button[data-evidence="metric"]').click()
|
||
const offPanel = off.page.locator('[data-evidence-panel]')
|
||
await offPanel.locator('[data-ev-metric]').waitFor({ timeout: 8000 })
|
||
check('关登录(所有人等同管理员):指标上有「推演」', await offPanel.locator('[data-whatif-toggle]').count() === 1)
|
||
check('关登录:遮罩只减少暴露,不是安全边界', (await offPanel.locator('[data-ev-mask-note]').innerText().catch(() => ''))
|
||
.includes('遮罩只减少暴露,不是安全边界'))
|
||
|
||
const state = { writes: [], me: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true } }
|
||
const { page } = await open({ state })
|
||
await masked(page)
|
||
await page.goto(`${WEB}/apps/app_weekly`)
|
||
await page.locator('[data-node="gmv"] button[data-evidence="metric"]').click()
|
||
const panel = page.locator('[data-evidence-panel]')
|
||
await panel.locator('[data-ev-metric]').waitFor({ timeout: 8000 })
|
||
check('使用者:出处照常看,没有「推演」', await panel.locator('[data-whatif-toggle]').count() === 0)
|
||
check('使用者:没有「请模型判断」', await panel.locator('[data-ev-judge-ask]').count() === 0)
|
||
const note = panel.locator('[data-ev-mask-note]')
|
||
check('使用者:遮罩对他是边界,不说「完整快照中仍为原值」', await note.getAttribute('data-ev-mask-note') === 'user'
|
||
&& (await note.innerText()).includes('原值不对使用者开放') && !(await note.innerText()).includes('仍为原值'), await note.innerText().catch(() => ''))
|
||
check('没有发出只给搭建者的请求', !state.writes.some((w) => /whatif|evidence\/judge|inquiries/.test(w.path)),
|
||
state.writes.map((w) => w.path).join(','))
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
})
|
||
|
||
await browser.close()
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
|
||
process.exit(failed ? 1 : 0)
|