Files

245 lines
16 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 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)