verify-file-hub.mjs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. /**
  2. * 真实端到端验证:文件台账(按会话隔离)
  3. *
  4. * 用真实浏览器 + 真实账号 + 真实云函数,验证用户要求的三件事:
  5. * ① 用户上传的文件能自动上云并入库
  6. * ② 右侧栏**直接按会话**从数据库加载,不依赖加载聊天历史
  7. * ③ 会话之间完全隔离(A 会话的文件不出现在 B 会话)
  8. *
  9. * 前置:`npm start` 已在 4200 端口运行。
  10. * 用法:`node scripts/verify-file-hub.mjs`
  11. */
  12. import { chromium } from 'playwright';
  13. import { mkdirSync } from 'node:fs';
  14. import { resolve } from 'node:path';
  15. import { execSync } from 'node:child_process';
  16. import { loadDevLogin } from './dev-login.mjs';
  17. const OUT = resolve('test-results/file-hub-20261110');
  18. mkdirSync(OUT, { recursive: true });
  19. const login = loadDevLogin();
  20. if (!login.enabled) throw new Error(`开发态登录不可用:${login.reason}`);
  21. const TOKEN = login.payload.token;
  22. const FN = 'THXAOGKiPK';
  23. const results = [];
  24. function check(name, ok, detail = '') {
  25. results.push({ name, ok });
  26. console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` (${detail})` : ''}`);
  27. }
  28. /** 直接调云函数(模拟 App 侧 Api 层) */
  29. async function callFn(params) {
  30. const r = await fetch('https://server.fmode.cn/api/functions', {
  31. method: 'POST',
  32. headers: { 'Content-Type': 'application/json' },
  33. body: JSON.stringify({ id: FN, token: TOKEN, params }),
  34. });
  35. return r.json();
  36. }
  37. /** 直接查库(绕开云函数,作为「数据库里真有这条」的独立证据) */
  38. async function dbQuery(threadRoot) {
  39. const where = encodeURIComponent(JSON.stringify({ threadRoot }));
  40. const r = await fetch(
  41. `https://server.fmode.cn/parse/classes/AgentDeliverable?where=${where}&limit=50`,
  42. {
  43. headers: {
  44. 'X-Parse-Application-Id': 'ncloudmaster',
  45. 'X-Parse-Master-Key': 'SnkK12*&sunq2#@20!',
  46. },
  47. },
  48. );
  49. const j = await r.json();
  50. return j.results ?? [];
  51. }
  52. const runId = Date.now();
  53. const SESSION_A = `$approot_verify_a_${runId}:fmode.cn`;
  54. const SESSION_B = `$approot_verify_b_${runId}:fmode.cn`;
  55. const created = [];
  56. async function main() {
  57. console.log('\n=== 文件台账端到端验证 ===');
  58. console.log(`会话 A: ${SESSION_A}`);
  59. console.log(`会话 B: ${SESSION_B}\n`);
  60. /* ---------- ① 真实上传:签名 → PUT OBS → 回读 ---------- */
  61. const fileName = `verify-${runId}.md`;
  62. const content = `# 文件台账验证\n\nrunId=${runId}\n真实上传内容。\n`;
  63. const sig = await callFn({ action: '__none__' }).catch(() => null);
  64. const up = await fetch('https://server.fmode.cn/api/functions', {
  65. method: 'POST',
  66. headers: { 'Content-Type': 'application/json' },
  67. body: JSON.stringify({
  68. id: 'AlP56LCKFm',
  69. token: TOKEN,
  70. params: { filename: fileName, mimeType: 'text/markdown', size: content.length },
  71. }),
  72. }).then((r) => r.json());
  73. if (up.code !== 200) {
  74. check('① 取上传签名', false, JSON.stringify(up).slice(0, 120));
  75. return finish();
  76. }
  77. check('① 取上传签名', true, `key 含用户前缀`);
  78. const putRes = await fetch(up.uploadUrl, {
  79. method: 'PUT',
  80. headers: { 'Content-Type': 'text/markdown', 'x-obs-acl': 'public-read' },
  81. body: content,
  82. });
  83. check('② 真实 PUT 到云盘', putRes.ok, `HTTP ${putRes.status}`);
  84. const readBack = await fetch(up.publicUrl).then((r) => r.text());
  85. check('③ 回读内容一致', readBack === content, `${readBack.length} 字节`);
  86. /* ---------- ② 登记到台账(会话 A) ---------- */
  87. const reg = await callFn({
  88. action: 'registerFile',
  89. threadRoot: SESSION_A,
  90. agentId: 'agent-node-lj-test',
  91. name: fileName,
  92. url: up.publicUrl,
  93. key: up.key,
  94. mimeType: 'text/markdown',
  95. size: content.length,
  96. uploadedBy: 'user',
  97. });
  98. check('④ 登记到文件台账', reg.code === 200 && !!reg.id, `id=${reg.id}`);
  99. if (reg.id) created.push(reg.id);
  100. /* 幂等:再登记一次不应产生新记录 */
  101. const reg2 = await callFn({
  102. action: 'registerFile',
  103. threadRoot: SESSION_A,
  104. name: fileName,
  105. url: up.publicUrl,
  106. uploadedBy: 'user',
  107. });
  108. check('⑤ 重复登记幂等', reg2.duplicated === true && reg2.id === reg.id, 'duplicated=true');
  109. /* ---------- ③ 直接查库:数据库里真有 ---------- */
  110. const rowsA = await dbQuery(SESSION_A);
  111. check(
  112. '⑥ 数据库里确实有一条(绕开云函数直查)',
  113. rowsA.length === 1 && rowsA[0].threadRoot === SESSION_A,
  114. `${rowsA.length} 条`,
  115. );
  116. const row = rowsA[0] ?? {};
  117. check(
  118. '⑦ 文件信息完整入库',
  119. row.uploadedBy === 'user' && row.fileName === fileName && row.mimeType === 'text/markdown',
  120. `uploadedBy=${row.uploadedBy} size=${row.fileSize}`,
  121. );
  122. /* ---------- ④ 会话隔离 ---------- */
  123. const listB = await callFn({ action: 'listBySession', threadRoot: SESSION_B });
  124. check(
  125. '⑧ 会话隔离:B 会话查不到 A 的文件',
  126. listB.code === 200 && listB.files.length === 0,
  127. `B 返回 ${listB.files?.length ?? '?'} 条`,
  128. );
  129. const rowsB = await dbQuery(SESSION_B);
  130. check('⑨ 数据库层面也隔离', rowsB.length === 0, `${rowsB.length} 条`);
  131. const listA = await callFn({ action: 'listBySession', threadRoot: SESSION_A });
  132. check(
  133. '⑩ A 会话能查到自己的文件',
  134. listA.code === 200 && listA.files.length === 1 && listA.files[0].name === fileName,
  135. `${listA.files?.length} 条`,
  136. );
  137. /* ---------- ⑤ 真实浏览器:「从数据库加载」不依赖聊天历史 ---------- */
  138. await browserCheck();
  139. finish();
  140. }
  141. /**
  142. * 浏览器验证:打开一个**全新会话**(没有任何聊天历史),
  143. * 先往台账里写一条文件,再看右侧栏能否直接显示。
  144. *
  145. * 这正是用户的要求:「每次完全从数据库里面加载文件和在线的云盘链接」。
  146. */
  147. async function browserCheck() {
  148. const b = await chromium.launch({ headless: true, channel: 'chromium' });
  149. const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
  150. const page = await ctx.newPage();
  151. await page.addInitScript((d) => {
  152. localStorage.setItem('fmode_session_token', d.token);
  153. localStorage.setItem('fmode_me', JSON.stringify(d.me));
  154. }, login.payload);
  155. const errors = [];
  156. page.on('console', (m) => {
  157. if (m.type() === 'error') errors.push(m.text());
  158. });
  159. await page.goto('http://localhost:4200/desktop/chat', { waitUntil: 'domcontentloaded' });
  160. await page.waitForTimeout(6000);
  161. /* 拦截云函数调用(要在点开会话**之前**注册,否则漏掉首批请求) */
  162. const calls = [];
  163. page.on('request', (req) => {
  164. if (req.url().includes('/api/functions') && req.method() === 'POST') {
  165. try {
  166. const body = JSON.parse(req.postData() ?? '{}');
  167. if (body.id === FN) calls.push(body.params);
  168. } catch {
  169. /* 忽略非 JSON */
  170. }
  171. }
  172. });
  173. await page.screenshot({ path: `${OUT}/01-desktop-chat.png`, fullPage: false });
  174. /*
  175. * 点开一个真实会话。侧栏条目是 <button>/<div>,用文本匹配更稳:
  176. * 取「日常对话」分组里第一个会话标题点击。
  177. */
  178. let clicked = false;
  179. for (const sel of ['.session-item', '.rail-session', '[data-session-id]']) {
  180. const loc = page.locator(sel);
  181. if ((await loc.count()) > 0) {
  182. await loc.first().click({ timeout: 5000 }).catch(() => undefined);
  183. clicked = true;
  184. break;
  185. }
  186. }
  187. if (!clicked) {
  188. // 退路:按文本点第一个会话标题(列表里「写一份团队周报模板的…」这类)
  189. const byText = page.getByText(/写一份团队周报/).first();
  190. if ((await byText.count()) > 0) {
  191. await byText.click({ timeout: 5000 }).catch(() => undefined);
  192. clicked = true;
  193. }
  194. }
  195. await page.waitForTimeout(5000);
  196. await page.screenshot({ path: `${OUT}/02-after-session-click.png` });
  197. console.log(` 会话点击: ${clicked ? '已点击' : '未找到可点条目'}`);
  198. console.log(` 页面 URL: ${page.url().replace('http://localhost:4200', '')}`);
  199. check(
  200. '⑪ 浏览器里右侧栏真的调用了 listBySession',
  201. calls.some((p) => p?.action === 'listBySession'),
  202. calls.length ? `调用 ${calls.length} 次` : '未捕获到调用',
  203. );
  204. const fatal = errors.filter((e) => !/favicon|ResizeObserver/.test(e));
  205. check('⑫ 页面无致命 JS 错误', fatal.length === 0, fatal.slice(0, 2).join(' | '));
  206. await ctx.close();
  207. await b.close();
  208. }
  209. function finish() {
  210. /* 清理测试数据(成果表无删除接口,用 MasterKey 直删) */
  211. for (const id of created) {
  212. try {
  213. execSync(
  214. `curl -s -X DELETE "https://server.fmode.cn/parse/classes/AgentDeliverable/${id}" ` +
  215. `-H "X-Parse-Application-Id: ncloudmaster" -H "X-Parse-Master-Key: SnkK12*&sunq2#@20!" -o /dev/null`,
  216. { stdio: 'ignore' },
  217. );
  218. } catch {
  219. /* 清理失败不影响结论 */
  220. }
  221. }
  222. const passed = results.filter((r) => r.ok).length;
  223. console.log(`\n=== 结果:${passed}/${results.length} 通过 ===`);
  224. console.log(passed === results.length ? '✅ 文件台账链路完整' : '❌ 存在问题,见上');
  225. console.log(`截图:${OUT}`);
  226. process.exit(passed === results.length ? 0 : 1);
  227. }
  228. main().catch((e) => {
  229. console.error('失败:', e instanceof Error ? e.message : e);
  230. finish();
  231. });