Files
AgentLab/scripts/check-personal.mjs
ThearchyHelios 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 项),全部通过。
2026-10-16 11:04:28 +08:00

393 lines
27 KiB
JavaScript
Raw Permalink 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.
// 个人应用的使用面(docs/design/normal-user-apps.md N7、§4.6)的回归检查。
//
// 守的是这些「看着没坏、其实不对」的地方:
// - 使用者建好的个人应用出现在应用首页单独一组「我的应用」,卡片角标写「个人」、不写「未发布」;共享的应用另列一组;
// - 个人应用的页上写明是个人应用(只有自己看得见、遮罩、探索运行未经发布审核),印章的来源照服务端写的
// 「探索 · 未经发布审核」,不另编;没有补算面板;
// - 「刷新」写明今天还剩几次(服务端在详情和刷新回包里给):点一次发 POST /refresh,剩余跟着回包变;用完了按钮置灰、
// 写「今天的刷新次数已用完」;服务端回 429 时照实说服务端的原话,不当成网络错误;
// - 「提交审核」:没选人不让提交;选一位搭建者或一个组、附一句说明,POST /submit-review 带 {to, note};提交后写
// 「审核中」和交给了谁,不再给提交按钮;选审核的人按候选池取(purpose=review:启用的搭建者和管理员,只有名字),
// 不在池里的当场说清(05-已知遗留 (b));
// - 审核状态以服务端为准(详情的 personal,形状同服务端 blueprints/personal.personal_state):审核待办还在等写「审核中」;
// 结论是已发布写「已有正式版本:去看看」并链到那个应用;
// 详情没给时看蓝图的状态(in_review 写「审核中」);
// - 管理员关了「使用者可以建个人应用」:已有的个人应用还看得到,写明暂时不能刷新、提交审核,两个按钮置灰;
// - 开了登录的管理员看别人的个人应用(服务端 api/apps._personal_stub,my_role 为空):应用首页另列一组「使用者的个人应用」,
// 只写名称、拥有者、创建和更新时间,不链到页面、不混进「我的应用」;可以归档(确认框写后果);直接打开它的地址只给
// 元数据和「归档」,不去取页面、蓝图;
// - 别人的共享蓝图(不是拥有者、不管它发布出来的应用):服务端当没有(404),蓝图页写「不存在或已被删除」。
// 使用者在蓝图里「存成我的应用」那一步在 check-blueprint 里查。
//
// 页面是真页面(走真的应用外壳),/api/apps/**、/api/blueprints/** 在浏览器层答掉,数据是这里虚构的(「我的退款周报」);
// 其余 GET 放行到服务端(外壳要它),写请求一律拦下、记在探针里,不碰真实写接口。
//
// node scripts/check-personal.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-personal.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()
// 认人接口:管理员开了「使用者可以建个人应用」(user_personal_apps,服务端 api/auth.principal_full)
const USER = { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: ['门店'], verified: true,
user_personal_apps: true }
const ADMIN = { mode: 'local', username: 'root', display_name: '管理员甲', role: 'admin', groups: [], verified: true,
user_personal_apps: true }
const BUILDER = { mode: 'local', username: 'lisi', display_name: '李四', role: 'builder', groups: [], verified: true,
user_personal_apps: true }
// ---------------------------------------------------------------- 假数据
// 拥有者对自己的个人应用是管理权(服务端 apps/access.personal_role),详情里带草稿和 personal
const MINE = {
id: 'app_mine', name: '我的退款周报', description: '每周看自己门店的退款', icon: 'app', status: 'active',
published_version: null, level: null, needs_upgrade: false, pending_tasks: 0, my_role: 'manage', created_at: ago(86_400_000),
updated_at: ago(600_000), scope: 'personal', owner: 'zhangsan', blueprint_id: 'bp_mine',
}
const SHARED = {
id: 'app_weekly', name: '门店周报', description: '每周一看各门店的销售概况', icon: 'app', status: 'active',
published_version: 3, level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'use', created_at: ago(7 * 86_400_000),
updated_at: ago(3600_000), scope: 'shared', owner: null, blueprint_id: null,
}
// 管理员看到的别人的个人应用:只有元数据(服务端 api/apps._personal_stub,列表、详情、归档的回包同一份)
const STUB = {
id: 'app_mine', name: '我的退款周报', description: '', icon: 'app', status: 'active', published_version: null, level: null,
needs_upgrade: false, pending_tasks: 0, my_role: null, created_at: '2026-10-01T08:00:00+00:00',
updated_at: '2026-10-14T09:30:00+00:00', scope: 'personal', owner: 'zhangsan', blueprint_id: null,
}
/** 详情里的 personal:形状同服务端 blueprints/personal.personal_state */
const personalOf = (over = {}) => ({ allowed: true, remaining_today: 2, daily_limit: 5, blueprint_status: 'personal',
graph_changed: false, review: null, ...over })
/** personal_state.review:最近一条审核待办 */
const reviewOf = (over = {}) => ({ task_id: 't1', status: 'pending', decision: null, note: '', app_id: null, to: [],
resolved_at: null, ...over })
const STAMP = {
as_of: ago(3600_000), as_of_text: '截至 2026-10-14 09:05;统计期 上一 ISO 周', pin: 'mine', graph_hash: '9c1e22aa',
calibers: [], tier: 'formal', tier_text: '完整出具', from: '探索 · 未经发布审核', sealed: true, notes: [],
}
const HOME = {
format: 'agentlab.view/1',
app: { id: 'app_mine', name: '我的退款周报', version: 0, level: 'published', needs_upgrade: false },
page: { id: 'home', title: '本周退款' },
pages: [{ id: 'home', title: '本周退款' }],
sources: { main: { kind: 'exploratory', state: 'ok', run_id: 'run_p1', stamp: STAMP } },
tree: [
{ id: 'stamp', type: 'stamp', props: {}, data: { view: 'main', state: 'ok', stamp: STAMP } },
{ id: 'refund', type: 'metric', props: { label: '退款率' }, data: { state: 'ok', text: '3.2%', name: '退款率' } },
],
forms: {},
problems: [],
}
const DRAFT = { format: 'agentlab.app/1', name: '我的退款周报', pins: [], slots: {}, entries: [], forms: {}, task_ui: {},
views: {}, pages: [{ id: 'home', title: '本周退款', body: [] }], hooks: [], notify: [] }
const detailOf = (personal) => ({ ...MINE, published: null, draft: DRAFT, draft_bindings: { slots: {} }, created_by: 'zhangsan',
...(personal === undefined ? {} : { personal }) })
// ---------------------------------------------------------------- 浏览器
const browser = await chromium.launch({ executablePath: CHROME })
async function open({ state, width = 1280, height = 900 }) {
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') {
state.writes.push({ method: r.method(), path: p, body: r.postData() })
if (p === '/api/apps/app_mine/refresh') {
const next = state.refreshes.shift()
if (!next) return route.fulfill({ status: 503, json: { detail: '检查脚本没有准备更多的刷新回包' } })
return next.status ? route.fulfill({ status: next.status, json: next.json }) : route.fulfill({ json: next })
}
if (p === '/api/apps/app_mine/submit-review') return route.fulfill({ json: { task_id: 'task_review_1' } })
if (p === '/api/apps/app_mine/archive') {
state.archived = true
return route.fulfill({ json: { ...STUB, status: 'archived', published: null } })
}
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') {
// 归档了的不再列(服务端默认不列归档的)
const items = (state.apps ?? [MINE, SHARED]).filter((a) => !(state.archived && a.id === 'app_mine'))
return route.fulfill({ json: { items, can_create: { shared: false, personal: true } } })
}
if (p === '/api/apps/app_mine') return route.fulfill({ json: state.detail })
// 别人的共享蓝图:服务端当没有(blueprints/store.can_access)
if (p === '/api/blueprints/bp_other') return route.fulfill({ status: 404, json: { detail: '蓝图不存在或已被删除' } })
if (p === '/api/apps/app_mine/pages/home') {
state.pageHits = (state.pageHits ?? 0) + 1
return route.fulfill({ json: HOME })
}
if (/^\/api\/apps\/[^/]+\/instances$/.test(p)) return route.fulfill({ json: { items: [] } })
if (p === '/api/blueprints/bp_mine') {
state.blueprintHits = (state.blueprintHits ?? 0) + 1
return route.fulfill({ json: { id: 'bp_mine', name: '我的退款周报', status: state.blueprintStatus ?? 'personal', scope: 'personal' } })
}
return route.continue()
})
return { ctx, page }
}
const bar = (page) => page.locator('[data-personal-bar]')
// ---------------------------------------------------------------- 首页
await section('首页:「我的应用」单独一组,角标「个人」', async () => {
const state = { writes: [], refreshes: [], detail: detailOf(personalOf()) }
const { page } = await open({ state })
await page.goto(`${WEB}/apps?tab=apps`)
const group = page.locator('[data-app-group="personal"]')
check('建好的个人应用出现在「我的应用」一组', await group.waitFor({ timeout: 15000 }).then(() => true, () => false)
&& (await group.locator('h2').innerText()) === '我的应用' && await group.locator('[data-app="app_mine"]').count() === 1)
const card = group.locator('[data-app="app_mine"]')
check('卡片角标写「个人」', (await card.locator('[data-app-personal]').innerText()) === '个人')
check('……不写「未发布」(个人应用没有应用版本)', !(await card.innerText()).includes('未发布'), (await card.innerText()).replace(/\s+/g, ' '))
check('……没有「编辑」链接', await card.locator('a[href$="/edit"]').count() === 0)
check('共享的应用另列一组,不混进「我的应用」', await page.locator('[data-app-group="shared"] [data-app="app_weekly"]').count() === 1
&& await group.locator('[data-app="app_weekly"]').count() === 0)
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 应用页
await section('应用页:写明是个人应用、印章照服务端写、刷新写剩余次数', async () => {
const state = {
writes: [], detail: detailOf(personalOf()),
refreshes: [{ run_id: 'run_p2', remaining: 1 }, { run_id: 'run_p3', remaining: 0 }],
}
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_mine`)
await bar(page).waitFor({ timeout: 15000 })
check('页上写明:只有你看得见、未经发布审核', /只有你看得见/.test(await page.locator('[data-personal-banner]').innerText())
&& /未经发布审核/.test(await page.locator('[data-personal-banner]').innerText()))
check('页头角标写「个人」', (await page.locator('header [data-app-personal]').innerText()) === '个人')
check('印章的来源照服务端写「探索 · 未经发布审核」', (await page.locator('[data-stamp-row="from"]').innerText()).includes('探索 · 未经发布审核'))
check('拥有者对自己的个人应用有管理权:可以在页面上直接改', await page.locator('[data-page-edit-start]').count() === 1)
check('……开关开着:没有「暂时不能刷新」的提示', await page.locator('[data-personal-off]').count() === 0)
const left = page.locator('[data-personal-remaining]')
check('「刷新」旁写明今天还剩几次(服务端给的)', (await left.innerText()) === '今天还剩 2 次', await left.innerText())
const hits = state.pageHits ?? 0
await page.locator('[data-personal-refresh]').click()
await page.waitForFunction(() => document.querySelector('[data-personal-remaining]')?.getAttribute('data-personal-remaining') === '1',
null, { timeout: 5000 }).catch(() => {})
check('点「刷新」发 POST /refresh', state.writes.filter((w) => w.path === '/api/apps/app_mine/refresh').length === 1)
check('……剩余次数按回包变成 1', (await left.innerText()) === '今天还剩 1 次', await left.innerText())
check('……刷新后重取页面', (state.pageHits ?? 0) > hits, `${hits} → ${state.pageHits ?? 0}`)
await page.locator('[data-personal-refresh]').click()
await page.waitForFunction(() => document.querySelector('[data-personal-remaining]')?.getAttribute('data-personal-remaining') === '0',
null, { timeout: 5000 }).catch(() => {})
check('用完了:写「今天的刷新次数已用完」', (await left.innerText()).includes('今天的刷新次数已用完'), await left.innerText())
check('……按钮置灰,不再发', await page.locator('[data-personal-refresh]').isDisabled())
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('刷新:服务端回 429 时照实说原话、按钮置灰;不知道剩几次时只说有上限', async () => {
const state = {
writes: [], detail: detailOf(undefined),
refreshes: [{ status: 429, json: { detail: '今天的刷新次数已用完(每天 5 次)', code: 'personal_refresh_limit' } }],
}
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_mine`)
await bar(page).waitFor({ timeout: 15000 })
check('详情没给剩余次数:不猜,只说有上限', (await page.locator('[data-personal-remaining]').innerText()).includes('有上限'))
await page.locator('[data-personal-refresh]').click()
const toast = page.getByText('今天的刷新次数已用完(每天 5 次)')
check('429:照实写服务端的原话', await toast.first().waitFor({ timeout: 5000 }).then(() => true, () => false))
check('……按钮置灰', await page.locator('[data-personal-refresh]').isDisabled())
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 提交审核
await section('提交审核:选人才能交、带说明;提交后写「审核中」', async () => {
const state = { writes: [], refreshes: [], detail: detailOf(personalOf({ remaining_today: 5 })) }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_mine`)
await bar(page).waitFor({ timeout: 15000 })
await page.locator('[data-personal-submit-review]').click()
const dlg = page.getByRole('dialog')
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(() => {})
const sent = state.writes.find((w) => w.path === '/api/apps/app_mine/submit-review')
const body = sent ? JSON.parse(sent.body || '{}') : {}
check('POST /submit-review 带 {to, note}', JSON.stringify(body.to) === JSON.stringify([{ kind: 'user', name: 'lisi' }])
&& body.note === '门店店长都想看', JSON.stringify(body))
const status = page.locator('[data-personal-review="in_review"]')
check('提交后写「审核中」和交给了谁', /审核中/.test(await status.innerText()) && /lisi/.test(await status.innerText()),
await status.innerText().catch(() => ''))
check('……不再给「提交审核」', await page.locator('[data-personal-submit-review]').count() === 0)
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('审核状态:以服务端为准(审核中、已有正式版本、详情没给时看蓝图)', async () => {
const a = { writes: [], refreshes: [], detail: detailOf(personalOf({ remaining_today: 3, blueprint_status: 'in_review',
review: reviewOf({ to: [{ kind: 'group', name: '搭建组' }] }) })) }
const one = await open({ state: a })
await one.page.goto(`${WEB}/apps/app_mine`)
await bar(one.page).waitFor({ timeout: 15000 })
check('详情说审核中:写「审核中」和交给的组', /已提交给 搭建组 审核/.test(await one.page.locator('[data-personal-review="in_review"]').innerText()))
check('……没有「提交审核」', await one.page.locator('[data-personal-submit-review]').count() === 0)
const b = { writes: [], refreshes: [], detail: detailOf(personalOf({ remaining_today: 3,
review: reviewOf({ status: 'resolved', decision: 'published', app_id: 'app_refund', resolved_at: ago(600_000) }) })) }
const two = await open({ state: b })
await two.page.goto(`${WEB}/apps/app_mine`)
await bar(two.page).waitFor({ timeout: 15000 })
const pub = two.page.locator('[data-personal-review="published"]')
check('审核发布后写「已有正式版本」', /已有正式版本/.test(await pub.innerText()), await pub.innerText().catch(() => ''))
check('……「去看看」链到正式应用', await pub.locator('[data-personal-formal]').getAttribute('href') === '/apps/app_refund')
const c = { writes: [], refreshes: [], detail: detailOf(undefined), blueprintStatus: 'in_review' }
const three = await open({ state: c })
await three.page.goto(`${WEB}/apps/app_mine`)
await bar(three.page).waitFor({ timeout: 15000 })
await three.page.locator('[data-personal-review="in_review"]').waitFor({ timeout: 5000 }).catch(() => {})
check('详情没给审核状态:看蓝图(in_review 写「审核中」)', (c.blueprintHits ?? 0) >= 1
&& await three.page.locator('[data-personal-review="in_review"]').count() === 1)
check('没有未捕获的运行时错误', [one, two, three].every((x) => x.page.errors.length === 0),
[one, two, three].map((x) => x.page.errors[0]).filter(Boolean).join(' | '))
})
await section('审核退回:写退回和原因,可以再提交', async () => {
const state = { writes: [], refreshes: [], detail: detailOf(personalOf({
review: reviewOf({ status: 'resolved', decision: 'reject', note: '口径和财务的对不上', resolved_at: ago(600_000) }) })) }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_mine`)
await bar(page).waitFor({ timeout: 15000 })
const rejected = page.locator('[data-personal-review="rejected"]')
check('退回了:写退回和原因', (await rejected.innerText().catch(() => '')).includes('口径和财务的对不上'), await rejected.innerText().catch(() => ''))
check('……可以再提交', await page.locator('[data-personal-submit-review]').isEnabled())
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('开关关了:已有的个人应用还看得到,暂时不能刷新、提交审核', async () => {
// 能不能刷新、提交审核由服务端在详情里给(personal_state.allowed,同 blueprints/personal.allowed),界面不按开关自己推
const state = { me: { ...USER, user_personal_apps: false }, writes: [], refreshes: [],
detail: detailOf(personalOf({ allowed: false })) }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_mine`)
await bar(page).waitFor({ timeout: 15000 })
check('写明管理员关了开关、暂时不能刷新和提交审核', (await page.locator('[data-personal-off]').innerText().catch(() => ''))
.includes('暂时不能刷新、提交审核'))
check('……「刷新」「提交审核」置灰', await page.locator('[data-personal-refresh]').isDisabled()
&& await page.locator('[data-personal-submit-review]').isDisabled())
check('……页面照常显示', await page.locator('[data-component="metric"], [data-node="refund"]').count() >= 1)
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('管理员看别人的个人应用:只有名称、拥有者、时间,可以归档', async () => {
const state = { me: ADMIN, apps: [STUB, { ...SHARED, my_role: 'manage' }], writes: [], refreshes: [],
detail: { ...STUB, published: null } }
const { page } = await open({ state })
await page.goto(`${WEB}/apps?tab=apps`)
const group = page.locator('[data-app-group="others"]')
check('另列一组「使用者的个人应用」', await group.waitFor({ timeout: 15000 }).then(() => true, () => false)
&& (await group.locator('h2').innerText()) === '使用者的个人应用')
check('……写明只看得到名称、拥有者和时间,看不到内容', (await group.innerText()).includes('看不到页面、运行和蓝图的内容'))
const card = group.locator('[data-app-stub="app_mine"]')
const text = (await card.innerText()).replace(/\s+/g, ' ')
check('卡片写名称、拥有者、创建和更新时间', text.includes('我的退款周报') && text.includes('拥有者:zhangsan')
&& await card.locator('[data-app-stub-created]').count() === 1 && await card.locator('[data-app-stub-updated]').count() === 1, text)
check('……不链到应用页(看不到内容)', await card.locator('a[href="/apps/app_mine"]').count() === 0)
check('……不混进「我的应用」', await page.locator('[data-app-group="personal"]').count() === 0)
await card.locator('[data-app-stub-archive]').click()
const dlg = page.getByRole('dialog')
await dlg.waitFor({ timeout: 5000 })
const said = await dlg.innerText()
check('归档先确认,写明后果(拥有者不能再刷新、没有恢复)', said.includes('不能再刷新或提交审核') && said.includes('没有恢复'), said.replace(/\s+/g, ' '))
await dlg.getByRole('button', { name: '归档个人应用' }).click()
await page.waitForTimeout(400)
check('确认后 POST /archive', state.writes.some((w) => w.path === '/api/apps/app_mine/archive'))
check('……归档之后列表里不再有它', await page.locator('[data-app-stub="app_mine"]').waitFor({ state: 'detached', timeout: 5000 })
.then(() => true, () => false))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
// 直接打开它的地址:只给元数据和「归档」,不去取页面、蓝图
const s2 = { ...state, archived: false, writes: [], pageHits: 0, blueprintHits: 0 }
const two = await open({ state: s2 })
await two.page.goto(`${WEB}/apps/app_mine`)
const stubPage = two.page.locator('[data-app-stub-page="app_mine"]')
check('打开地址:写明这是谁的个人应用、看不到内容', await stubPage.waitFor({ timeout: 15000 }).then(() => true, () => false)
&& (await stubPage.locator('[data-app-stub-notice]').innerText()).includes('这是 zhangsan 的个人应用'))
check('……给「归档」', await stubPage.locator('[data-app-stub-archive]').count() === 1)
await two.page.waitForTimeout(500)
check('……不去取页面、蓝图', s2.pageHits === 0 && s2.blueprintHits === 0, `页面 ${s2.pageHits} 次、蓝图 ${s2.blueprintHits} 次`)
check('……没有刷新、提交审核', await two.page.locator('[data-personal-refresh], [data-personal-submit-review]').count() === 0)
check('没有未捕获的运行时错误', two.page.errors.length === 0, two.page.errors[0] ?? '')
})
await section('别人的共享蓝图:服务端当没有,蓝图页写「不存在或已被删除」', async () => {
const state = { me: BUILDER, writes: [], refreshes: [], detail: detailOf(personalOf()) }
const { page } = await open({ state })
await page.goto(`${WEB}/blueprints/bp_other`)
check('写「蓝图不存在或已被删除」,不说它在不在', await page.getByText('蓝图不存在或已被删除').first().waitFor({ timeout: 15000 })
.then(() => true, () => false))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('窄屏:390px 不出横向滚动', async () => {
const state = { writes: [], refreshes: [], detail: detailOf(personalOf()) }
const { page } = await open({ state, width: 390, height: 844 })
for (const path of ['/apps?tab=apps', '/apps/app_mine']) {
await page.goto(`${WEB}${path}`)
await page.waitForTimeout(800)
check(`${path} 没有横向滚动`, await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1))
}
})
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
process.exit(failed ? 1 : 0)