From 335dc99c3e5c934ed315ee83e2367bb665ffab26 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 11:04:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A3=80=E6=9F=A5=E8=84=9A=E6=9C=AC=E8=B7=9F?= =?UTF-8?q?=E4=B8=8A=EF=BC=9A=E9=A9=B3=E5=9B=9E=E3=80=81=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=80=85=E9=80=89=E4=BA=BA=E3=80=81=E8=87=AA=E7=94=B1=E5=BD=A2?= =?UTF-8?q?=E3=80=8C=E8=B0=81=E6=9D=A5=E5=81=9A=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:前几个提交改了驳回的终态、选人控件的候选、自由形蓝图的「谁来做」,浏览器检查里的伪造数据和断言要守住新说法 (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 项),全部通过。 --- scripts/check-apps.mjs | 79 +++++++++++++++++++++++++++++++++++-- scripts/check-blueprint.mjs | 54 ++++++++++++++++++++++++- scripts/check-inbox.mjs | 51 +++++++++++++++++++++++- scripts/check-personal.mjs | 17 +++++++- 4 files changed, 194 insertions(+), 7 deletions(-) diff --git a/scripts/check-apps.mjs b/scripts/check-apps.mjs index 549b9e84..a1e786f5 100644 --- a/scripts/check-apps.mjs +++ b/scripts/check-apps.mjs @@ -9,10 +9,11 @@ // 不认识的组件写「此组件需要更新前端」,不报错、不空白;服务端解析出错的组件(problems)列在页面顶上; // 有视图在生成时自动重取; // - 入口页:必填没填本地拦下、错误挂到字段下面;服务端 422 的 {errors:[{path, code, message}]} 按 path 挂回字段, -// 对不上字段的列在表单顶上;⌘/Ctrl+Enter 发起;成功后进实例页;需升级时不让发起; +// 对不上字段的列在表单顶上;⌘/Ctrl+Enter 发起;成功后进实例页;需升级时不让发起;使用者在选人控件里边输入边按 +// 候选池取候选(GET /api/directory/candidates,只有名字),不在池里的当场说清,不去取整份名录(05-已知遗留 (b)); // - 实例页:状态(服务端给的说法:结果未知写「等待核实写入结果」)、印章、进度(结果未知的那一步不写「完成」)、 // 本实例的待办卡(就地处理);不是处理人的待办表单只画这一步的状态、不去取待办;动作状态(哈希前 8 位、环境; -// 结果未知的经过记一笔,不当失败原因); +// 结果未知的经过记一笔,不当失败原因);被驳回的实例照服务端的说法写「「付款」未确认(被驳回)」,不写失败(05-已知遗留 (a)); // - 窄屏(390px)不出横向滚动; // - 页面上直接改(normal-user-apps N6):有 manage 的人点「编辑」进编辑状态(地址记 edit=1),按草稿解析;点组件是选中、 // 不打开出处;小工具条改文字只给目录标了 x-text 的属性;隐藏、上移、删除都自动存草稿(PATCH 带整份草稿)后按草稿 @@ -340,6 +341,15 @@ async function open({ width = 1280, height = 900, state }) { 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 } }) @@ -360,7 +370,7 @@ async function open({ width = 1280, height = 900, state }) { 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: 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 }) @@ -555,6 +565,46 @@ await section('入口:本地校验、422 挂回字段、发起后进实例页' 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 }) @@ -566,6 +616,29 @@ await section('入口:需升级时不让发起', async () => { // ---------------------------------------------------------------- 实例 +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 }) diff --git a/scripts/check-blueprint.mjs b/scripts/check-blueprint.mjs index eb86799e..5326619f 100644 --- a/scripts/check-blueprint.mjs +++ b/scripts/check-blueprint.mjs @@ -9,6 +9,8 @@ // - 卡片:卡头标状态(缺按服务端检查、待确认、已确认),对到数据的项写表、中文名、粒度、业务日期、为什么;「修改」发 bp_set // (纯值),确认只改状态,「确认全部」一次带上整张卡片待确认的几项,加一项发 bp_add(纯值);缺项时「用真数试一次」置灰并写清缺什么; // - 写回卡片:目标能力没登记查询时摆「结果不明时谁去核实」(服务端 reconciler_needed),缺时只挡发布,选人发 bp_set; +// - 自由形的「谁来做」(05-已知遗留 (d)):按服务端的 derived_people 一行一行摆(岗位、核实人能选人,发起人、表达式的 +// 只读),不能加减步骤、不改会签;没绑的照服务端的 bp.people_unbound 标「缺」;选人发 bp_set 到那一行的 path; // - 反问:助手的 bp_ask 画成可点的选项,「这一轮在等你回答」常显,输入框提示回答;点选项就是把它作为下一轮发出去(回答记在 // 问题上、这一轮落进蓝图对话都是服务端做的,前端不另记); // - 关节:只列生成关节,按卡片填的汇总一句;「让助手填」走 fill-joints-stream,逐组写进度,组之间并行; @@ -77,7 +79,8 @@ const TEMPLATES = [ cards: [{ card: 'purpose', note: '必填' }, { card: 'fields', note: '必填:发起时填什么' }, { card: 'people', note: '必填:谁审批' }, { card: 'writeback', note: '可选:通过后写回哪个系统' }, { card: 'access', note: '' }] }, { id: 'freeform', version: 1, label: '自由形', description: '从已有的运行或工作流导入', applies_when: '', tags: [], personal: '只读部分', - cards: [{ card: 'purpose', note: '' }, { card: 'cadence', note: '' }, { card: 'access', note: '' }] }, + cards: [{ card: 'purpose', note: '' }, { card: 'cadence', note: '' }, + { card: 'people', note: '可选:只定每一步由谁做;会签、时限、超时在画布上改' }, { card: 'access', note: '' }] }, ] const item = (value, source = 'assistant', status, why = null) => @@ -550,6 +553,55 @@ await section('写回:结果不明时谁去核实(能力没登记查询才 && await third.page.locator('[data-bp-reconciler-hint="unused"]').count() === 1) }) +// ---------------------------------------------------------------- 自由形:谁来做 + +/** 从已发布的「财务审批(岗位)+ 写回(没登记查询)」工作流导入的自由形蓝图:服务端按图列出的「谁来做」(freeform.people_rows) */ +const FREE_ROWS = [ + { kind: 'slot', slot: 'finance', label: '「财务审批」', detail: '「财务审批」处理', path: '/people/0/who', who: null, + uses: [{ node_id: 'review', title: '财务审批', role: 'assignee' }] }, + { kind: 'reconciler', slot: 'reconciler', label: '写入结果不明时谁去核实', detail: '「写回报销单」写入结果不明时去核实', + path: '/writeback/reconciler', who: null, uses: [{ node_id: 'pay', title: '写回报销单', role: 'reconcile' }] }, + { kind: 'initiator', slot: null, label: '「本人确认」', detail: '由发起人自己处理', path: null, who: null, + uses: [{ node_id: 'me', title: '本人确认', role: 'assignee' }] }, +] +const unboundAt = (path, message) => ({ code: 'bp.people_unbound', level: 'error', path, message, slot: 'x', + plain: { title: '有一步还没定谁来处理', why: '流程走到这一步就没人接手', who: 'you', action: '在「谁来做」里选人、组或岗位。' } }) +const FREE = () => ({ ...clone(BP), template_id: 'freeform', + spec: { ...clone(SPEC), template: { id: 'freeform', version: 1 }, data: [], people: [] }, + joints: {}, derived_people: clone(FREE_ROWS), + issues: [unboundAt('/people/0/who', '「财务审批」还没定谁来处理(「财务审批」处理)'), + unboundAt('/writeback/reconciler', '写入结果不明时谁去核实还没定')] }) + +await section('自由形:「谁来做」按导入的图列出,只定由谁做(05-已知遗留 (d))', async () => { + const state = { bp: FREE() } + const { page } = await open({ state }) + await page.goto(`${WEB}/blueprints/bp_week`) + const card = page.locator('[data-card="people"]') + await card.waitFor({ timeout: 15000 }) + check('按服务端给的行摆:岗位、核实人、只读的发起人', (await card.locator('[data-bp-derived]').evaluateAll( + (els) => els.map((e) => e.getAttribute('data-bp-derived')))).join(',') === 'slot,reconciler,initiator') + check('行名、用途照服务端写(节点在图上的名字)', (await card.locator('[data-bp-derived="slot"]').innerText()).includes('「财务审批」处理')) + check('没绑的标「缺」,卡头也是缺', await card.locator('[data-bp-item="/people/0/who"]').getAttribute('data-item-status') === 'missing' + && await card.locator('[data-bp-item="/writeback/reconciler"]').getAttribute('data-item-status') === 'missing' + && await card.getAttribute('data-card-status') === 'missing') + check('卡片上照白话写缺什么、怎么改(核实人那一条也归在这张卡片上)', + (await card.locator('[data-card-issues]').getAttribute('data-card-issues')) === '2') + check('发起人的那一步只读:没有「修改」', await card.locator('[data-bp-derived="initiator"] [data-bp-edit]').count() === 0) + check('不能加减步骤:没有「加一步」、没有删除', await card.locator('[data-bp-add="/people"]').count() === 0 + && await card.locator('[data-bp-remove]').count() === 0) + const row = card.locator('[data-bp-item="/people/0/who"]') + await row.locator('[data-bp-edit]').click() + const editor = row.locator('[data-bp-editor]') + await editor.locator('[data-principal-picker] input').fill('zhang') + await editor.locator('[data-principal-picker] input').press('Enter') + await row.locator('[data-bp-save]').click() + check('选人:bp_set 到那一行的 path,值是一组人或组', await until(() => (state.patches ?? []).length === 1) + && JSON.stringify(state.patches[0].ops) === JSON.stringify([ + { op: 'bp_set', path: '/people/0/who', value: [{ kind: 'user', name: 'zhang' }] }]), + JSON.stringify(state.patches?.[0])) + check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') +}) + // ---------------------------------------------------------------- 反问 await section('反问:选项可点、「这一轮在等你回答」常显、回答记在问题上', async () => { diff --git a/scripts/check-inbox.mjs b/scripts/check-inbox.mjs index 6bb2ee6c..1fba2777 100644 --- a/scripts/check-inbox.mjs +++ b/scripts/check-inbox.mjs @@ -11,7 +11,8 @@ // - 不是派给我的(can_respond 为假):只读,写明原因;同意过、还在等别人的写「你已处理:同意」,不写「没有派给你」; // - 升级之后:升级加派的那一份不算进会签人数,另写升级后的规则(升级给谁、其中一人说了算),名单里标「升级」; // 待办箱一行写所属实例,同标题的分得开; -// - 补充输入带表单的:本地校验,服务端 422 的错误挂回字段; +// - 补充输入带表单的:本地校验,服务端 422 的错误挂回字段;表单里的选人控件按这条待办的候选池取候选(使用者读不到 +// 整份名录),不在池里的服务端 422(code=principal)挂回那个字段(05-已知遗留 (b)); // - 关登录时签批人一行写本机署名;会签只记下一票(resolved 为假)时提示「等待其他人处理」; // - 审核个人应用(kind=review,payload 同服务端 blueprints/personal.review_payload):写提交人(显示名)、目的、说明;没有 // 「同意」——「取副本,去发布」调 POST /api/blueprints/{id}/copy 后进副本蓝图;取过的给「打开我的副本」(只认自己取的那 @@ -133,6 +134,13 @@ const TASKS = { { id: 'bp_daily_mine', owner: '审批人甲', status: 'trialed', app_id: null }] }, people: { zhangsan: '张三' }, }, + // 补充输入里选复核人(选人控件):使用者按这条待办的候选池选 + 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: { @@ -162,6 +170,10 @@ async function open(state) { 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' @@ -171,7 +183,17 @@ async function open(state) { 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: { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } }) + 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: '蓝图不存在或已被删除' } }) if (p === '/api/tasks/count') return route.fulfill({ json: { mine: Object.keys(TASKS).length, apps: Object.keys(TASKS).length, other: 0 } }) @@ -306,6 +328,31 @@ await section('补充输入:表单校验、422 挂回字段', async () => { 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) diff --git a/scripts/check-personal.mjs b/scripts/check-personal.mjs index feb5761b..e3af84c1 100644 --- a/scripts/check-personal.mjs +++ b/scripts/check-personal.mjs @@ -7,7 +7,8 @@ // - 「刷新」写明今天还剩几次(服务端在详情和刷新回包里给):点一次发 POST /refresh,剩余跟着回包变;用完了按钮置灰、 // 写「今天的刷新次数已用完」;服务端回 429 时照实说服务端的原话,不当成网络错误; // - 「提交审核」:没选人不让提交;选一位搭建者或一个组、附一句说明,POST /submit-review 带 {to, note};提交后写 -// 「审核中」和交给了谁,不再给提交按钮; +// 「审核中」和交给了谁,不再给提交按钮;选审核的人按候选池取(purpose=review:启用的搭建者和管理员,只有名字), +// 不在池里的当场说清(05-已知遗留 (b)); // - 审核状态以服务端为准(详情的 personal,形状同服务端 blueprints/personal.personal_state):审核待办还在等写「审核中」; // 结论是已发布写「已有正式版本:去看看」并链到那个应用; // 详情没给时看蓝图的状态(in_review 写「审核中」); @@ -134,6 +135,13 @@ async function open({ state, width = 1280, height = 900 }) { return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } }) } if (p === '/api/auth/me') return route.fulfill({ json: state.me ?? USER }) + // 提交审核选人的候选池(purpose=review):启用的搭建者和管理员,只有名字 + if (p === '/api/directory/candidates') { + (state.candidateCalls ??= []).push(Object.fromEntries(url.searchParams)) + const builders = [{ username: 'lisi', display_name: '李四' }, { username: 'root', display_name: '管理员甲' }] + return route.fulfill({ json: { users: builders.filter((u) => u.username.startsWith(url.searchParams.get('q') ?? '')), + groups: [], truncated: false } }) + } if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 0, apps: 0, other: 0 } }) if (p === '/api/tasks') return route.fulfill({ json: { items: [] } }) if (p === '/api/apps') { @@ -240,8 +248,15 @@ await section('提交审核:选人才能交、带说明;提交后写「审 await dlg.locator('[data-personal-review-dialog]').waitFor({ timeout: 5000 }) check('写明搭建者在副本上发布、个人应用不会被改动', /副本/.test(await dlg.innerText()) && /不会被改动/.test(await dlg.innerText())) check('没选人不让提交', await dlg.locator('[data-personal-review-submit]').isDisabled()) + await dlg.getByRole('textbox', { name: '交给谁审核' }).fill('wangwu') + await dlg.getByRole('button', { name: '添加' }).click() + await dlg.locator('[data-principal-refused="hard"]').waitFor({ timeout: 5000 }).catch(() => {}) + check('不是搭建者的名字当场说清,不收', /「wangwu」不在可选的人里/.test(await dlg.locator('[data-principal-refused]').innerText().catch(() => ''))) + check('……候选按提交审核的池问(purpose=review)', (state.candidateCalls ?? []).some((c) => c.purpose === 'review'), + JSON.stringify(state.candidateCalls ?? [])) await dlg.getByRole('textbox', { name: '交给谁审核' }).fill('lisi') await dlg.getByRole('button', { name: '添加' }).click() + await dlg.locator('[data-principal="user"]').waitFor({ timeout: 5000 }).catch(() => {}) await dlg.locator('#personal-review-note').fill('门店店长都想看') await dlg.locator('[data-personal-review-submit]').click() await page.locator('[data-personal-review="in_review"]').waitFor({ timeout: 5000 }).catch(() => {})