mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:21:58 +00:00
262 lines
17 KiB
JavaScript
262 lines
17 KiB
JavaScript
// 个人应用的使用面(docs/design/normal-user-apps.md N7、§4.6)的回归检查。
|
|
//
|
|
// 守的是这些「看着没坏、其实不对」的地方:
|
|
// - 使用者建好的个人应用出现在应用首页单独一组「我的应用」,卡片角标写「个人」、不写「未发布」;共享的应用另列一组;
|
|
// - 个人应用的页上写明是个人应用(只有自己看得见、遮罩、探索运行未经发布审核),印章的来源照服务端写的
|
|
// 「探索 · 未经发布审核」,不另编;没有补算面板;
|
|
// - 「刷新」写明今天还剩几次(服务端在详情和刷新回包里给):点一次发 POST /refresh,剩余跟着回包变;用完了按钮置灰、
|
|
// 写「今天的刷新次数已用完」;服务端回 429 时照实说服务端的原话,不当成网络错误;
|
|
// - 「提交审核」:没选人不让提交;选一位搭建者或一个组、附一句说明,POST /submit-review 带 {to, note};提交后写
|
|
// 「审核中」和交给了谁,不再给提交按钮;
|
|
// - 审核状态以服务端为准(详情的 personal,形状同服务端 blueprints/personal.personal_state):审核待办还在等写「审核中」;
|
|
// 结论是已发布写「已有正式版本:去看看」并链到那个应用;
|
|
// 详情没给时看蓝图的状态(in_review 写「审核中」)。
|
|
// 使用者在蓝图里「存成我的应用」那一步在 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()
|
|
const USER = { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: ['门店'], verified: true }
|
|
|
|
// ---------------------------------------------------------------- 假数据
|
|
|
|
const MINE = {
|
|
id: 'app_mine', name: '我的退款周报', description: '每周看自己门店的退款', icon: 'app', status: 'active',
|
|
published_version: null, level: null, needs_upgrade: false, pending_tasks: 0, my_role: 'use', 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', updated_at: ago(3600_000),
|
|
scope: 'shared', owner: null, blueprint_id: null,
|
|
}
|
|
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 detailOf = (personal) => ({ ...MINE, published: null, ...(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' } })
|
|
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
|
|
}
|
|
if (p === '/api/auth/me') return route.fulfill({ json: state.me ?? USER })
|
|
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') return route.fulfill({ json: { items: [MINE, SHARED] } })
|
|
if (p === '/api/apps/app_mine') return route.fulfill({ json: state.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({ daily_limit: 5, remaining_today: 2, review: null }) }
|
|
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({ daily_limit: 5, remaining_today: 2, review: null }),
|
|
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() === 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({ daily_limit: 5, remaining_today: 5, review: null }) }
|
|
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('lisi')
|
|
await dlg.getByRole('button', { name: '添加' }).click()
|
|
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({ remaining_today: 3, review: { task_id: 't1', status: 'pending', 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({ remaining_today: 3,
|
|
review: { task_id: 't1', status: 'resolved', decision: 'published', app_id: 'app_refund' } }) }
|
|
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('窄屏:390px 不出横向滚动', async () => {
|
|
const state = { writes: [], refreshes: [], detail: detailOf({ remaining_today: 2, review: null }) }
|
|
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)
|