import fs from 'node:fs/promises'; import path from 'node:path'; import assert from 'node:assert/strict'; import { pathToFileURL } from 'node:url'; const out = path.resolve('docs/report-quality-html-20261111'); const raw = JSON.parse(await fs.readFile(path.join(out, 'browser-fixture.json'), 'utf8')); const html = await fs.readFile(path.join(out, '报告版式预览.html'), 'utf8'); const task = JSON.parse(await fs.readFile(path.join(out, 'preview-task.json'), 'utf8')); const origin = 'http://127.0.0.1:4181'; const { chromium } = await import(pathToFileURL(path.resolve('work/workshop-manual-tools/node_modules/playwright/index.mjs')).href); const browser = await chromium.launch({ headless: true, executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' }); const checks = [], errors = [], requests = [], sizes = []; await fs.mkdir(path.join(out, 'screenshots'), { recursive: true }); try { const context = await browser.newContext({ viewport: { width: 1440, height: 1050 } }); let filesEnabled = true; await context.route('**/*', async route => { const request = route.request(), url = new URL(request.url()); if (request.method() === 'GET' && url.pathname === '/api/functions' && url.searchParams.get('path') === '/ksf/file') { assert.equal(url.searchParams.get('artifactId'), 'argument-preview'); return route.fulfill({ contentType: 'text/html', body: html, headers: { 'content-security-policy': "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; sandbox allow-popups allow-popups-to-escape-sandbox", } }); } if (url.pathname === '/api/functions' || url.pathname === '/__dev/member-groups') { const body = request.postDataJSON(); requests.push(body?.path); const data = body?.path === '/ksfGetPublicBoard' ? structuredClone(raw) : body?.path === '/ksfGetPublicMemberGroups' ? { schema: 'ksf.workshop-member-groups.v1', groups: [], members: [] } : null; if (!data) { errors.push('Unexpected API ' + body?.path); return route.abort(); } if (!filesEnabled && data.artifact) delete data.artifact[0].report.attachments; return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ result: { ok: true, data } }) }); } if (url.origin === origin && request.method() === 'GET') return route.continue(); // Source link navigation is intercepted to prove it can open from the sandbox. if (url.hostname === 'sh.jd.com') return route.fulfill({ contentType: 'text/html', body: '

原始来源链接打开验证(未访问线上来源)

' }); errors.push('Unexpected request ' + request.method() + ' ' + url.origin + url.pathname); return route.abort(); }); const page = await context.newPage(); page.on('pageerror', e => errors.push(e.message)); for (const width of [1440, 390, 320]) { await page.setViewportSize({ width, height: 1050 }); await page.goto(origin + '/docs/report-quality-html-20261111/' + encodeURIComponent('报告版式预览.html')); const measure = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth, quotes: [...document.querySelectorAll('.source-group .quote-card')].map(el => ({ height: el.getBoundingClientRect().height, clientHeight: el.clientHeight, scrollHeight: el.scrollHeight })), tableScrolls: [...document.querySelectorAll('.table-wrap')].map(el => ({ client: el.clientWidth, scroll: el.scrollWidth })), text: document.body.innerText, dividers: [...document.querySelectorAll('.quote-card')].every(el => getComputedStyle(el.querySelector('figcaption')).borderBottomWidth === '1px' && getComputedStyle(el.querySelector('.quote-ref')).borderTopWidth === '1px'), })); assert.ok(measure.scrollWidth <= width, JSON.stringify(measure)); assert.equal(measure.dividers, true); assert.equal(measure.quotes.length, 3); assert.ok(measure.quotes.every(q => q.scrollHeight <= q.clientHeight + 1)); assert.ok(measure.quotes[2].height > measure.quotes[0].height + 30); for (const entry of Object.values(task.report.evidence)) assert.ok(measure.text.includes(entry.quotes[0].text)); await page.screenshot({ path: path.join(out, `screenshots/html-cover-${width}.png`) }); await page.locator('.finding').first().scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(out, `screenshots/html-evidence-${width}.png`) }); await page.locator('#chapter-3').scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(out, `screenshots/html-comparison-${width}.png`) }); sizes.push({ type: 'html', width, ...measure, text: undefined }); checks.push(`${width}px 独立HTML:摘要/正文/原话保留、卡片自适应、来源分段、表格局部滚动、整页无溢出`); await page.goto(origin + '/report/argument-preview?mode=sample'); await page.locator('[data-html-report]').waitFor(); const frame = page.frameLocator('view-report iframe.html-report-preview'); await frame.locator('h1').waitFor(); assert.equal(await frame.locator('h1').textContent(), task.title); assert.equal(await page.locator('[data-chapter]:visible').count(), 0, '默认读HTML,避免正文重复堆叠'); assert.equal(await page.locator('.reading-formats button[aria-pressed="true"]').textContent(), 'HTML 排版'); const screen = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth })); assert.ok(screen.scrollWidth <= width, JSON.stringify(screen)); await page.locator('[data-html-report]').scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(out, `screenshots/board-html-${width}.png`) }); await page.getByRole('button', { name: '看板正文与证据', exact: true }).click(); await page.locator('[data-evidence-reading]').waitFor(); assert.equal(await page.locator('[data-chapter]:visible').count(), 3); assert.equal(await page.locator('.html-report-preview').count(), 0); assert.deepEqual(await page.locator('[data-evidence-reading] blockquote').allTextContents(), Object.values(task.report.evidence).map(e => e.quotes[0].text)); checks.push(`${width}px 看板:默认HTML、切换正文/证据、保留章节与原话、无溢出`); } await page.setViewportSize({ width: 1440, height: 1050 }); await page.getByRole('button', { name: 'HTML 排版', exact: true }).click(); const frame = page.frameLocator('view-report iframe.html-report-preview'); const popupWait = page.waitForEvent('popup'); await frame.locator('.quote-ref a').first().click(); const popup = await popupWait; await popup.waitForLoadState(); assert.equal(new URL(popup.url()).hostname, 'sh.jd.com'); await popup.close(); checks.push('HTML内来源链接可从受限预览打开;仅测试拦截,未访问外部来源'); const sandbox = await page.locator('.html-report-preview').getAttribute('sandbox'); assert.ok(!sandbox.includes('allow-scripts') && !sandbox.includes('allow-same-origin')); assert.ok((await page.locator('.html-open').getAttribute('href')).includes('preview=1')); assert.ok((await page.getByRole('link', { name: '下载 HTML', exact: true }).getAttribute('href')).includes('artifactId=argument-preview')); checks.push('独立阅读、下载关联同报告文件;预览不授予脚本或同源权限'); await page.emulateMedia({ media: 'print' }); assert.equal(await page.locator('[data-html-report]:visible').count(), 0); assert.equal(await page.locator('[data-chapter]:visible').count(), 3); assert.equal(await page.locator('[data-evidence-reading]:visible').count(), 1); await page.emulateMedia({ media: 'screen' }); await page.goto(origin + '/report/argument-preview?mode=sample&ch=next-test'); await page.locator('#ch-next-test').waitFor(); assert.equal(await page.locator('.reading-formats button[aria-pressed="true"]').textContent(), '看板正文与证据'); checks.push('带章节深链接自动切到看板正文,保留原章节阅读'); filesEnabled = false; await page.goto(origin + '/report/argument-preview?mode=sample'); await page.locator('[data-evidence-reading]').waitFor(); assert.equal(await page.locator('[data-html-report]').count(), 0); assert.equal(await page.locator('[data-chapter]:visible').count(), 3); checks.push('没有HTML附件的历史报告保留可读正文,不造文件或链接'); await page.goto(origin + '/docs/workshop-quality-r13-20261110/' + encodeURIComponent('final-康师傅工作坊操作手册.html')); assert.ok(await page.locator('body').innerText()); const manual = await page.evaluate(() => window.WORKSHOP_MANUAL_DATA); assert.equal(manual.steps.length, 14); const bank = JSON.parse(await fs.readFile('docs/workshop-quality-r13-20261110/manual/workshop-dialogue-prompts.json', 'utf8')); assert.match(bank.shared.delivery, /不合格先补研究或修正文/); checks.push('final手册可读取,课表14页、42轮提示词结构保留,含内容自检与默认HTML规则'); assert.deepEqual(errors, []); assert.ok(requests.every(x => ['/ksfGetPublicBoard','/ksfGetPublicMemberGroups'].includes(x))); const result = { ok: true, checks, sizes, errors, fixtureOnly: true, externalCollectionRequests: 0, productionWrites: 0 }; await fs.writeFile(path.join(out, 'browser-verification.json'), JSON.stringify(result, null, 2) + '\n'); console.log(JSON.stringify({ ok: true, checks: checks.length, errors, productionWrites: 0 })); } finally { await browser.close(); }