feat: 新增日报生产片段 UI 契约与视觉保护

This commit is contained in:
Wxw-Gu
2026-09-09 11:17:29 +08:00
parent 1bc66e33fd
commit 7554de7d72
4 changed files with 494 additions and 35 deletions
@@ -0,0 +1,176 @@
import { _electron as electron, chromium, expect, test } from '@playwright/test'
import { createWriteStream, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { ZipArchive } from 'archiver'
const fixture = JSON.parse(
readFileSync(resolve('tests/fixtures/report-template-production-fragments.json'), 'utf8')
)
const makeZip = async (directory: string): Promise<string> => {
const archivePath = join(directory, 'hostile-avatar-template.zip')
const output = createWriteStream(archivePath)
const archive = new ZipArchive({ zlib: { level: 6 } })
archive.pipe(output)
archive.append(
JSON.stringify({
protocolVersion: '1.0',
kind: 'daily-report',
id: 'community.github.example.fragment-contract',
name: 'Production Fragment Contract 压力模板',
author: { name: 'fixture' },
templateVersion: '1.0.0',
interfaceVersion: '1',
entry: 'template.html',
capture: { width: 1000, maxWidth: 1000, maxHeight: 20000 },
license: { spdx: 'MIT' }
}),
{ name: 'manifest.json' }
)
archive.append(
`<!doctype html><html><head><style>
*{box-sizing:border-box}html,body{margin:0;background:#f2f5f7;color:#102030;font-family:sans-serif}body{overflow-x:hidden}.report{max-width:1000px;margin:0 auto;padding:20px}.panel{margin:12px 0;padding:14px;border:1px solid #c9d4dd;border-radius:8px}.topics,.participants{display:flex;flex-wrap:wrap;gap:8px}.topic-card{min-width:0;flex:1 1 260px}.important-card,.chat-msg{padding:10px;border-top:1px solid #d8e0e6}.chat-bubble{padding:8px;background:#fff;max-width:100%}img{width:100%;height:auto}.empty-section{display:none!important}@media(max-width:680px){.report{padding:12px}.panel{padding:10px}}
</style></head><body><main class="report"><header class="panel">{{HERO_AVATARS}}</header><section class="panel topics">{{TOPIC_CARDS}}</section><section class="panel">{{IMPORTANT_MESSAGES}}</section><section class="panel">{{QUOTE_BLOCKS}}</section><section class="panel">{{TODO_CARDS}}{{UNRESOLVED_CARDS}}</section><section class="panel">{{QA_CARDS}}</section><section class="panel">{{RANK_ITEMS}}</section></main></body></html>`,
{ name: 'template.html' }
)
await new Promise<void>((resolvePromise, reject) => {
output.on('close', resolvePromise)
output.on('error', reject)
archive.on('error', reject)
void archive.finalize().catch(reject)
})
return archivePath
}
test('REPORT-TEMPLATE-E2E-FRAGMENT-01 protects production avatars and text from hostile template CSS', async ({}, testInfo) => {
const userData = mkdtempSync(join(tmpdir(), 'tracememo-fragment-contract-user-'))
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-fragment-contract-output-'))
const fixtureDir = mkdtempSync(join(tmpdir(), 'tracememo-fragment-contract-fixture-'))
const launch = () =>
electron.launch({
args: [resolve('out/main/reportTemplateTest.js')],
env: {
...process.env,
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir
}
})
let app = await launch()
try {
const page = await app.firstWindow()
await page.waitForLoadState('domcontentloaded')
const installed = await page.evaluate((packagePath) => window.api.installReportTemplate(packagePath), await makeZip(fixtureDir))
expect(installed.success, installed.error).toBe(true)
const exported = await page.evaluate(
({ metadata, report }) =>
window.api.exportGroupReport({
templateRef: { id: 'community.github.example.fragment-contract', version: '1.0.0' },
metadata,
report
}),
fixture
)
expect(exported.success, exported.error).toBe(true)
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('tm-production-fragment-contract')
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('tm-avatar--fallback')
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('&lt;&gt;')
expect(readFileSync(exported.pngPath!).length).toBeGreaterThan(1000)
const browser = await chromium.launch({ headless: true })
try {
for (const viewportWidth of [1000, 430]) {
const inspectPage = await browser.newPage({ viewport: { width: viewportWidth, height: 1200 }, deviceScaleFactor: 1 })
const externalRequests: string[] = []
const badResponses: string[] = []
inspectPage.on('request', (request) => {
if (/^https?:/i.test(request.url())) externalRequests.push(request.url())
})
inspectPage.on('response', (response) => {
if (response.status() >= 400) badResponses.push(`${response.status()} ${response.url()}`)
})
await inspectPage.goto(pathToFileURL(exported.htmlPath!).href)
await inspectPage.waitForFunction(() => Array.from(document.images).every((image) => image.complete))
const metrics = await inspectPage.evaluate(() => {
const rect = (element: Element) => {
const box = element.getBoundingClientRect()
return { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }
}
const overlaps = (first: ReturnType<typeof rect>, second: ReturnType<typeof rect>) =>
first.left < second.right && first.right > second.left && first.top < second.bottom && first.bottom > second.top
const avatars = Array.from(document.querySelectorAll('img.tm-avatar')).map((element) => {
const box = rect(element)
const parent = element.closest('.tm-message,.tm-participant,.tm-ranking-item,.tm-fragment,.report')
return {
classes: element.className,
box,
parent: parent ? rect(parent) : null,
display: getComputedStyle(element).display,
objectFit: getComputedStyle(element).objectFit
}
})
const messages = Array.from(document.querySelectorAll('.tm-message')).map((element) => {
const avatar = element.querySelector('img.tm-avatar')
const body = element.querySelector('.tm-message__body')
const author = element.querySelector('.tm-message__author')
const timestamp = element.querySelector('.tm-message__time')
return {
message: rect(element),
avatar: avatar ? rect(avatar) : null,
body: body ? rect(body) : null,
author: author ? rect(author) : null,
timestamp: timestamp ? rect(timestamp) : null,
authorTimestampOverlap: author && timestamp ? overlaps(rect(author), rect(timestamp)) : false
}
})
return {
scrollWidth: document.documentElement.scrollWidth,
imageFailures: Array.from(document.images).filter((image) => image.naturalWidth === 0).length,
avatars,
messages,
fallbackCount: document.querySelectorAll('.tm-avatar--fallback').length
}
})
expect(metrics.scrollWidth).toBeLessThanOrEqual(viewportWidth + 1)
expect(metrics.imageFailures).toBe(0)
expect(metrics.fallbackCount).toBeGreaterThanOrEqual(4)
for (const avatar of metrics.avatars) {
expect(avatar.display).not.toBe('none')
expect(avatar.objectFit).toBe('cover')
expect(avatar.box.width).toBeGreaterThan(0)
expect(avatar.box.height).toBeGreaterThan(0)
expect(Math.abs(avatar.box.width - avatar.box.height)).toBeLessThanOrEqual(1)
if (avatar.classes.includes('tm-avatar--message')) {
expect(avatar.box.width).toBeGreaterThanOrEqual(28)
expect(avatar.box.width).toBeLessThanOrEqual(44)
}
if (avatar.classes.includes('tm-avatar--participant')) {
expect(avatar.box.width).toBeGreaterThanOrEqual(18)
expect(avatar.box.width).toBeLessThanOrEqual(28)
}
expect(avatar.parent).not.toBeNull()
expect(avatar.box.width).toBeLessThanOrEqual(avatar.parent!.width + 1)
expect(avatar.box.height).toBeLessThanOrEqual(avatar.parent!.height + 1)
}
for (const message of metrics.messages) {
expect(message.body?.width || 0).toBeGreaterThan(0)
expect(message.body!.right).toBeLessThanOrEqual(viewportWidth + 1)
expect(message.authorTimestampOverlap).toBe(false)
expect(message.avatar!.right).toBeLessThanOrEqual(message.body!.left + 1)
}
await inspectPage.screenshot({ path: testInfo.outputPath(`fragment-contract-${viewportWidth}.png`), fullPage: true })
await inspectPage.close()
expect(externalRequests).toEqual([])
expect(badResponses).toEqual([])
}
} finally {
await browser.close()
}
} finally {
await app.close()
rmSync(fixtureDir, { recursive: true, force: true })
rmSync(outputDir, { recursive: true, force: true })
rmSync(userData, { recursive: true, force: true })
}
})
+134
View File
@@ -0,0 +1,134 @@
{
"metadata": {
"groupName": "Production Fragment 验收组",
"reportDate": "2026-09-09",
"dateRange": "09:00-18:30",
"messageCount": 128,
"activeUsers": 9,
"timeSpan": "9 h 30 min",
"generatedAt": "2026-09-09 18:35",
"recordNote": "虚构数据:仅用于生产 fragment UI contract 验收。",
"footerNote": "不对应真实聊天记录。",
"heroParticipants": ["真实图片用户", "无头像", "王小明"],
"avatars": {
"真实图片用户": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII="
},
"reportMode": "full"
},
"report": {
"overview": "真实 renderer 生成的 fragment 压力 fixture,覆盖头像、fallback、长昵称、长正文与空模块。",
"hero": {
"headline": "生产 Fragment 压力验收",
"summary": "头像必须保持受保护的几何尺寸,文本必须在桌面和移动宽度下可读。",
"keyTakeaway": "模板主题可以变化,但不能把生产 fragment 挤坏。",
"pendingNote": "",
"statusLine": "使用真实与 fallback 头像混合场景。"
},
"topics": [
{
"title": "超长话题标题:接口回归、缓存排查与多端发布窗口协调需要持续跟踪",
"timeRange": "09:10-11:40",
"heat": "高",
"participants": ["真实图片用户", "无头像", "王小明", "赵小红", "陈晨", "李雷", "韩梅梅", "超长昵称示例用户(含 &<> 特殊字符)以及更多文字"],
"summary": "多个参与者同时讨论时,参与者头像与昵称应换行而不突破 topic 容器。",
"conclusions": [{ "text": "保留最小可读正文宽度,并保证 fallback 与真实图片头像的几何一致。" }],
"keywords": ["头像", "长昵称", "移动端", "contract"]
},
{
"title": "第二个话题",
"timeRange": "14:00-15:30",
"heat": "中",
"participants": ["无头像"],
"summary": "单参与者与空 optional 模块不应留下异常空白。",
"conclusions": [],
"keywords": ["空模块"]
}
],
"resources": [],
"importantMessages": [
{
"sender": "真实图片用户",
"time": "10:01",
"content": "普通图片头像与普通正文。",
"note": "图片头像"
},
{
"sender": "超长昵称示例用户(含 &<> 特殊字符)以及更多文字",
"time": "10:02",
"content": "这是一段包含中文、English、emoji 🙂、URL-like https://example.invalid/path?long=query 与超长连续字符串abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 的多行正文。\n第二行仍应自然换行,且不能被头像、昵称或时间挤压。",
"note": "长昵称与长正文"
},
{
"sender": "无头像",
"time": "10:03",
"content": "fallback avatar 使用单字名称。",
"note": "单字 fallback"
},
{
"sender": "王小明",
"time": "10:04",
"content": "fallback avatar 使用多字名称。",
"note": "多字 fallback"
}
],
"quotes": [
{
"messages": [
{ "sender": "真实图片用户", "content": "普通金句与真实图片头像。" },
{ "sender": "无头像", "content": "fallback 头像也必须保持与消息头像相同的安全几何。" }
],
"note": "带作者的普通金句。"
},
{
"messages": [
{ "sender": "超长昵称示例用户(含 &<> 特殊字符)以及更多文字", "content": "没有额外作者字段的长金句:当内容足够长时,消息容器的正文宽度仍必须大于零,并能在 430px 视口中换行。" }
],
"note": ""
}
],
"qa": [
{ "question": "长正文会不会压住头像?", "answer": "不会,正文容器具有最小可用宽度与换行保护。", "answerer": "无头像" }
],
"todos": [
{ "task": "完成 Production Fragment UI Contract 验收记录", "owner": "真实图片用户", "deadline": "今晚", "topic": "模板", "note": "正常 TODO" }
],
"unresolved": [
{ "question": "超长连续字符串在窄屏是否溢出?", "owner": "王小明", "status": "待验证", "note": "使用 guardrail 自动检查。", "lastDiscussedAt": "18:20" }
],
"storylines": [],
"reversals": [],
"participantChains": [],
"analytics": {
"topicHeat": [],
"activeTimeline": "09:00-18:30",
"topSpeakers": [
{ "name": "真实图片用户", "count": 34 },
{ "name": "无头像", "count": 21 },
{ "name": "王小明", "count": 15 }
],
"voiceLeaderboard": []
},
"keywords": ["production", "avatar", "fallback", "长昵称", "长正文"],
"media": { "gallery": [], "voiceHighlights": [], "funBadges": [] },
"sectionMeta": {
"topics": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 2, "displayedCount": 2 },
"importantMessages": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 4, "displayedCount": 4 },
"moments": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 2, "displayedCount": 2 },
"actions": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 2, "displayedCount": 2 },
"qa": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 1, "displayedCount": 1 },
"resources": { "enabled": false, "importance": 0, "confidence": 1, "totalCount": 0, "displayedCount": 0 }
},
"summaryStats": {
"messageCount": 128,
"activeUsers": 9,
"topicCount": 2,
"mediaCount": 0,
"imageCount": 0,
"voiceCount": 0,
"stickerCount": 0,
"conclusionCount": 1,
"todoCount": 1,
"unresolvedCount": 1
}
}
}