mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:01:58 +00:00
7739340607
- 问数据和个人应用的图放行绑定只读查询等合规工具的调用工具节点,只读和遮罩在这条路径上生效;被治理成写入的工具在只读运行里直接拒;知识库范围在工具层固定(图级默认值、参数名纠错、Agent 都改不了) - 个人应用的刷新和个人蓝图的试运行只有拥有者能读:运行、事件、证据、工件、文件、运行与审批列表、对比、可验证报告、质询、假设分析、提取工作流一并收紧;个人蓝图的试运行不论谁发起都按个人应用约束 - 共享蓝图只给拥有者、对应应用的管理者、管理员,看不到的一律 404;审核副本归审核人;管理员看别人的个人应用只有元数据、可以归档 - 记录表跨版本汇总(只读挂载旧版本、按当前列 UNION ALL、各版本各自固定高水位,缺的版本照实写进印章);停用前发起的运行可以继续写旧版本;更正沿用原提交人、更正人记审计 - 约定收口:待办种类 review、事件 file.extracted、表单错误 file、模板定义带示例与预置;前端类型一次补齐、去掉临时的局部类型;审核待办卡不再给打不开的链接;读附件事件有中文说法;检查脚本补断言
233 lines
15 KiB
JavaScript
233 lines
15 KiB
JavaScript
// 记录列表 records_list(docs/design/normal-user-apps.md N8、§3.7、§4.7)的回归检查。
|
||
//
|
||
// 守的是这些「看着没坏、其实不对」的地方:
|
||
// - 行级可见在服务端做,界面只照服务端说的写:使用者(mine_only)的标题是「我的记录」、写明只显示自己提交的、不列提交人;
|
||
// 查看全部的人标题是「全部记录」、每行写提交人;界面不自己按人过滤(服务端交来几行就列几行);
|
||
// - 数据取自 GET /api/apps/{id}/records(带 table),每次打开实时取,不走封存;服务端解析页面时给了第一页(node.data)
|
||
// 的照它画、不再取一遍;
|
||
// - 按时间排序(序号就是提交先后),默认最新的在前,可以换成最早的在前;按内容或提交人筛选,写明筛出几条;
|
||
// - 每行可以展开看全部字段,附件是可下载的链接(/api/files/{id});
|
||
// - 更正不改原行:更正过的行标「已更正」,展开能看更正历史(带 history=true 再取一次,顺着指向往前找),原来那条的
|
||
// 值照原样写;
|
||
// - 没写记录表的组件取这个应用的第一张记录表(同服务端),照样列出来;服务端说这个应用还没有记录表(state=empty)的
|
||
// 照实说,不报错。
|
||
//
|
||
// 页面是真页面(走真的应用外壳),/api/apps/** 在浏览器层答掉,数据是这里虚构的(「门店报损」);其余 GET 放行到服务端
|
||
// (外壳要它),写请求一律拦下、记在探针里,不碰真实写接口。
|
||
//
|
||
// node scripts/check-records.mjs
|
||
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-records.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 at = (h) => new Date(Date.UTC(2026, 9, 12, h)).toISOString()
|
||
const USER = { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: ['门店'], verified: true }
|
||
const LEAD = { mode: 'local', username: 'wangwu', display_name: '王五', role: 'builder', groups: ['区域'], verified: true }
|
||
|
||
// ---------------------------------------------------------------- 假数据
|
||
|
||
/** 服务端解析页面时给的第一页里的列(和接口给的一样,单独写一份免得引用顺序出错) */
|
||
const COLUMNS_FIRST = [{ name: 'store', label: '门店', type: 'string' }]
|
||
const APP = {
|
||
id: 'app_loss', name: '门店报损', description: '门店每日报损登记', icon: 'app', status: 'active', published_version: 2,
|
||
level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'use', updated_at: at(1), scope: 'shared',
|
||
}
|
||
const HOME = {
|
||
format: 'agentlab.view/1',
|
||
app: { id: 'app_loss', name: '门店报损', version: 2, level: 'published', needs_upgrade: false },
|
||
page: { id: 'home', title: '报损' },
|
||
pages: [{ id: 'home', title: '报损' }],
|
||
sources: {},
|
||
tree: [
|
||
{ id: 'my_records', type: 'records_list', props: { table: 'loss', title: '报损登记' } },
|
||
{ id: 'no_table', type: 'records_list', props: {} },
|
||
{ id: 'server_page', type: 'records_list', props: { who: 'mine' },
|
||
data: { table: 'loss', version: 1, columns: COLUMNS_FIRST, mine_only: true, items: [], next_cursor: null, state: 'ok' } },
|
||
{ id: 'empty_page', type: 'records_list', props: {},
|
||
data: { table: null, version: 0, columns: [], items: [], mine_only: true, state: 'empty', next_cursor: null } },
|
||
// 按草稿预览(服务端 apps/resolve._records_list):不列真实的记录
|
||
{ id: 'preview_page', type: 'records_list', props: {},
|
||
data: { table: null, version: 0, columns: [], items: [], mine_only: false, state: 'preview', next_cursor: null } },
|
||
],
|
||
forms: {},
|
||
problems: [],
|
||
}
|
||
const COLUMNS = [
|
||
{ name: 'store', label: '门店', type: 'string' },
|
||
{ name: 'amount', label: '金额(元)', type: 'number' },
|
||
{ name: 'reason', label: '原因', type: 'string' },
|
||
{ name: 'photo', label: '照片', type: 'file' },
|
||
]
|
||
const PHOTO = { file_id: 'f_photo', name: '破损.png', size: 20480, sha256: 'ab'.repeat(32), type: 'image/png' }
|
||
// 一行的形状同服务端 records/listing.py:系统列(records.SYSTEM_COLUMNS)加上这一行在第几版表里(_version)
|
||
const row = (id, seq, by, values, extra = {}) => ({
|
||
_id: id, _seq: seq, _submitted_by: by, _submitted_at: at(seq), _run_id: `run_${id}`, _supersedes: null, _schema_version: 1,
|
||
_version: 1, superseded: false, values, ...extra,
|
||
})
|
||
/** zhangsan 的三条:r3 是对 r1 的更正(r1 被更正过,只在历史里) */
|
||
const MINE_ROWS = [
|
||
row('r2', 2, 'zhangsan', { store: '南山店', amount: 80, reason: '过期', photo: null }),
|
||
row('r3', 3, 'zhangsan', { store: '南山店', amount: 120, reason: '运输破损', photo: PHOTO }, { _supersedes: 'r1' }),
|
||
row('r5', 5, 'zhangsan', { store: '南山店', amount: 45, reason: '试吃', photo: null }),
|
||
]
|
||
const R1 = row('r1', 1, 'zhangsan', { store: '南山店', amount: 210, reason: '运输破损', photo: null }, { superseded: true })
|
||
const ALL_ROWS = [...MINE_ROWS, row('r4', 4, 'lisi', { store: '福田店', amount: 60, reason: '过期', photo: null })]
|
||
|
||
// ---------------------------------------------------------------- 浏览器
|
||
|
||
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 })
|
||
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
|
||
}
|
||
if (p === '/api/auth/me') return route.fulfill({ json: state.me })
|
||
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 0, apps: 0, other: 0 } })
|
||
if (p === '/api/apps') return route.fulfill({ json: { items: [APP] } })
|
||
if (p === '/api/apps/app_loss') return route.fulfill({ json: { ...APP, my_role: state.role, published: { version: 2, pages: HOME.pages, entries: [] } } })
|
||
if (p === '/api/apps/app_loss/pages/home') return route.fulfill({ json: HOME })
|
||
if (p === '/api/apps/app_loss/records') {
|
||
state.reads.push(url.search)
|
||
const mine = state.role === 'use'
|
||
const items = mine ? MINE_ROWS : ALL_ROWS
|
||
const history = url.searchParams.get('history') === 'true' || url.searchParams.get('history') === '1'
|
||
return route.fulfill({ json: { table: 'loss', version: 1, columns: COLUMNS, mine_only: mine,
|
||
items: history ? [R1, ...items] : items, next_cursor: null, state: 'ok' } })
|
||
}
|
||
return route.continue()
|
||
})
|
||
return { ctx, page }
|
||
}
|
||
|
||
const list = (page) => page.locator('[data-node="my_records"] [data-component="records_list"]')
|
||
const ids = (page) => list(page).locator('[data-record]').evaluateAll((els) => els.map((e) => e.getAttribute('data-record')))
|
||
|
||
// ---------------------------------------------------------------- 我的记录
|
||
|
||
await section('我的记录:使用者只看到自己的、标题写「我的记录」、按时间排序、筛选', async () => {
|
||
const state = { writes: [], reads: [], me: USER, role: 'use' }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_loss`)
|
||
await list(page).locator('[data-record]').first().waitFor({ timeout: 15000 })
|
||
check('数据取自记录接口、带上记录表', state.reads.some((q) => /[?&]table=loss/.test(q)), state.reads.join(' | '))
|
||
check('使用者的标题是「我的记录」', (await list(page).locator('[data-records-title]').innerText()) === '我的记录')
|
||
check('……写明只显示自己提交的(服务端 mine_only)', (await list(page).locator('[data-records-hint]').innerText()).includes('只显示你自己提交的记录')
|
||
&& await list(page).getAttribute('data-records-scope') === 'mine')
|
||
check('……不列提交人', !(await list(page).innerText()).includes('由「zhangsan」提交'))
|
||
check('服务端交来几行就列几行(界面不自己按人过滤)', (await ids(page)).length === 3)
|
||
check('默认最新的在前', (await ids(page)).join(',') === 'r5,r3,r2', (await ids(page)).join(','))
|
||
await list(page).locator('[data-records-sort]').selectOption('oldest')
|
||
check('换成最早的在前', (await ids(page)).join(',') === 'r2,r3,r5', (await ids(page)).join(','))
|
||
await list(page).locator('[data-records-filter]').fill('破损')
|
||
check('按内容筛选,写明筛出几条', (await ids(page)).join(',') === 'r3'
|
||
&& (await list(page).locator('[data-records-count]').innerText()) === '1 / 3 条', await list(page).locator('[data-records-count]').innerText())
|
||
await list(page).locator('[data-records-filter]').fill('没有这样的')
|
||
check('筛不出来时照实说', (await list(page).innerText()).includes('没有符合筛选条件的记录'))
|
||
await list(page).locator('[data-records-filter]').fill('')
|
||
|
||
// 展开看全部字段和附件
|
||
await list(page).locator('[data-record="r3"] > button').click()
|
||
const detail = list(page).locator('[data-record="r3"] [data-record-detail]')
|
||
check('展开看全部字段', await detail.locator('[data-record-field]').count() === 4
|
||
&& (await detail.locator('[data-record-field="reason"]').innerText()).includes('运输破损'))
|
||
const link = detail.locator('[data-record-field="photo"] a[data-file-link]')
|
||
check('附件是可下载的链接', await link.getAttribute('href') === '/api/files/f_photo' && (await link.innerText()).includes('破损.png'),
|
||
await link.getAttribute('href').catch(() => ''))
|
||
|
||
check('没写记录表的组件取第一张记录表,照样列出来', await page.locator('[data-node="no_table"] [data-record]').count() === 3
|
||
&& state.reads.some((q) => !/[?&]table=/.test(q)), state.reads.join(' | '))
|
||
check('服务端给了第一页的照它画(这里是空的,写「你还没有提交过记录」)',
|
||
(await page.locator('[data-node="server_page"]').innerText()).includes('你还没有提交过记录'))
|
||
check('服务端说还没有记录表的照实说', (await page.locator('[data-node="empty_page"]').innerText()).includes('还没有记录表'))
|
||
const preview = page.locator('[data-node="preview_page"]')
|
||
check('按草稿预览(state=preview):照实说不列真实的记录,不说成「还没有记录表」,也不给重新读取',
|
||
await preview.locator('[data-records-preview]').count() === 1 && !(await preview.innerText()).includes('还没有记录表')
|
||
&& await preview.getByRole('button', { name: '重新读取' }).count() === 0, (await preview.innerText()).replace(/\s+/g, ' '))
|
||
check('只读:没有发出写请求', state.writes.length === 0, state.writes.map((w) => w.path).join(','))
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 更正
|
||
|
||
await section('更正:更正过的行标「已更正」,展开能看更正历史、原来那条照原样', async () => {
|
||
const state = { writes: [], reads: [], me: USER, role: 'use' }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_loss`)
|
||
await list(page).locator('[data-record]').first().waitFor({ timeout: 15000 })
|
||
const r3 = list(page).locator('[data-record="r3"]')
|
||
check('更正过的行标「已更正」', await r3.getAttribute('data-record-corrected') === '' && (await r3.innerText()).includes('已更正'))
|
||
check('没更正过的不标', !(await list(page).locator('[data-record="r2"]').innerText()).includes('已更正'))
|
||
check('被更正的原行默认不列(只列当前有效的)', await list(page).locator('[data-record="r1"]').count() === 0)
|
||
await r3.locator('> button').click()
|
||
await r3.locator('[data-record-history-toggle]').click()
|
||
const hist = r3.locator('[data-record-history]')
|
||
check('展开更正历史:带 history 再取一次', await hist.waitFor({ timeout: 5000 }).then(() => true, () => false)
|
||
&& state.reads.some((q) => /[?&]history=(true|1)/.test(q)), state.reads.join(' | '))
|
||
check('……列出被更正的那一条,值照原样', await hist.locator('[data-record-history-item="r1"]').count() === 1
|
||
&& (await hist.locator('[data-record-history-item="r1"] [data-record-field="amount"]').innerText()).includes('210'))
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 全部记录
|
||
|
||
await section('全部记录:查看全部的人看到所有人的、每行写提交人', async () => {
|
||
const state = { writes: [], reads: [], me: LEAD, role: 'observe' }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_loss`)
|
||
await list(page).locator('[data-record]').first().waitFor({ timeout: 15000 })
|
||
check('标题写组件的标题(不是「我的记录」)', (await list(page).locator('[data-records-title]').innerText()) === '报损登记')
|
||
check('……写明能查看全部记录', (await list(page).locator('[data-records-hint]').innerText()).includes('全部记录')
|
||
&& await list(page).getAttribute('data-records-scope') === 'all')
|
||
check('列出别人提交的', (await ids(page)).includes('r4'))
|
||
check('每行写提交人', (await list(page).locator('[data-record="r4"]').innerText()).includes('由「lisi」提交'))
|
||
await list(page).locator('[data-records-filter]').fill('lisi')
|
||
check('按提交人筛选', (await ids(page)).join(',') === 'r4', (await ids(page)).join(','))
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
})
|
||
|
||
await section('窄屏:390px 不出横向滚动', async () => {
|
||
const state = { writes: [], reads: [], me: USER, role: 'use' }
|
||
const { page } = await open({ state, width: 390, height: 844 })
|
||
await page.goto(`${WEB}/apps/app_loss`)
|
||
await list(page).locator('[data-record]').first().waitFor({ timeout: 15000 })
|
||
await list(page).locator('[data-record="r3"] > button').click()
|
||
check('展开一行后没有横向滚动', await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1))
|
||
})
|
||
|
||
await browser.close()
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
|
||
process.exit(failed ? 1 : 0)
|