verify-workshop-local-final.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import fs from 'node:fs/promises';
  2. import path from 'node:path';
  3. import vm from 'node:vm';
  4. import assert from 'node:assert/strict';
  5. import crypto from 'node:crypto';
  6. import { pathToFileURL } from 'node:url';
  7. import { buildModel } from '../src/core/kfs/build.js';
  8. import { WORKSHOP_OUTPUTS } from '../src/core/workshop-outputs.js';
  9. const root = path.resolve('docs/workshop-final-local-20261110');
  10. const work = path.resolve('work/workshop-final-local-20261110');
  11. await fs.mkdir(work, { recursive: true });
  12. const samples = JSON.parse(await fs.readFile(path.join(root, 'review-samples.json')));
  13. const source = Object.fromEntries(samples.outputs.map(row => [row.id, row]));
  14. const sourceIds = { market: 'D1-01', social: 'D2-02', evidence: 'D2-01', opportunities: 'D2-04', validation: 'D2-06', value: 'D2-07', proposal: 'D2-08' };
  15. const blank = () => ({ project: [], artifact: [], session: [], source: [], opportunity: [], insight: [] });
  16. const raw = blank();
  17. for (const [person, groupId] of [['a', 'sample-a'], ['b', 'sample-b']]) {
  18. raw.session.push({ objectId: 'sample-session-' + person, studentName: '同名样例', groupId,
  19. meta: { participantKey: 'participant:sample-person-' + person }, workshopKey: person === 'a' ? '样例办公午餐' : '样例家庭晚餐' });
  20. }
  21. for (const output of WORKSHOP_OUTPUTS) {
  22. const original = source[sourceIds[output.key]];
  23. assert.ok(original, sourceIds[output.key]);
  24. const report = structuredClone(original.report);
  25. report.executive = [original.finding, original.meaning, original.next];
  26. if (output.key === 'social') {
  27. report.chapters = [{ id: output.anchor, title: '社媒场景线索', stage: 'S2', sections: [
  28. { kind: 'para', text: '构造素材V12:“宿舍没锅,想找不需要煮、倒水也简单的夜宵。”该条无商品归属,只支持宿舍使用任务的品类线索,不归属某款或品牌,也不能推需求规模。' },
  29. ] }];
  30. } else report.chapters[0].id = output.anchor;
  31. raw.artifact.push({ objectId: 'render-sample-' + output.key, title: '【展示样例】' + original.title,
  32. artifactType: original.type, dataMode: 'sample', workshopKey: '样例办公午餐', ownerName: '同名样例', uploadedByKey: 'participant:sample-person-a',
  33. reportSeriesKey: 'sample-topic-a:' + sourceIds[output.key] + ':' + output.key, version: 2,
  34. stageCode: original.stage, report, rawText: original.rawText, updatedAt: '2026-11-10T02:00:00Z' });
  35. }
  36. const history = structuredClone(raw.artifact[0]); history.objectId += '-history';history.version = 1;raw.artifact.push(history);
  37. const second = structuredClone(raw.artifact[0]);second.objectId += '-group-b';second.uploadedByKey = 'participant:sample-person-b';second.workshopKey = '样例家庭晚餐';
  38. second.title = '【展示样例】家庭可煮条件的产品比较';raw.artifact.push(second);
  39. const missing = { objectId: 'render-sample-unknown', title: '【展示样例】未定模式和类别', rawText: '仅用于核验未知模式显示', artifactType: 'assessment_report' };raw.artifact.push(missing);
  40. const db = buildModel(raw);
  41. const groupState = { schema: 'ksf.member-groups.v1', revision: 0, groups: [{ id: 'sample-a', name: '样例A组' }, { id: 'sample-b', name: '样例B组' }],
  42. assignments: Object.fromEntries(db.members.filter(m => m.keys.length).map(m => [m.id, m.keys.includes('participant:sample-person-a') ? 'sample-a' : 'sample-b'])) };
  43. await fs.writeFile(path.join(work, 'rendering-fixture.json'), JSON.stringify({ notice: '隔离浏览器展示样例,未保存至任何业务库', raw, groupState }, null, 2));
  44. const { chromium } = await import(pathToFileURL(path.resolve('work/workshop-manual-tools/node_modules/playwright/index.mjs')).href);
  45. const browser = await chromium.launch({ headless: true, executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' });
  46. const checks = [], errors = [], actions = [];
  47. const record = (name, extra = {}) => checks.push({ name, ...extra });
  48. try {
  49. const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, permissions: ['clipboard-read', 'clipboard-write'] });
  50. const page = await context.newPage();page.on('pageerror', e => errors.push(e.message));
  51. let activeRaw = raw, activeGroups = groupState, failGroups = false;
  52. await page.route('**/api/functions', async route => {
  53. const body = route.request().postDataJSON(), name = body.path;
  54. actions.push(name);
  55. assert.equal(name, '/ksfGetPublicBoard', 'Only the public board read may reach this intercepted endpoint');
  56. return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ result: { ok: true, data: { ...activeRaw, nextCursor: null } } }) });
  57. });
  58. await page.route('**/__dev/member-groups', route => {
  59. if (failGroups) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'sample-group-read-unavailable' }) });
  60. return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ result: { ok: true, data: activeGroups } }) });
  61. });
  62. const base = 'http://127.0.0.1:4192';
  63. const ready = async () => {
  64. await page.locator('view-workshop-outputs .filters').waitFor();
  65. await page.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot.querySelector('view-workshop-outputs')?._loading === false);
  66. };
  67. const countIs = async expected => {
  68. await page.waitForFunction(n => document.querySelector('kf-app')?.shadowRoot.querySelector('view-workshop-outputs')?.renderRoot.querySelectorAll('[data-workshop-artifact]').length === n, expected);
  69. assert.equal(await page.locator('[data-workshop-artifact]').count(), expected);
  70. };
  71. await page.goto(base + '/workshop');await ready();
  72. await countIs(0);record('default-live-excludes-samples');
  73. await page.getByLabel('资料范围', { exact: true }).selectOption('sample');
  74. await page.locator('[data-workshop-artifact="render-sample-market"]').waitFor();
  75. await countIs(8);record('sample-scope-latest-versions');
  76. await page.getByLabel('小组', { exact: true }).selectOption('sample-a');
  77. await countIs(7);record('same-name-group-stable-identity');
  78. await page.getByLabel('研究课题', { exact: true }).selectOption('样例办公午餐');
  79. const matrixRow = page.locator('[data-output-group="sample-a"]');
  80. assert.equal(await matrixRow.getByRole('button', { name: /1份$/ }).count(), 7);record('seven-output-links');
  81. await page.reload();await ready();
  82. assert.equal(await page.getByLabel('小组', { exact: true }).inputValue(), 'sample-a');
  83. await countIs(7);record('deep-link-refresh-retains-scope');
  84. await page.getByLabel('成果类别', { exact: true }).selectOption('market');
  85. await countIs(1);
  86. await page.getByLabel('显示历史版本', { exact: true }).check();
  87. await countIs(2);record('history-toggle');
  88. await page.getByLabel('显示历史版本', { exact: true }).uncheck();
  89. for (const width of [1440, 390]) {
  90. await page.setViewportSize({ width, height: 1000 });
  91. await page.getByLabel('成果类别', { exact: true }).selectOption('');
  92. const size = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
  93. assert.ok(size.scroll <= size.width, JSON.stringify(size));
  94. await page.screenshot({ path: path.join(root, `小组成果-${width}.png`), fullPage: true });record('group-view-no-page-overflow', { width });
  95. }
  96. await page.setViewportSize({ width: 1440, height: 1000 });
  97. for (const output of WORKSHOP_OUTPUTS) {
  98. await page.locator(`[data-workshop-artifact="render-sample-${output.key}"] .refs a`).first().click();
  99. await page.locator('view-report').waitFor();
  100. assert.equal(new URL(page.url()).searchParams.get('ch'), output.anchor);
  101. const returnTo = new URL(page.url()).searchParams.get('returnTo');assert.ok(returnTo.includes('sample-a'));
  102. await page.locator(`view-report [data-chapter="${output.anchor}"]`).waitFor();
  103. const original = raw.artifact.find(a => a.objectId === 'render-sample-' + output.key);
  104. const chapter = original.report.chapters[0], normalize = text => String(text).replace(/\s+/gu, '');
  105. const rendered = normalize(await page.locator('view-report').evaluate(el => el.renderRoot.textContent));
  106. assert.ok(rendered.includes(normalize(chapter.title)), output.key);
  107. const cellText = cell => {
  108. if (Array.isArray(cell)) return cell.flatMap(v => Array.isArray(v) ? [String(v[0]), String(v[1]) + '%'] : [String(v)]);
  109. if (typeof cell === 'number') return [cell.toLocaleString('zh-CN')];
  110. if (typeof cell === 'string' && original.report.evidence?.[cell]) return [original.report.evidence[cell].claim || cell];
  111. return [String(cell ?? '—')];
  112. };
  113. const values = chapter.sections.flatMap(s => ['para', 'longpara'].includes(s.kind) ? [s.text || '']
  114. : ['table', 'matrix'].includes(s.kind) ? [...(s.data?.cols || []), ...(s.data?.rows || []).flatMap(row => row.flatMap(cellText))] : []).filter(v => normalize(v));
  115. for (const value of values) assert.ok(rendered.includes(normalize(value)), output.key + ': missing ' + value);
  116. await page.goBack();await ready();await countIs(7);record('chapter-content-link-and-return', { output: output.key, paragraphAndTableValues: values.length });
  117. }
  118. await page.getByLabel('资料范围', { exact: true }).selectOption('unknown');
  119. await page.getByLabel('研究课题', { exact: true }).selectOption('');
  120. await page.getByLabel('小组', { exact: true }).selectOption('');
  121. await countIs(1);record('unknown-mode-visible-separately');
  122. failGroups = true;await page.reload();await ready();
  123. await page.locator('view-workshop-outputs [role="alert"]').waitFor();
  124. await countIs(1);record('group-read-failure-keeps-content');
  125. failGroups = false;
  126. activeRaw = JSON.parse(await fs.readFile('work/workshop-board-acceptance-20261110/public-board.json'));
  127. activeGroups = JSON.parse(await fs.readFile('work/workshop-board-acceptance-20261110/public-groups.json'));
  128. await page.goto(base + '/workshop?mode=all');await ready();
  129. await countIs(activeRaw.artifact.length);record('existing-public-snapshot-all-artifacts-retained', { artifacts: activeRaw.artifact.length });
  130. await page.getByLabel('资料范围', { exact: true }).selectOption('live');
  131. await countIs(15);
  132. assert.ok((await page.locator('view-workshop-outputs').evaluate(el => el.renderRoot.textContent)).includes('15 份成果未分组'));record('existing-live-ungrouped-not-invented');
  133. await page.screenshot({ path: path.join(root, '现有数据-缺项如实显示.png'), fullPage: true });
  134. await page.goto(base + '/reports/assessment');await page.locator('view-report-type').waitFor();
  135. await page.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot.querySelector('view-report-type')?.renderRoot.textContent.includes('评估与论证'));
  136. record('assessment-page-readable');
  137. const manualUrl = base + '/docs/workshop-final-local-20261110/manual/index.html';
  138. await page.goto(manualUrl);await page.locator('#prompt-text').waitFor();
  139. const steps = await page.evaluate(() => window.WORKSHOP_MANUAL_DATA.steps.map(s => s.id));
  140. assert.equal(steps.length, 15);
  141. for (const width of [1440, 390]) {
  142. await page.setViewportSize({ width, height: 1000 });
  143. for (const id of steps) {
  144. await page.evaluate(id => { location.hash = id; }, id);
  145. await page.waitForFunction(id => location.hash === '#' + id && document.querySelector('.nav-item[aria-current="step"]')?.getAttribute('href') === '#' + id, id);
  146. const text = await page.locator('#prompt-text').textContent();assert.ok(text.length > 100);
  147. await page.locator('#copy-prompt').click();assert.equal((await page.evaluate(() => navigator.clipboard.readText())).replaceAll('\r\n', '\n'), text.replaceAll('\r\n', '\n'));
  148. assert.ok(await page.locator('#prompt-text').isVisible());
  149. const size = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
  150. assert.ok(size.scroll <= size.width);record('manual-full-copy-visible-and-no-overflow', { width, id });
  151. }
  152. await page.screenshot({ path: path.join(root, `手册-${width}.png`), fullPage: true });
  153. }
  154. await page.goto(pathToFileURL(path.join(root, '康师傅工作坊操作手册-本地定稿.html')).href);
  155. await page.locator('#prompt-text').waitFor();assert.equal(await page.locator('.nav-item').count(), 15);record('standalone-file-opens-offline');
  156. await page.goto(pathToFileURL(path.join(root, '提示词与高质反馈预览.html')).href);
  157. assert.equal(await page.locator('#module option').count(), 13);
  158. for (const width of [1440, 390]) {
  159. await page.setViewportSize({ width, height: 1000 });
  160. for (const output of samples.outputs) {
  161. await page.locator('#module').selectOption(output.id);
  162. assert.ok((await page.locator('article.active .finding').textContent()).includes(output.finding));
  163. assert.ok(!(await page.locator('article.active').textContent()).includes('49天'));
  164. const size = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
  165. assert.ok(size.scroll <= size.width);record('feedback-preview-content-and-overflow', { width, module: output.id });
  166. }
  167. await page.screenshot({ path: path.join(root, `反馈预览-${width}.png`), fullPage: true });
  168. }
  169. await context.close();
  170. } finally { await browser.close(); }
  171. assert.deepEqual(errors, []);
  172. const sha = buffer => crypto.createHash('sha256').update(buffer).digest('hex');
  173. const skill = path.resolve('skills/ksf-workshop-runtime');
  174. const manifest = (await fs.readFile(path.join(skill, 'FILES.sha256'), 'utf8')).trim().split(/\r?\n/);
  175. for (const entry of manifest) { const [hash, name] = entry.split(' ');assert.equal(sha(await fs.readFile(path.join(skill, name))), hash, name); }
  176. const result = { schema: 'ksf.workshop.local-final-validation.v1', checkedAt: new Date().toISOString(),
  177. checks, browserChecksPassed: checks.length, browserErrors: errors, skillManifestFilesVerified: manifest.length,
  178. productionWrites: 0, deploymentPerformed: false, sampleFixtureIsolation: 'browser route interception only',
  179. actualHostOrEmployeeTrial: false, publicSnapshotReplay: 'existing read-only production audit data, not newly published content' };
  180. await fs.writeFile(path.join(root, 'validation.json'), JSON.stringify(result, null, 2) + '\n');
  181. console.log(JSON.stringify({ browserChecksPassed: checks.length, errors: errors.length, manifestFiles: manifest.length, productionWrites: 0 }));