report-html.test.mjs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import os from 'node:os';
  5. import path from 'node:path';
  6. import crypto from 'node:crypto';
  7. import { renderReportHtml } from '../skills/ksf-workshop-runtime/scripts/render-report-html.mjs';
  8. import { prepareHtmlReport } from '../skills/ksf-workshop-runtime/scripts/save-report.mjs';
  9. const task = () => ({ title: '隔离测试:面体与酱体的取舍', taskKey: 'test:proposal', version: 1, stageCode: 'S5', artifactType: 'concept',
  10. rawText: '仅用于测试,不是真实调研。', sourceIds: [], pendingQuestions: ['哪项实测会改变建议?'],
  11. report: { executive: ['这是样本内的待验证判断,不代表总体需求。'], delivery: { schema: 'ksf.workshop-delivery.v1', moduleId: 'D2-08', kind: 'proposal', contentStatus: 'ready', outputs: ['proposal'] },
  12. chapters: [{ id: 'product-proposal', title: '本轮比较问题', sections: [{ kind: 'findings', data: [{ conclusion: '改动与代价须一起验证', attitude: '解释不能超出依据。', evidence: 'e1' }] }] }],
  13. evidence: { e1: { claim: '单条原话仅作线索', sourceId: 'test-source', quotes: [{ text: '测试原话\n第二段内容。', author: '测试作者', ref: '测试平台|观察日2026-11-11; https://example.org/review/1' }] } } } });
  14. test('HTML保留真实输入的摘要、章节、解释、逐字证言、来源及待核问题,不编造统计或确认', () => {
  15. const input = task(), original = structuredClone(input), html = renderReportHtml(input);
  16. for (const text of [input.title, input.report.executive[0], input.report.chapters[0].title, '解释不能超出依据。', '测试原话\n第二段内容。', input.pendingQuestions[0], '测试平台|观察日2026-11-11']) assert.ok(html.includes(text), text);
  17. assert.match(html, /href="https:\/\/example.org\/review\/1"/);
  18. assert.match(html, /课堂过程成果 · 待员工核对/);
  19. assert.doesNotMatch(html, /<script|已获员工确认|<iframe/);
  20. assert.deepEqual(input, original);
  21. assert.equal(renderReportHtml(input), html, '同版输入无需联网即可重复生成相同字节');
  22. });
  23. test('来源协议与输入内容不会成为可执行HTML,锚点不使用不可信章节ID', () => {
  24. const input = task();
  25. input.title = '<img src=x onerror=alert(1)>';
  26. input.report.chapters[0].id = '"><script>bad</script>';
  27. input.report.evidence.e1.quotes[0].text = '</blockquote><script>bad</script>';
  28. input.report.evidence.e1.quotes[0].ref = 'javascript:alert(1)';
  29. const html = renderReportHtml(input);
  30. assert.ok(html.includes('&lt;img src=x onerror=alert(1)&gt;'));
  31. assert.doesNotMatch(html, /<script|href="javascript:|onerror="/);
  32. assert.match(html, /href="#chapter-1"/);
  33. for (const ref of ['data:text/html,test', 'https://name:secret@example.org/path', 'file:///private']) {
  34. input.report.evidence.e1.quotes[0].ref = ref;
  35. assert.ok(!renderReportHtml(input).includes(`href="${ref}"`));
  36. }
  37. });
  38. test('各合法section.kind完整可读,未知结构保留原文而不静默丢失', () => {
  39. const input = task();
  40. input.report.pipeline = [{ label: '资格核验', value: 7 }];
  41. input.report.chapters[0].sections = [
  42. { kind: 'para', text: '段落内容' }, { kind: 'longpara', text: '长文第一段\n\n长文第二段' },
  43. { kind: 'table', data: { cols: ['方案', '依据'], rows: [['真实产品', 'e1']] } },
  44. { kind: 'matrix', data: { cols: ['维度', '差异'], rows: [['测试维度', ['保留体验', '新增代价']]] } },
  45. { kind: 'two', data: { can: ['可做项'], cannot: ['红线项'] } },
  46. { kind: 'fit', data: { transferCols: ['路线'], transfer: [['可迁移条件']], redlineCols: ['限制'], redline: [['不得迁移条件']] } },
  47. { kind: 'score', data: [['测试机会', '待评', '待评', '待评', '待评', '不能排序']] },
  48. { kind: 'ladder', data: { ladder: [[1, '产品变量', '拟改设定']], statement: '本题定位', for_who: '本题场景' } },
  49. { kind: 'statement', data: { alt: ['测试表达'] } },
  50. { kind: 'milestones', data: [['M1', '对照试验', ['记录项'], '结果决定去向']] },
  51. { kind: 'funnel', data: [{ stage: 1, name: '去重', value: 3, unit: '条', scope: '隔离测试' }] },
  52. { kind: 'future', data: { original: '未知结构保留值' } },
  53. ];
  54. const html = renderReportHtml(input);
  55. for (const value of ['段落内容','长文第二段','真实产品','新增代价','可做项','红线项','不得迁移条件','不能排序','拟改设定','本题定位','测试表达','结果决定去向','隔离测试','未知结构保留值','资格核验']) assert.ok(html.includes(value), value);
  56. assert.doesNotMatch(html, /NaN|undefined|\[object Object\]/);
  57. });
  58. test('默认生成实际HTML附件,保留输入及其他附件,相同版本可复用但不能覆盖不同正文', t => {
  59. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-html-test-'));
  60. t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
  61. const input = task(), original = structuredClone(input), textFile = path.join(dir, '说明.txt');
  62. fs.writeFileSync(textFile, '隔离测试附件');
  63. const first = prepareHtmlReport(input, { directory: dir, localFiles: [{ path: textFile, contentType: 'text/plain' }] });
  64. assert.equal(first.task.report.attachments.length, 2);
  65. const html = first.task.report.attachments[0], bytes = fs.readFileSync(first.htmlPath);
  66. assert.equal(html.contentType, 'text/html');assert.equal(bytes.length, html.sizeBytes);
  67. assert.equal(bytes.toString('base64'), html.dataBase64);
  68. assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'), html.sha256);
  69. assert.deepEqual(prepareHtmlReport(first.task, { directory: dir }).task, first.task);
  70. input.report.executive[0] = '实质修改了判断';
  71. assert.throws(() => prepareHtmlReport(input, { directory: dir }), { code: 'REPORT_HTML_VERSION_CONFLICT' });
  72. input.version = 2;
  73. assert.notEqual(prepareHtmlReport(input, { directory: dir }).htmlPath, first.htmlPath);
  74. assert.deepEqual(original.report.executive, ['这是样本内的待验证判断,不代表总体需求。']);
  75. assert.equal(original.report.attachments, undefined);
  76. });
  77. test('超限HTML在任何写入前失败,既有交付合同仍检查空核心章节', t => {
  78. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-html-limit-'));
  79. t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
  80. const input = task();input.report.chapters[0].sections = [{ kind: 'para', text: '字'.repeat(400000) }];
  81. assert.throws(() => prepareHtmlReport(input, { directory: dir }), { code: 'REPORT_HTML_TOO_LARGE' });
  82. assert.equal(fs.readdirSync(dir).length, 0);
  83. input.report.chapters[0].sections = [];
  84. assert.throws(() => prepareHtmlReport(input, { directory: dir }), { code: 'VALIDATION_FAILED' });
  85. });
  86. test('样例标识由真实会话模式传入渲染,不向业务任务增加未允许字段', t => {
  87. const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-html-mode-'));
  88. t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
  89. const prepared = prepareHtmlReport(task(), { directory, dataMode: 'sample' });
  90. assert.match(fs.readFileSync(prepared.htmlPath, 'utf8'), /样例资料/);
  91. assert.equal(Object.hasOwn(prepared.task, 'dataMode'), false);
  92. });
  93. test('共用数字人的不同员工即使课题键和版本相同,本地HTML文件也按真实会话隔离', t => {
  94. const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-html-people-'));
  95. t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
  96. const a = task(), b = task(); a.sessionId = 'person-a'; b.sessionId = 'person-b';
  97. b.report.executive[0] = '另一位员工自己的报告内容';
  98. const first = prepareHtmlReport(a, { directory }), second = prepareHtmlReport(b, { directory });
  99. assert.notEqual(first.htmlPath, second.htmlPath);
  100. assert.ok(!fs.readFileSync(first.htmlPath, 'utf8').includes(b.report.executive[0]));
  101. assert.ok(fs.readFileSync(second.htmlPath, 'utf8').includes(b.report.executive[0]));
  102. });