| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264 |
- /**
- * 真实端到端验证:文件台账(按会话隔离)
- *
- * 用真实浏览器 + 真实账号 + 真实云函数,验证用户要求的三件事:
- * ① 用户上传的文件能自动上云并入库
- * ② 右侧栏**直接按会话**从数据库加载,不依赖加载聊天历史
- * ③ 会话之间完全隔离(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 });
- /*
- * 点开一个真实会话。侧栏条目是 <button>/<div>,用文本匹配更稳:
- * 取「日常对话」分组里第一个会话标题点击。
- */
- let clicked = false;
- for (const sel of ['.session-item', '.rail-session', '[data-session-id]']) {
- const loc = page.locator(sel);
- if ((await loc.count()) > 0) {
- await loc.first().click({ timeout: 5000 }).catch(() => undefined);
- clicked = true;
- break;
- }
- }
- if (!clicked) {
- // 退路:按文本点第一个会话标题(列表里「写一份团队周报模板的…」这类)
- const byText = page.getByText(/写一份团队周报/).first();
- if ((await byText.count()) > 0) {
- await byText.click({ timeout: 5000 }).catch(() => undefined);
- clicked = true;
- }
- }
- await page.waitForTimeout(5000);
- await page.screenshot({ path: `${OUT}/02-after-session-click.png` });
- console.log(` 会话点击: ${clicked ? '已点击' : '未找到可点条目'}`);
- console.log(` 页面 URL: ${page.url().replace('http://localhost:4200', '')}`);
- check(
- '⑪ 浏览器里右侧栏真的调用了 listBySession',
- calls.some((p) => p?.action === 'listBySession'),
- calls.length ? `调用 ${calls.length} 次` : '未捕获到调用',
- );
- const fatal = errors.filter((e) => !/favicon|ResizeObserver/.test(e));
- check('⑫ 页面无致命 JS 错误', fatal.length === 0, fatal.slice(0, 2).join(' | '));
- await ctx.close();
- await b.close();
- }
- function finish() {
- /* 清理测试数据(成果表无删除接口,用 MasterKey 直删) */
- for (const id of created) {
- try {
- execSync(
- `curl -s -X DELETE "https://server.fmode.cn/parse/classes/AgentDeliverable/${id}" ` +
- `-H "X-Parse-Application-Id: ncloudmaster" -H "X-Parse-Master-Key: SnkK12*&sunq2#@20!" -o /dev/null`,
- { stdio: 'ignore' },
- );
- } catch {
- /* 清理失败不影响结论 */
- }
- }
- const passed = results.filter((r) => r.ok).length;
- console.log(`\n=== 结果:${passed}/${results.length} 通过 ===`);
- console.log(passed === results.length ? '✅ 文件台账链路完整' : '❌ 存在问题,见上');
- console.log(`截图:${OUT}`);
- process.exit(passed === results.length ? 0 : 1);
- }
- main().catch((e) => {
- console.error('失败:', e instanceof Error ? e.message : e);
- finish();
- });
|