verify-real-upload.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. /**
  2. * 真实场景验证:在真实会话里上传文件 → 右侧栏直接显示
  3. *
  4. * 这是用户要求的核心体验验证:
  5. * 「用户上传文件后,右侧栏应该能看到,且**不依赖加载聊天历史**」
  6. *
  7. * 用法:`node scripts/verify-real-upload.mjs`
  8. * 前置:`npm start` 在 4200 运行。
  9. */
  10. import { chromium } from 'playwright';
  11. import { mkdirSync, writeFileSync } from 'node:fs';
  12. import { resolve } from 'node:path';
  13. import { execSync } from 'node:child_process';
  14. import { loadDevLogin } from './dev-login.mjs';
  15. const OUT = resolve('test-results/real-upload-20261110');
  16. mkdirSync(OUT, { recursive: true });
  17. const login = loadDevLogin();
  18. if (!login.enabled) throw new Error(`开发态登录不可用:${login.reason}`);
  19. const TOKEN = login.payload.token;
  20. const FN = 'THXAOGKiPK';
  21. const results = [];
  22. function check(name, ok, detail = '') {
  23. results.push({ name, ok });
  24. console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` (${detail})` : ''}`);
  25. }
  26. async function dbQuery(threadRoot) {
  27. const where = encodeURIComponent(JSON.stringify({ threadRoot }));
  28. const r = await fetch(
  29. `https://server.fmode.cn/parse/classes/AgentDeliverable?where=${where}&limit=50`,
  30. {
  31. headers: {
  32. 'X-Parse-Application-Id': 'ncloudmaster',
  33. 'X-Parse-Master-Key': 'SnkK12*&sunq2#@20!',
  34. },
  35. },
  36. );
  37. return (await r.json()).results ?? [];
  38. }
  39. const FILE_PATH = resolve('/tmp', `real-upload-${Date.now()}.md`);
  40. writeFileSync(FILE_PATH, `# 真实上传验证\n\n时间:${new Date().toISOString()}\n`);
  41. async function main() {
  42. const b = await chromium.launch({ headless: true, channel: 'chromium' });
  43. const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
  44. const page = await ctx.newPage();
  45. await page.addInitScript((d) => {
  46. localStorage.setItem('fmode_session_token', d.token);
  47. localStorage.setItem('fmode_me', JSON.stringify(d.me));
  48. }, login.payload);
  49. /* 记录 listBySession 的调用与返回值,用来判定「右侧栏真的从库里拿」 */
  50. const listCalls = [];
  51. page.on('response', async (res) => {
  52. if (!res.url().includes('/api/functions')) return;
  53. try {
  54. const req = res.request();
  55. const body = JSON.parse(req.postData() ?? '{}');
  56. if (body.id !== FN) return;
  57. if (body.params?.action !== 'listBySession') return;
  58. const json = await res.json();
  59. listCalls.push({ threadRoot: body.params.threadRoot, files: json.files ?? [] });
  60. } catch {
  61. /* 忽略 */
  62. }
  63. });
  64. await page.goto('http://localhost:4200/desktop/chat', { waitUntil: 'domcontentloaded' });
  65. await page.waitForTimeout(6000);
  66. /* 进入第一个真实会话 */
  67. const first = page.locator('.session-item, .rail-session, [data-session-id]').first();
  68. if ((await first.count()) === 0) {
  69. const byText = page.getByText(/写一份团队周报/).first();
  70. await byText.click({ timeout: 8000 }).catch(() => undefined);
  71. } else {
  72. await first.click({ timeout: 8000 }).catch(() => undefined);
  73. }
  74. await page.waitForTimeout(5000);
  75. const url = page.url();
  76. const threadRoot = decodeURIComponent(url.split('/').pop() ?? '');
  77. console.log(`\n当前会话: ${threadRoot}`);
  78. await page.screenshot({ path: `${OUT}/01-session-opened.png` });
  79. check('① 进入了具体会话', !!threadRoot && threadRoot.includes('fmode.cn'), threadRoot);
  80. /* 走文件选择器上传 */
  81. const input = page.locator('input[type="file"]').first();
  82. if ((await input.count()) === 0) {
  83. check('② 找到上传入口', false, '页面上没有 file input');
  84. return finish(b, []);
  85. }
  86. check('② 找到上传入口', true);
  87. await input.setInputFiles(FILE_PATH);
  88. await page.waitForTimeout(1500);
  89. await page.screenshot({ path: `${OUT}/02-file-staged.png` });
  90. /* 点发送 */
  91. const sendBtn = page
  92. .locator('button:has(i.fa-paper-plane), button[aria-label*="发送"], .composer button')
  93. .last();
  94. await sendBtn.click({ timeout: 8000 }).catch(() => undefined);
  95. await page.waitForTimeout(12000);
  96. await page.screenshot({ path: `${OUT}/03-after-send.png` });
  97. /* 直接查库:文件是否进了台账,归属是否正确 */
  98. const rows = await dbQuery(threadRoot);
  99. check(
  100. '③ 文件已入库且归属当前会话',
  101. rows.length > 0 && rows.every((r) => r.threadRoot === threadRoot),
  102. `${rows.length} 条`,
  103. );
  104. if (rows.length > 0) {
  105. const r = rows[0];
  106. check(
  107. '④ 记录信息完整(上传者/文件名/链接)',
  108. r.uploadedBy === 'user' && !!r.fileName && /^https:\/\/s3\.fmode\.cn\//.test(r.artifacts?.[0]?.url ?? ''),
  109. `name=${r.fileName}`,
  110. );
  111. /* 链接可访问 */
  112. const head = await fetch(r.artifacts[0].url).then((x) => x.status);
  113. check('⑤ 云盘链接可访问', head === 200, `HTTP ${head}`);
  114. }
  115. /* 刷新页面,确认「不依赖聊天历史」也能显示 */
  116. await page.reload({ waitUntil: 'domcontentloaded' });
  117. await page.waitForTimeout(8000);
  118. await page.screenshot({ path: `${OUT}/04-after-reload.png`, fullPage: false });
  119. const lastCall = listCalls.filter((c) => c.threadRoot === threadRoot).pop();
  120. check(
  121. '⑥ 刷新后右侧栏从数据库拿到了这个文件',
  122. !!lastCall && lastCall.files.length > 0,
  123. lastCall ? `${lastCall.files.length} 个文件` : '未捕获',
  124. );
  125. /* 页面上真的显示出来了吗 */
  126. const panelText = await page
  127. .locator('.rail, .session-rail, aside')
  128. .first()
  129. .innerText()
  130. .catch(() => '');
  131. const shown = /real-upload|真实上传验证/.test(panelText);
  132. check('⑦ 右侧栏界面上能看到这个文件', shown, shown ? '已显示' : `面板文本: ${panelText.slice(0, 60)}`);
  133. return finish(b, rows);
  134. }
  135. function finish(browser, rows) {
  136. for (const r of rows ?? []) {
  137. try {
  138. execSync(
  139. `curl -s -X DELETE "https://server.fmode.cn/parse/classes/AgentDeliverable/${r.objectId}" ` +
  140. `-H "X-Parse-Application-Id: ncloudmaster" -H "X-Parse-Master-Key: SnkK12*&sunq2#@20!" -o /dev/null`,
  141. { stdio: 'ignore' },
  142. );
  143. } catch {
  144. /* 忽略 */
  145. }
  146. }
  147. const passed = results.filter((x) => x.ok).length;
  148. console.log(`\n=== 结果:${passed}/${results.length} 通过 ===`);
  149. console.log(`截图:${OUT}`);
  150. browser?.close();
  151. process.exit(passed === results.length ? 0 : 1);
  152. }
  153. main().catch((e) => {
  154. console.error('失败:', e instanceof Error ? e.message : e);
  155. finish(null, []);
  156. });