mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:21:58 +00:00
245 lines
16 KiB
JavaScript
245 lines
16 KiB
JavaScript
// 文件控件(docs/design/normal-user-apps.md N9、§3.8、§4.7)的回归检查。
|
||
//
|
||
// 守的是这些「看着没坏、其实不对」的地方:
|
||
// - 表单里 x-widget 为 file 的字段画成文件控件:拖放或点选,写明允许的类型和大小(来自 x-file)、谁能下载附件;
|
||
// - 两步走:先 POST /api/files(multipart,带上下文——入口是 {app_id, entry},待办是 {task_id}),拿回引用;引用就是
|
||
// 这个字段的值,发起时 values 里原样带着(只有引用的五个键);
|
||
// - 上传中写文件名、大小、进度,可以取消;服务端回包慢也不把进度冒充完成;
|
||
// - 超过大小、类型不在允许范围内的先在这里拦下,不发请求,说清原因;服务端拒收的(415 / 413)照实写服务端的原话;
|
||
// - 必填的没上传,本地拦下、挂在字段下面(规则和服务端同一份:必填,形状是文件引用);
|
||
// - 这次上传的移除时顺手删掉(DELETE /api/files/{id});
|
||
// - 已提交的引用(待办里只读的表单、实例页上的字段)显示成可下载的链接(/api/files/{id});
|
||
// - 不在入口、待办、试运行里的表单(页面的筛选表单)没有上传的上下文:照实说这里不能上传。
|
||
//
|
||
// 页面是真页面(走真的应用外壳),/api/apps/**、/api/files/**、/api/tasks/** 在浏览器层答掉,数据是这里虚构的(「报销审批」);
|
||
// 其余 GET 放行到服务端(外壳要它),别的写请求一律拦下、记在探针里,不碰真实写接口。
|
||
//
|
||
// node scripts/check-files.mjs
|
||
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-files.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 FILE_FIELD = { type: 'object', title: '发票', 'x-widget': 'file', 'x-file': { accept: ['application/pdf', 'image/png'], max_mb: 1 } }
|
||
const FORM = {
|
||
format: 'agentlab.form/1', type: 'object', required: ['amount', 'invoice'], 'x-order': ['amount', 'invoice'],
|
||
properties: { amount: { type: 'number', title: '金额(元)', minimum: 0 }, invoice: FILE_FIELD },
|
||
}
|
||
const FILTER_FORM = {
|
||
format: 'agentlab.form/1', type: 'object', required: [], properties: { scan: { type: 'object', title: '扫描件', 'x-widget': 'file' } },
|
||
}
|
||
const APP = {
|
||
id: 'app_claim', name: '报销审批', description: '员工发起报销', icon: 'app', status: 'active', published_version: 2,
|
||
level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'use', updated_at: ago(3600_000), scope: 'shared',
|
||
}
|
||
const 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: 'filter', type: 'form', props: { form: 'filter' }, data: { form: 'filter' } },
|
||
],
|
||
forms: { apply: FORM, filter: FILTER_FORM },
|
||
problems: [],
|
||
}
|
||
const SUBMITTED = { file_id: 'f_inv', name: '发票-1001.pdf', size: 52_314, sha256: 'cd'.repeat(32), type: 'application/pdf' }
|
||
/** 财务的待办:表单里带着发起人交的发票(只读:不是这一位处理) */
|
||
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: 'input', title: '补充发票',
|
||
form: { format: 'agentlab.form/1', type: 'object', required: ['invoice'], properties: { invoice: { ...FILE_FIELD, default: SUBMITTED } } },
|
||
policy: { kind: 'any' }, status: 'pending', due_at: null, escalation_level: 0,
|
||
payload: { kind: 'human_node', message: '请补充发票' }, assignments: [], can_respond: false, outcome: null,
|
||
resolved_by: null, resolved_at: null, created_at: ago(600_000),
|
||
}
|
||
/** 一张能处理的待办:上传的上下文是 {task_id} */
|
||
const TASK_OPEN = { ...TASK, id: 'task_open', form: { ...TASK.form, properties: { invoice: FILE_FIELD } }, can_respond: true }
|
||
|
||
const pdf = (name, bytes) => ({ name, mimeType: 'application/pdf', buffer: Buffer.alloc(bytes, 0x25) })
|
||
|
||
// ---------------------------------------------------------------- 浏览器
|
||
|
||
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/'), async (route) => {
|
||
const r = route.request()
|
||
const url = new URL(r.url())
|
||
const p = url.pathname
|
||
if (r.method() !== 'GET') {
|
||
const raw = r.postDataBuffer()?.toString('utf8') ?? ''
|
||
state.writes.push({ method: r.method(), path: p, body: raw })
|
||
if (r.method() === 'POST' && p === '/api/files') {
|
||
// 服务端回包慢一点:上传中的样子要看得到。取消了的请求再答会抛错,吞掉
|
||
const id = `f_${state.writes.length}`
|
||
await new Promise((res) => setTimeout(res, state.uploadDelay ?? 600))
|
||
if (state.reject) return route.fulfill({ status: state.reject.status, json: { detail: state.reject.detail } }).catch(() => {})
|
||
const name = /filename="([^"]+)"/.exec(raw)?.[1] ?? 'file'
|
||
return route.fulfill({ status: 201, json: { file_id: id, name, size: 2048, sha256: 'ef'.repeat(32),
|
||
type: 'application/pdf', uploaded_at: ago(0) } }).catch(() => {})
|
||
}
|
||
if (r.method() === 'DELETE' && p.startsWith('/api/files/')) return route.fulfill({ status: 204, body: '' })
|
||
if (p === '/api/apps/app_claim/entries/apply') return route.fulfill({ status: 201, json: { run_id: 'run_claim_9', title: '报销' } })
|
||
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/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_claim') return route.fulfill({ json: { ...APP, published: { version: 2, pages: HOME.pages, entries: [{ id: 'apply', label: '发起报销' }] } } })
|
||
if (p === '/api/apps/app_claim/pages/home') return route.fulfill({ json: HOME })
|
||
if (p === '/api/apps/app_claim/instances/run_claim_9') return route.fulfill({ status: 404, json: { detail: '检查脚本不画实例页' } })
|
||
if (p === '/api/tasks/task_fin') return route.fulfill({ json: TASK })
|
||
if (p === '/api/tasks/task_open') return route.fulfill({ json: TASK_OPEN })
|
||
return route.continue()
|
||
})
|
||
return { ctx, page }
|
||
}
|
||
|
||
const field = (page) => page.locator('[data-field="invoice"]')
|
||
const uploads = (state) => state.writes.filter((w) => w.method === 'POST' && w.path === '/api/files')
|
||
|
||
// ---------------------------------------------------------------- 上传
|
||
|
||
await section('入口:上传、进度、引用随表单提交、移除时删掉', async () => {
|
||
const state = { writes: [], uploadDelay: 1200 }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_claim/new/apply`)
|
||
await field(page).waitFor({ timeout: 15000 })
|
||
check('画成文件控件', await field(page).getAttribute('data-widget') === 'file'
|
||
&& await field(page).locator('[data-file-input="ready"] [data-file-drop]').count() === 1)
|
||
const limits = await field(page).locator('[data-file-limits]').innerText()
|
||
check('写明允许的类型和大小(来自 x-file)', limits.includes('PDF') && limits.includes('PNG 图片') && limits.includes('1 MB'), limits)
|
||
check('写明谁能下载附件', limits.includes('都能下载附件'), limits)
|
||
|
||
// 必填没上传:本地拦下
|
||
await page.locator('#entry-apply-amount').fill('1200')
|
||
await page.locator('[data-entry-submit]').click()
|
||
check('必填的文件没上传:本地拦下、挂在字段下面', (await field(page).locator('[data-field-error]').innerText().catch(() => '')) === '必填'
|
||
&& !state.writes.some((w) => w.path.endsWith('/entries/apply')))
|
||
|
||
await field(page).locator('[data-file-picker]').setInputFiles(pdf('发票-2001.pdf', 2048))
|
||
const sending = field(page).locator('[data-file-sending]')
|
||
check('上传中写文件名、大小', await sending.waitFor({ timeout: 3000 }).then(() => true, () => false)
|
||
&& (await sending.innerText()).includes('发票-2001.pdf') && (await sending.innerText()).includes('2.0 KB'),
|
||
await sending.innerText().catch(() => ''))
|
||
check('……写进度、不冒充完成', /正在上传|上传完成,正在等服务端核对/.test(await sending.innerText().catch(() => '')))
|
||
const value = field(page).locator('[data-file-value]')
|
||
await value.waitFor({ timeout: 5000 })
|
||
const up = uploads(state)[0]
|
||
check('先 POST /api/files,带上下文 {app_id, entry}', !!up && up.body.includes('"app_id":"app_claim"') && up.body.includes('"entry":"apply"'),
|
||
(up?.body ?? '').slice(0, 120))
|
||
check('上传完写文件名,给「换一个」「移除」', (await value.innerText()).includes('发票-2001.pdf')
|
||
&& await value.locator('[data-file-replace]').count() === 1 && await value.locator('[data-file-remove]').count() === 1)
|
||
|
||
await page.locator('[data-entry-submit]').click()
|
||
await page.waitForTimeout(500)
|
||
const start = state.writes.find((w) => w.path === '/api/apps/app_claim/entries/apply')
|
||
const values = start ? JSON.parse(start.body || '{}').values : null
|
||
check('发起时 values 里带着引用(只有五个键)', !!values && values.invoice?.file_id === 'f_1'
|
||
&& Object.keys(values.invoice).sort().join(',') === 'file_id,name,sha256,size,type', JSON.stringify(values))
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
|
||
// 移除:这次上传的顺手删掉
|
||
const s2 = { writes: [], uploadDelay: 100 }
|
||
const two = await open({ state: s2 })
|
||
await two.page.goto(`${WEB}/apps/app_claim/new/apply`)
|
||
await field(two.page).locator('[data-file-picker]').setInputFiles(pdf('发票-3001.pdf', 1024))
|
||
await field(two.page).locator('[data-file-value]').waitFor({ timeout: 5000 })
|
||
await field(two.page).locator('[data-file-remove]').click()
|
||
await two.page.waitForTimeout(300)
|
||
check('移除这次上传的:DELETE /api/files/{id}', s2.writes.some((w) => w.method === 'DELETE' && w.path === '/api/files/f_1'))
|
||
check('……控件回到可以上传的样子', await field(two.page).locator('[data-file-drop]').count() === 1)
|
||
})
|
||
|
||
await section('上传:取消、超过大小和类型不对先拦下、服务端拒收照实说', async () => {
|
||
const state = { writes: [], uploadDelay: 4000 }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/app_claim/new/apply`)
|
||
await field(page).waitFor({ timeout: 15000 })
|
||
|
||
await field(page).locator('[data-file-picker]').setInputFiles(pdf('大文件.pdf', 2 * 1024 * 1024))
|
||
check('超过大小:不发请求,说清超过几 MB', (await field(page).locator('[data-file-failure]').innerText()).includes('超过 1 MB')
|
||
&& uploads(state).length === 0)
|
||
await field(page).locator('[data-file-picker]').setInputFiles({ name: '说明.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') })
|
||
const bad = await field(page).locator('[data-file-failure]').innerText()
|
||
check('类型不在允许范围内:不发请求,写明允许的类型', bad.includes('类型不在允许范围内') && bad.includes('PDF') && uploads(state).length === 0, bad)
|
||
|
||
await field(page).locator('[data-file-picker]').setInputFiles(pdf('慢.pdf', 1024))
|
||
await field(page).locator('[data-file-cancel]').click()
|
||
check('上传中可以取消,取消照实说', (await field(page).locator('[data-file-failure]').innerText()).includes('已取消上传'))
|
||
check('……没有留下值', await field(page).locator('[data-file-value]').count() === 0)
|
||
|
||
state.uploadDelay = 50
|
||
state.reject = { status: 415, detail: '这种类型的文件不能上传:只收 PDF、Word、Excel、CSV、图片和纯文本' }
|
||
await field(page).locator('[data-file-picker]').setInputFiles({ name: '扫描.png', mimeType: 'image/png', buffer: Buffer.alloc(512, 1) })
|
||
await page.waitForFunction(() => !!document.querySelector('[data-field="invoice"] [data-file-failure]')?.textContent?.includes('上传失败'),
|
||
null, { timeout: 5000 }).catch(() => {})
|
||
const refused = await field(page).locator('[data-file-failure]').innerText()
|
||
check('服务端拒收:照实写服务端的原话', refused.includes('「扫描.png」上传失败') && refused.includes('只收 PDF'), refused)
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 下载、上下文
|
||
|
||
await section('已提交的引用是下载链接;待办的上传带 {task_id};筛选表单不能上传', async () => {
|
||
const state = { writes: [], uploadDelay: 100 }
|
||
const { page } = await open({ state })
|
||
await page.goto(`${WEB}/apps/inbox/task_fin`)
|
||
await page.locator('[data-task="task_fin"]').waitFor({ timeout: 15000 })
|
||
const link = page.locator('[data-task="task_fin"] [data-field="invoice"] a[data-file-link]')
|
||
check('只读的表单里,已提交的引用是可下载的链接', await link.getAttribute('href') === '/api/files/f_inv'
|
||
&& (await link.innerText()).includes('发票-1001.pdf'), await link.getAttribute('href').catch(() => ''))
|
||
|
||
await page.goto(`${WEB}/apps/inbox/task_open`)
|
||
await page.locator('[data-task="task_open"] [data-field="invoice"]').waitFor({ timeout: 15000 })
|
||
await page.locator('[data-task="task_open"] [data-file-picker]').setInputFiles(pdf('补充发票.pdf', 1024))
|
||
await page.locator('[data-task="task_open"] [data-file-value]').waitFor({ timeout: 5000 })
|
||
const up = uploads(state).at(-1)
|
||
check('待办里上传带上下文 {task_id}', !!up && up.body.includes('"task_id":"task_open"'), (up?.body ?? '').slice(0, 120))
|
||
|
||
await page.goto(`${WEB}/apps/app_claim`)
|
||
const scan = page.locator('[data-node="filter"] [data-field="scan"]')
|
||
await scan.waitFor({ timeout: 15000 })
|
||
check('页面的筛选表单没有上传的上下文:照实说这里不能上传', await scan.locator('[data-file-input="no-context"]').count() === 1
|
||
&& (await scan.innerText()).includes('这里不能上传文件') && await scan.locator('[data-file-drop]').isDisabled())
|
||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||
})
|
||
|
||
await browser.close()
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
|
||
process.exit(failed ? 1 : 0)
|