/** * 真实端到端验证:文件台账(按会话隔离) * * 用真实浏览器 + 真实账号 + 真实云函数,验证用户要求的三件事: * ① 用户上传的文件能自动上云并入库 * ② 右侧栏**直接按会话**从数据库加载,不依赖加载聊天历史 * ③ 会话之间完全隔离(A 会话的文件不出现在 B 会话) * * 前置:`npm start` 已在 4200 端口运行。 * 用法:`node scripts/verify-file-hub.mjs` */ import { chromium } from 'playwright'; import { mkdirSync } from 'node:fs'; import { resolve } from 'node:path'; import { execSync } from 'node:child_process'; import { loadDevLogin } from './dev-login.mjs'; const OUT = resolve('test-results/file-hub-20261110'); mkdirSync(OUT, { recursive: true }); const login = loadDevLogin(); if (!login.enabled) throw new Error(`开发态登录不可用:${login.reason}`); const TOKEN = login.payload.token; const FN = 'THXAOGKiPK'; const results = []; function check(name, ok, detail = '') { results.push({ name, ok }); console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` (${detail})` : ''}`); } /** 直接调云函数(模拟 App 侧 Api 层) */ async function callFn(params) { const r = await fetch('https://server.fmode.cn/api/functions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: FN, token: TOKEN, params }), }); return r.json(); } /** 直接查库(绕开云函数,作为「数据库里真有这条」的独立证据) */ async function dbQuery(threadRoot) { const where = encodeURIComponent(JSON.stringify({ threadRoot })); const r = await fetch( `https://server.fmode.cn/parse/classes/AgentDeliverable?where=${where}&limit=50`, { headers: { 'X-Parse-Application-Id': 'ncloudmaster', 'X-Parse-Master-Key': 'SnkK12*&sunq2#@20!', }, }, ); const j = await r.json(); return j.results ?? []; } const runId = Date.now(); const SESSION_A = `$approot_verify_a_${runId}:fmode.cn`; const SESSION_B = `$approot_verify_b_${runId}:fmode.cn`; const created = []; async function main() { console.log('\n=== 文件台账端到端验证 ==='); console.log(`会话 A: ${SESSION_A}`); console.log(`会话 B: ${SESSION_B}\n`); /* ---------- ① 真实上传:签名 → PUT OBS → 回读 ---------- */ const fileName = `verify-${runId}.md`; const content = `# 文件台账验证\n\nrunId=${runId}\n真实上传内容。\n`; const sig = await callFn({ action: '__none__' }).catch(() => null); const up = await fetch('https://server.fmode.cn/api/functions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'AlP56LCKFm', token: TOKEN, params: { filename: fileName, mimeType: 'text/markdown', size: content.length }, }), }).then((r) => r.json()); if (up.code !== 200) { check('① 取上传签名', false, JSON.stringify(up).slice(0, 120)); return finish(); } check('① 取上传签名', true, `key 含用户前缀`); const putRes = await fetch(up.uploadUrl, { method: 'PUT', headers: { 'Content-Type': 'text/markdown', 'x-obs-acl': 'public-read' }, body: content, }); check('② 真实 PUT 到云盘', putRes.ok, `HTTP ${putRes.status}`); const readBack = await fetch(up.publicUrl).then((r) => r.text()); check('③ 回读内容一致', readBack === content, `${readBack.length} 字节`); /* ---------- ② 登记到台账(会话 A) ---------- */ const reg = await callFn({ action: 'registerFile', threadRoot: SESSION_A, agentId: 'agent-node-lj-test', name: fileName, url: up.publicUrl, key: up.key, mimeType: 'text/markdown', size: content.length, uploadedBy: 'user', }); check('④ 登记到文件台账', reg.code === 200 && !!reg.id, `id=${reg.id}`); if (reg.id) created.push(reg.id); /* 幂等:再登记一次不应产生新记录 */ const reg2 = await callFn({ action: 'registerFile', threadRoot: SESSION_A, name: fileName, url: up.publicUrl, uploadedBy: 'user', }); check('⑤ 重复登记幂等', reg2.duplicated === true && reg2.id === reg.id, 'duplicated=true'); /* ---------- ③ 直接查库:数据库里真有 ---------- */ const rowsA = await dbQuery(SESSION_A); check( '⑥ 数据库里确实有一条(绕开云函数直查)', rowsA.length === 1 && rowsA[0].threadRoot === SESSION_A, `${rowsA.length} 条`, ); const row = rowsA[0] ?? {}; check( '⑦ 文件信息完整入库', row.uploadedBy === 'user' && row.fileName === fileName && row.mimeType === 'text/markdown', `uploadedBy=${row.uploadedBy} size=${row.fileSize}`, ); /* ---------- ④ 会话隔离 ---------- */ const listB = await callFn({ action: 'listBySession', threadRoot: SESSION_B }); check( '⑧ 会话隔离:B 会话查不到 A 的文件', listB.code === 200 && listB.files.length === 0, `B 返回 ${listB.files?.length ?? '?'} 条`, ); const rowsB = await dbQuery(SESSION_B); check('⑨ 数据库层面也隔离', rowsB.length === 0, `${rowsB.length} 条`); const listA = await callFn({ action: 'listBySession', threadRoot: SESSION_A }); check( '⑩ A 会话能查到自己的文件', listA.code === 200 && listA.files.length === 1 && listA.files[0].name === fileName, `${listA.files?.length} 条`, ); /* ---------- ⑤ 真实浏览器:「从数据库加载」不依赖聊天历史 ---------- */ await browserCheck(); finish(); } /** * 浏览器验证:打开一个**全新会话**(没有任何聊天历史), * 先往台账里写一条文件,再看右侧栏能否直接显示。 * * 这正是用户的要求:「每次完全从数据库里面加载文件和在线的云盘链接」。 */ async function browserCheck() { const b = await chromium.launch({ headless: true, channel: 'chromium' }); const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); await page.addInitScript((d) => { localStorage.setItem('fmode_session_token', d.token); localStorage.setItem('fmode_me', JSON.stringify(d.me)); }, login.payload); const errors = []; page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); await page.goto('http://localhost:4200/desktop/chat', { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(6000); /* 拦截云函数调用(要在点开会话**之前**注册,否则漏掉首批请求) */ const calls = []; page.on('request', (req) => { if (req.url().includes('/api/functions') && req.method() === 'POST') { try { const body = JSON.parse(req.postData() ?? '{}'); if (body.id === FN) calls.push(body.params); } catch { /* 忽略非 JSON */ } } }); await page.screenshot({ path: `${OUT}/01-desktop-chat.png`, fullPage: false }); /* * 点开一个真实会话。侧栏条目是