verify-workshop-dialogue-manual.mjs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import fs from 'node:fs/promises';
  2. import path from 'node:path';
  3. import assert from 'node:assert/strict';
  4. import crypto from 'node:crypto';
  5. import { pathToFileURL } from 'node:url';
  6. import { manualFile, bankFile, compileDialogue } from './update-workshop-dialogue-manual.mjs';
  7. const outIndex = process.argv.indexOf('--out');
  8. const out = path.resolve(outIndex < 0 ? 'docs/workshop-dialogue-guide-20261111' : process.argv[outIndex + 1]);
  9. await fs.mkdir(out, { recursive: true });
  10. const html = await fs.readFile(manualFile, 'utf8');
  11. const data = JSON.parse(html.match(/window\.WORKSHOP_MANUAL_DATA = (\{[\s\S]*?\n\});\r?\n<\/script>/)[1]);
  12. const bank = JSON.parse(await fs.readFile(bankFile, 'utf8'));
  13. const map = compileDialogue(bank, data.steps);
  14. const embedded = JSON.parse(html.match(/ const dialogueMap = (\{[^\r\n]+\});/)[1]);
  15. assert.deepEqual(embedded, map, '界面复制文本必须来自当前提示词库');
  16. const protectedRoot = 'docs/ksf-workshop-skill-20261110-r13-r2/package/skills/ksf-workshop-runtime';
  17. const protectedFiles = ['runtime-lock.json', ...['ksf', 'ksf-secure', 'ksf-workshop', 'ksf-identity', 'ksf-identity-bridge', 'describe-business-schema'].map(file => 'scripts/' + file + '.mjs')];
  18. const protectedChecks = [];
  19. for (const file of protectedFiles) {
  20. const actual = await fs.readFile('skills/ksf-workshop-runtime/' + file);
  21. assert.deepEqual(actual, await fs.readFile(protectedRoot + '/' + file), '受保护文件变化:' + file);
  22. protectedChecks.push({ file, sha256: crypto.createHash('sha256').update(actual).digest('hex') });
  23. }
  24. const manifest = await fs.readFile('skills/ksf-workshop-runtime/FILES.sha256', 'utf8');
  25. let filesVerified = 0;
  26. for (const line of manifest.trim().split(/\r?\n/)) {
  27. const [, expected, file] = line.match(/^([a-f0-9]{64})\s+(.+)$/);
  28. assert.equal(crypto.createHash('sha256').update(await fs.readFile('skills/ksf-workshop-runtime/' + file)).digest('hex'), expected, file);
  29. filesVerified++;
  30. }
  31. for (const step of data.steps.filter(step => step.id !== 'topic')) {
  32. assert.ok(bank.steps[step.id].rounds.some(round => round.deliver), '环节缺少完整成报轮:' + step.id);
  33. }
  34. const { chromium } = await import(pathToFileURL(path.resolve('work/workshop-manual-tools/node_modules/playwright/index.mjs')).href);
  35. const browser = await chromium.launch({ headless: true, executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' });
  36. const origin = 'http://127.0.0.1:4181';
  37. const url = origin + '/' + manualFile.split('/').map(encodeURIComponent).join('/');
  38. const checks = [], errors = [], blocked = [];
  39. let copied = 0;
  40. try {
  41. const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
  42. await context.route('**/*', route => {
  43. const request = route.request();
  44. if (new URL(request.url()).origin === origin && request.method() === 'GET') return route.continue();
  45. blocked.push({ method: request.method(), url: request.url() });
  46. return route.abort();
  47. });
  48. await context.addInitScript(() => {
  49. window.__copiedPrompt = null;
  50. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: {
  51. writeText: async text => { window.__copiedPrompt = text; }
  52. } });
  53. });
  54. const page = await context.newPage();
  55. page.on('pageerror', error => errors.push(error.message));
  56. await page.goto(url);
  57. for (const width of [1280, 390]) {
  58. await page.setViewportSize({ width, height: 900 });
  59. for (const step of data.steps) {
  60. await page.evaluate(id => { location.hash = id; }, step.id);
  61. await page.locator('#prompt-' + step.id + '-0').waitFor();
  62. const rounds = map[step.id];
  63. assert.equal(await page.locator('[data-copy-round]').count(), rounds.length);
  64. assert.equal(await page.locator('.round-feedback').count(), rounds.length);
  65. const duplicateIds = await page.evaluate(() => {
  66. const ids = [...document.querySelectorAll('[id]')].map(node => node.id);
  67. return ids.filter((id, i) => ids.indexOf(id) !== i);
  68. });
  69. assert.deepEqual(duplicateIds, []);
  70. const size = await page.evaluate(() => ({ innerWidth, scrollWidth: document.documentElement.scrollWidth }));
  71. assert.ok(size.scrollWidth <= size.innerWidth, step.id + ': ' + JSON.stringify(size));
  72. for (let i = 0; i < rounds.length; i++) {
  73. assert.equal(await page.locator('#prompt-' + step.id + '-' + i).textContent(), rounds[i].prompt);
  74. if (width === 1280) {
  75. await page.locator('[data-copy-round="' + i + '"]').click();
  76. assert.equal(await page.evaluate(() => window.__copiedPrompt), rounds[i].prompt, '实际点击复制了错误轮次');
  77. copied++;
  78. }
  79. }
  80. }
  81. checks.push(width + 'px:14个环节轮数、正文、反馈、唯一ID及无横向溢出');
  82. await page.evaluate(() => { location.hash = 'category'; });
  83. await page.locator('#prompt-category-0').waitFor();
  84. await page.evaluate(() => scrollTo(0, 0));
  85. await page.screenshot({ path: path.join(out, 'manual-' + width + '.png') });
  86. }
  87. checks.push(copied + '段按钮实际复制内容与唯一提示词源逐字一致');
  88. await page.evaluate(() => { location.hash = 'topic'; });
  89. await page.locator('#prompt-topic-0').waitFor();
  90. assert.equal(await page.locator('.round-title').first().textContent(), '第1轮 / 3 确认称呼与登录账号');
  91. await page.evaluate(() => scrollTo(0, 0));
  92. await page.screenshot({ path: path.join(out, 'account-first-round-390.png') });
  93. checks.push('01首位为独立称呼与账号轮,课题研究从第二轮开始');
  94. await page.locator('.menu-button').click();
  95. await page.locator('.nav-item[href="#rules"]').click();
  96. await page.locator('#prompt-rules-0').waitFor();
  97. assert.equal(await page.locator('.menu-button').getAttribute('aria-expanded'), 'false');
  98. await page.locator('#next').click();
  99. await page.locator('#prompt-competitors-0').waitFor();
  100. await page.locator('#previous').click();
  101. await page.locator('#prompt-rules-0').waitFor();
  102. checks.push('移动目录、前后环节导航能切换正确提示词');
  103. // Clipboard failures must select the clicked round, not the first prompt on the page.
  104. await page.evaluate(() => { location.hash = 'proposal'; });
  105. await page.locator('#prompt-proposal-3').waitFor();
  106. assert.equal(await page.locator('.round-prerequisite').textContent(), map.proposal[3].prerequisite);
  107. await page.locator('.round-prerequisite').scrollIntoViewIfNeeded();
  108. await page.screenshot({ path: path.join(out, 'proposal-confirmation-390.png') });
  109. await page.evaluate(() => {
  110. navigator.clipboard.writeText = async () => { throw Error('Clipboard denied for QA'); };
  111. document.execCommand = () => false;
  112. });
  113. await page.locator('[data-copy-round="3"]').click();
  114. assert.equal(await page.evaluate(() => getSelection().toString()), map.proposal[3].prompt);
  115. checks.push('剪贴板拒绝时仅选中所点的第四轮正文;确认前提在按钮前可见');
  116. await page.evaluate(() => {
  117. Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
  118. document.execCommand = command => {
  119. window.__fallbackCopied = command === 'copy' ? document.activeElement.value : null;
  120. return command === 'copy';
  121. };
  122. });
  123. await page.locator('[data-copy-round="1"]').click();
  124. assert.equal(await page.evaluate(() => window.__fallbackCopied), map.proposal[1].prompt);
  125. assert.equal(await page.locator('textarea').count(), 0);
  126. checks.push('非安全上下文复制回退传入正确段落并移除临时文本框');
  127. const before = await page.evaluate(() => Object.keys(localStorage));
  128. await page.locator('#completed').check();
  129. assert.ok(await page.evaluate(() => Object.keys(localStorage).length >= 1));
  130. await page.locator('#completed').uncheck();
  131. checks.push('勾选进度仅保留本机,不触发业务网络写入');
  132. assert.deepEqual(blocked, []);
  133. assert.deepEqual(errors, []);
  134. await context.close();
  135. const offline = await browser.newContext({ offline: true, viewport: { width: 390, height: 900 } });
  136. const filePage = await offline.newPage();
  137. filePage.on('pageerror', error => errors.push(error.message));
  138. await filePage.goto(pathToFileURL(path.resolve(manualFile)).href + '#category');
  139. assert.equal(await filePage.locator('[data-copy-round]').count(), 3);
  140. assert.equal(await filePage.locator('#prompt-category-2').textContent(), map.category[2].prompt);
  141. checks.push('直接以file://离线打开时,分轮正文及导航无需联网加载');
  142. await offline.close();
  143. } finally {
  144. await browser.close();
  145. }
  146. assert.deepEqual(errors, []);
  147. const result = { ok: true, checks, stepCount: data.steps.length, roundCount: Object.values(map).flat().length,
  148. copiedRoundsVerified: copied, publicFilesVerified: filesVerified + 1, protectedFiles: protectedChecks,
  149. browserErrors: errors, blockedRequests: blocked, productionWrites: 0,
  150. limitations: ['浏览器与脚本核验不证明实际课堂研究质量提升', '未发起真实采集、模型对照试验或业务入库'] };
  151. await fs.writeFile(path.join(out, 'verification.json'), JSON.stringify(result, null, 2) + '\n');
  152. console.log(JSON.stringify({ ok: true, checks: checks.length, copiedRoundsVerified: copied, publicFilesVerified: filesVerified + 1 }));