frontend_markdown.html 7.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. <!doctype html>
  2. <html lang="zh-CN"><meta charset="utf-8"><title>Markdown regression checks</title>
  3. <style>body{font:14px system-ui;background:#0f1115;color:#e6e9ef;margin:24px}iframe{width:100%;height:620px;border:1px solid #2a2f3a}pre{white-space:pre-wrap}.pass{color:#34d399}.fail{color:#f87171}</style>
  4. <h1>Markdown regression checks</h1><pre id="result">Running…</pre><iframe title="Frontend under test" src="/static/index.html"></iframe>
  5. <script>
  6. const frame = document.querySelector('iframe');
  7. frame.addEventListener('load', async () => {
  8. const w = frame.contentWindow;
  9. const results = [];
  10. const check = (condition, message) => { if (!condition) throw new Error(message); results.push('PASS ' + message); };
  11. const pause = () => new Promise(resolve => setTimeout(resolve, 80));
  12. const render = (source) => { const ui = w.newAssistant(); w.appendToken(ui, source); w.breakSegment(ui); return ui; };
  13. const sample = '# Markdown 标题\n\n这是 **加粗**、*斜体*、~~删除线~~ 和 `inline code`。\n\n- 第一项\n - 嵌套项\n- [x] 已完成\n\n> 引用内容\n\n| 项目 | 数值 |\n| --- | ---: |\n| 风量 | 120 |\n\n```python\ndef greet():\n print("<hello>")\n```\n\n[文档](https://example.com)';
  14. try {
  15. await pause();
  16. w.document.getElementById('newBtn').click();
  17. const full = render(sample);
  18. check(full.bubble.querySelector('h1')?.textContent === 'Markdown 标题', 'headings');
  19. check(full.bubble.querySelector('strong')?.textContent === '加粗' && full.bubble.querySelector('em') && full.bubble.querySelector('del'), 'inline formatting');
  20. check(full.bubble.querySelector('ul ul li') && full.bubble.querySelector('input:checked:disabled'), 'nested and task lists');
  21. check(full.bubble.querySelector('blockquote') && full.bubble.querySelector('.markdown-table table tbody td'), 'quotes and GFM tables');
  22. check(full.bubble.querySelector('pre code')?.textContent.includes(' print("<hello>")'), 'code indentation and literal HTML');
  23. check(full.bubble.querySelector('a')?.rel === 'noopener noreferrer', 'safe external links');
  24. const streamed = w.newAssistant();
  25. for (const char of sample) w.appendToken(streamed, char);
  26. await pause();
  27. check(streamed.bubble.innerHTML === full.bubble.innerHTML, 'fragmented tokens match complete Markdown');
  28. const mixed = w.newAssistant();
  29. w.appendToken(mixed, '**before**');
  30. const tool = w.addCardFor(mixed, 'done', '工具完成', '{"ok":true}');
  31. w.appendToken(mixed, '**after**');
  32. w.breakSegment(mixed);
  33. await pause();
  34. check(mixed.bubble.children[1] === tool && mixed.bubble.children[0].querySelector('strong').textContent === 'before' && mixed.bubble.children[2].querySelector('strong').textContent === 'after', 'tool cards preserve segment order');
  35. const incomplete = render('```js\nconst value = "<tag>";');
  36. check(incomplete.bubble.querySelector('pre code')?.textContent.includes('<tag>'), 'unfinished code fences during streaming');
  37. const hostile = render('<img src="x" onerror="window.__xss=1"><script>window.__xss=1<\/script><svg onload="window.__xss=1"></svg><style>body{display:none}</style>[bad](javascript:alert%281%29)<a href="jav&#x61;script:alert(1)">bad</a><iframe srcdoc="bad"></iframe>');
  38. check(!hostile.bubble.querySelector('script,style,svg,iframe,[onerror],[onload],a[href^="javascript:"]') && !w.__xss, 'HTML and dangerous URLs are sanitized');
  39. const purifier = w.DOMPurify;
  40. w.DOMPurify = undefined;
  41. const fallback = render('**safe fallback** <img onerror="bad()">');
  42. check(fallback.bubble.textContent.includes('**safe fallback**') && !fallback.bubble.querySelector('img'), 'missing dependency falls back to plain text');
  43. w.DOMPurify = purifier;
  44. const fetchOriginal = w.fetch;
  45. const stub = async (url) => {
  46. if (url.includes('/chat/history/')) return w.Response.json({items:[{msg_role:'assistant',content:sample}]});
  47. if (url.includes('/chat/interrupt')) return new w.Response(null,{status:204});
  48. if (url.includes('/sessions')) return w.Response.json({items:[]});
  49. return w.Response.json({model:'test'});
  50. };
  51. w.fetch = stub;
  52. await w.openSession({id:'markdown-history',title:'Markdown 历史消息',mode:'full'});
  53. check(w.document.querySelector('#log h1')?.textContent === 'Markdown 标题' && w.document.querySelector('#log table'), 'history uses the same Markdown renderer');
  54. const encode = content => new w.TextEncoder().encode('data: '+JSON.stringify({type:'token',content})+'\n\n');
  55. w.fetch = async (url, options) => url === '/test-stream' ? new w.Response(new w.ReadableStream({start(c){c.enqueue(encode('## 完成时刷新\n\n**末尾**'));c.close();}})) : stub(url);
  56. await w.stream('/test-stream','POST',new w.FormData());
  57. check(w.document.querySelector('#log .assistant:last-child h2')?.textContent === '完成时刷新', 'stream completion flushes pending render');
  58. w.fetch = async (url, options) => url === '/test-abort' ? new w.Response(new w.ReadableStream({start(c){c.enqueue(encode('**保留部分回复**')); options.signal.addEventListener('abort',()=>c.error(new w.DOMException('Stopped','AbortError')));}})) : stub(url);
  59. const pending = w.stream('/test-abort','POST',new w.FormData());
  60. await pause();
  61. w.document.getElementById('stopBtn').click();
  62. await pending;
  63. check(w.document.querySelector('#log .assistant:last-child strong')?.textContent === '保留部分回复', 'stopping preserves partial Markdown');
  64. const originalResume = w.resume;
  65. let submitted;
  66. w.resume = async (payload) => { submitted = payload; };
  67. const approval = w.newAssistant();
  68. w.handle({type:'interrupt', tool_approval:{tool:'one', args:{}, interrupt_token:'batch-token', actions:[{name:'one',args:{path:'a'}},{name:'two',args:{path:'b'}}]}}, approval, []);
  69. check(approval.bubble.textContent.includes('one') && approval.bubble.textContent.includes('two'), 'approval shows every action');
  70. const approveButton = approval.bubble.querySelector('button.ok');
  71. check(approveButton.textContent.includes('全部 2 项'), 'batch approval scope is explicit');
  72. approveButton.click(); await pause();
  73. check(submitted?.interrupt_token === 'batch-token' && submitted?.approved === true, 'approval submits current interrupt token');
  74. const question = w.newAssistant();
  75. w.handle({type:'interrupt', interrupt_token:'question-token', ask_user:{questions:['请选择区域','需要什么格式']}}, question, []);
  76. const answers = question.bubble.querySelectorAll('textarea');
  77. check(answers.length === 2, 'ask-user displays all questions');
  78. answers[0].value = '一区'; answers[1].value = '表格';
  79. question.bubble.querySelector('button').click(); await pause();
  80. check(submitted?.interrupt_token === 'question-token' && submitted?.answers?.join(',') === '一区,表格', 'ask-user submits answers with interrupt token');
  81. question.bubble.querySelectorAll('button')[1].click(); await pause();
  82. check(submitted?.status === 'cancelled' && submitted?.interrupt_token === 'question-token', 'ask-user can cancel the current question');
  83. check(Boolean(w.buildForm('test').get('request_id')), 'chat attaches a request id');
  84. w.resume = originalResume;
  85. w.fetch = fetchOriginal;
  86. w.document.getElementById('newBtn').click();
  87. render(sample);
  88. document.getElementById('result').className = 'pass';
  89. document.getElementById('result').textContent = results.join('\n') + '\n\n' + results.length + ' checks passed';
  90. } catch (error) {
  91. document.getElementById('result').className = 'fail';
  92. document.getElementById('result').textContent = results.join('\n') + '\nFAIL ' + error.stack;
  93. }
  94. });
  95. </script></html>