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(); }