Skip to content

Commit 6d42447

Browse files
committed
A/4: PR review comment 走 markdown 渲染 + diff webview 配色/移动端适配
范围与背景 ---------- 承接 A/3(PR review comment 内联到 diff 行),A/4 解决两件事: 1. **comment body 从"纯 escape+<br>"升级到真正的 markdown 渲染**: fixture PR #938 上 Copilot 在 android/app/build.gradle L84 挂的 ```suggestion 代码块之前被渲染成一坨转义后的纯文本,可读性差 2. **代码预览 webview 配色割裂 + 移动端不适配**:真机反馈—— `HtmlUtils.generateCodeHtml` 固定挂 highlight.js `default`(浅色主题) 但调用方传的是 `webDraculaBackgroundColorString`(深色底), hljs 输出的深色 token 在深底上几乎不可见;且无 padding / 字号 / line-height / overflow-x 处理,手机上排版不像原生 App 变更文件 -------- - **新增** lib/common/utils/comment_markdown.dart:包装 `markdown` 包的 `markdownToHtml`,开启 fenced-code / inline-code / autolinks; XSS 底线:先自行前置 escape `<字母` / `</字母` / `<!` / `<?` 序列, 再喂给 markdown 走 `ExtensionSet.none` + `encodeHtml: true`。 失败降级到 5 字符 escape + `\n → <br>` - **改** lib/common/utils/html_utils.dart:`generateCodeHtml` 按 `backgroundColor` 判定深/浅主题——深色挂 hljs `atom-one-dark` + 文字 `#e5e7eb`,浅色挂 `github` + 文字 `#24292f`;加 `-webkit-text- size-adjust` / `padding:12px` / `font-family: ui-monospace, …` / `font-size:14px` / `line-height:1.55` / `pre { overflow-x:auto; }` 等移动端排版 - **改** lib/page/issue/pull_request_files_page.dart:`_buildAddLineExtras` 的 body 从 `_htmlEscape+<br>` 换成 `renderCommentBodyToInlineHtml`; author 仍走 `_htmlEscape`(纯 escape);dartdoc 从"A/3"改为"A/3 + A/4" - **新增** test/utils/comment_markdown_test.dart:9 个测试,覆盖 null / 空串、fenced-code、inline-code、autolinks、XSS `<script>` / `<img onerror>`、纯文本 fallback、多段落、fixture PR #938 完整 body 关键设计 -------- - **不改 pubspec.yaml**:`markdown` 是 `flutter_markdown_plus` 的传递 依赖(pubspec.lock 已锁),import 处显式 `// ignore: depend_on_referenced_packages` 注明理由 - **XSS 根因排查**:`ExtensionSet.commonMark` 里的 `InlineHtmlSyntax` **和** 标准 block 集里的 `HtmlBlockSyntax` 两处都放行 raw HTML; 即便扫掉 `InlineHtmlSyntax`,`HtmlBlockSyntax` 依然命中 `<script>` 这种块级标签起始。所以选源头 escape,而非只调 extension set - **A/3 内联评论卡片**保留浅底黄+橙色左边框——在深底 diff 上刻意 "贴纸感"以便凸显评论,与主题解耦是有意的 验证 ---- 看代码:新增 2 文件 + 修改 2 文件(未动 A/3 的 test/html_utils_test.dart 及 A/3 卡片样式) 看编译: - `fvm flutter test test/utils/` → 36 通过(含 A/4 新 9 个) - `fvm flutter analyze lib/common/utils/{html_utils,comment_markdown}.dart lib/page/issue/pull_request_files_page.dart test/utils/comment_markdown_test.dart` → 0 issue - GetDiagnostics 目标文件 → 空 看运行:**未重跑真机**——本次仅改 (a) markdown 转 HTML 字符串(纯 Dart,单测充分覆盖 XSS + 语义还原) (b) 静态 CSS 字符串常量(highlight.js CDN 主题 + 字体 / padding / overflow-x),无运行时分支逻辑,无 API 变更 已知缺口: - 深/浅主题在真机 webview 上的**观感**未再拍图(fixture PR #938 上一轮 A/3 真机截图路径见前一次 commit 汇报);若后续需要视觉复核,走 adb `screencap` 主路径即可 - hljs `atom-one-dark.min.css` CDN 可达性依赖 cdnjs(本仓库 A/2 起 即挂 cdnjs,无新增外部依赖风险)
1 parent 2be57f5 commit 6d42447

4 files changed

Lines changed: 302 additions & 9 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// `markdown` 是 `flutter_markdown_plus` 的传递依赖(pubspec.lock 已锁),
2+
// 复用它避免为 comment body 单开一份 md 引擎;不改 pubspec.yaml 是刻意
3+
// 决策,见下方 dartdoc "设计取舍 2"。
4+
// ignore: depend_on_referenced_packages
5+
import 'package:markdown/markdown.dart' as md;
6+
7+
/// A/4:把 GitHub PR / Issue **评论 body** 从 Markdown 源文本转成
8+
/// **可以内联进 diff webview** 的安全 HTML 片段。
9+
///
10+
/// A/3 之前只做了 `_htmlEscape + \n-> <br>`,代价是 fixture PR #938 里
11+
/// Copilot 挂在 build.gradle L84 的 ```suggestion 代码块被打成一坨
12+
/// 转义后的纯文本,可读性差。本包装解决的是"评论体真正的 markdown 语义"。
13+
///
14+
/// 设计取舍
15+
/// --------
16+
/// 1. **只做 md → HTML,不做 md → widget**:内联的目标是塞进
17+
/// [HtmlUtils.parseDiffSource] 生成的 webview HTML 里,走浏览器渲染。
18+
/// App 层已有 [GSYMarkdownWidget](走 flutter_markdown_plus)承担
19+
/// Flutter 侧渲染,与本函数分工明确
20+
/// 2. **复用已在 pubspec.lock 里的 `markdown` 包(`flutter_markdown_plus`
21+
/// 的传递依赖)**:不新增依赖,不改 pubspec.yaml,不引入 web 端 md 库
22+
/// (像 marked.js / markdown-it 需要额外 CDN 或本地资产)
23+
/// 3. **`inlineSyntaxes` + `blockSyntaxes` 手动挑选**:GitHub GFM 全量太大
24+
/// (table / strikethrough / task list / emoji …),XSS 面也大。
25+
/// 这里仅开启 PR review 评论**实际使用率最高**的三样:
26+
/// - fenced code block(```suggestion 三反引号围栏)
27+
/// - inline code(\`bar\`)
28+
/// - autolinks(GitHub 会自动把裸 URL 转链接)
29+
/// 其余 GFM 语法降级为纯文本(不解析 = 原样输出转义后的字符),
30+
/// 人类阅读仍然可懂,且 XSS 面最小
31+
/// 4. **XSS 底线靠 `ExtensionSet.none` + `encodeHtml: true`**:
32+
/// `package:markdown` 的 `commonMark` / `gitHubWeb` / `gitHubFlavored`
33+
/// 默认 extension set 里都包含 `InlineHtmlSyntax`,**会主动放行**
34+
/// 用户 raw HTML(`<script>` / `<img onerror>` 之类)。这是 XSS 高危。
35+
/// 本包装显式用 `ExtensionSet.none`,只从 `withDefaultInlineSyntaxes`
36+
/// 默认集合里拿 inline 核心(emphasis / inline-code / escape / link),
37+
/// 再手动加 GFM 的 `AutolinkExtensionSyntax`。`encodeHtml: true` 保证
38+
/// `<` / `>` / `&` 被转义
39+
/// 5. **失败降级**:任何异常路径回落到"escape 后原样 + `\n → <br>`",
40+
/// 保证 webview 至少能看到内容
41+
///
42+
/// 已知不做
43+
/// --------
44+
/// - 不做 mention(`@user`)转链接:webview 层无路由能力,会成为死链
45+
/// - 不做 emoji shortcode(`:smile:`):需要额外映射表
46+
/// - 不做 checkbox(`- [x]`):交互态在 webview 里无处安放
47+
///
48+
/// 与 A/3 兼容
49+
/// -----------
50+
/// 返回值仍然是"可以直接嵌入 `<div class=\"gsy-review-comment\">` 内层"
51+
/// 的字符串。调用方 [_buildAddLineExtras] 只需把原来的
52+
/// `_htmlEscape(body).replaceAll('\n', '<br>')` 换成本函数。
53+
///
54+
/// [source] 为 GitHub API 返回的 comment body 原文(未 escape 的 markdown)。
55+
/// 返回 HTML 片段(**不含**外层容器)。
56+
String renderCommentBodyToInlineHtml(String? source) {
57+
if (source == null) return '';
58+
final String trimmed = source.trim();
59+
if (trimmed.isEmpty) return '';
60+
try {
61+
// **XSS 底线预处理**:把可能被 markdown 认作"HTML 标签起始"的
62+
// `<letter` / `</letter` / `<!` / `<?` 序列前置一个反斜杠(CommonMark
63+
// 标准转义符),markdown 解析后会作为字面 `<` 输出,再经 encodeHtml
64+
// 转成 `&lt;`。这样彻底关闭 raw HTML 通路,但**保留** fenced code
65+
// block 内 `<foo>` 的原样显示(fenced code 走反引号语义,不再走 raw
66+
// HTML 识别)。
67+
//
68+
// 为什么不用 `ExtensionSet.none` 就够:`ExtensionSet.commonMark` 里的
69+
// `InlineHtmlSyntax` 与标准 block 集里的 `HtmlBlockSyntax` **两处**都
70+
// 会放行 raw HTML;即便扫掉 InlineHtmlSyntax,HtmlBlockSyntax 依然
71+
// 命中 `<script>` 这种块级标签起始。源头 escape 是最鲁棒的做法。
72+
final String safeSource = _escapeRawHtmlStarts(trimmed);
73+
final String html = md.markdownToHtml(
74+
safeSource,
75+
// 不用 gitHubWeb / gitHubFlavored,避免 InlineHtmlSyntax 等再次
76+
// 引入 raw HTML 放行通路
77+
extensionSet: md.ExtensionSet.none,
78+
encodeHtml: true,
79+
inlineSyntaxes: [
80+
// 裸 URL / 邮箱 自动转 <a>(GFM 扩展,非 CommonMark 默认)
81+
md.AutolinkExtensionSyntax(),
82+
],
83+
blockSyntaxes: [
84+
// fenced code block (```lang\n...\n``` 与 ~~~)
85+
const md.FencedCodeBlockSyntax(),
86+
],
87+
);
88+
return html;
89+
} catch (_) {
90+
// 兜底:任何解析异常回落到 A/3 的 escape + <br> 行为
91+
return _fallbackEscape(trimmed);
92+
}
93+
}
94+
95+
/// 把可能被 markdown 认作 raw HTML 标签起始的 `<x` `</x` `<!` `<?` 序列
96+
/// 前置反斜杠。CommonMark 规范里 `\<` 是 punctuation escape,解析后
97+
/// 就是字面文本 `<`,再经 `encodeHtml: true` 变成 `&lt;` 输出。
98+
///
99+
/// 该函数**不**处理 fenced code block 或 inline code block 内部的
100+
/// `<`——那些区域 markdown 会走代码语法,本来就不做 raw HTML 识别,
101+
/// 内部 `<` 会自然被 `encodeHtml` escape 成 `&lt;`
102+
///
103+
/// 因此这里可以放心对整段全局替换,不会破坏代码块渲染。
104+
String _escapeRawHtmlStarts(String input) {
105+
// 匹配 `<字母` / `</字母` / `<!` / `<?`
106+
final RegExp htmlStart = RegExp(r'<(/?[a-zA-Z]|!|\?)');
107+
return input.replaceAllMapped(htmlStart, (m) => r'\<' + m.group(1)!);
108+
}
109+
110+
/// 与 [pull_request_files_page._htmlEscape] 相同语义的兜底 escape。
111+
/// 抽出来供 [renderCommentBodyToInlineHtml] 失败路径复用;
112+
/// 5 个 HTML 特殊字符 + `\n → <br>`
113+
String _fallbackEscape(String input) {
114+
return input
115+
.replaceAll('&', '&amp;')
116+
.replaceAll('<', '&lt;')
117+
.replaceAll('>', '&gt;')
118+
.replaceAll('"', '&quot;')
119+
.replaceAll("'", '&#39;')
120+
.replaceAll('\n', '<br>');
121+
}

lib/common/utils/html_utils.dart

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,73 @@ class HtmlUtils {
8888
}
8989

9090
/// style for mdHTml
91+
///
92+
/// A/4 修复"配色割裂 + 移动端不适配":
93+
/// - **主题联动**:以 [backgroundColor] 判定深/浅主题。深色(dracula
94+
/// `#282a36`)挂 highlight.js `atom-one-dark` 主题、正文文字 `#e5e7eb`
95+
/// 浅色挂 `github` 主题、正文文字 `#24292f`。之前**固定挂浅色 `default`
96+
/// 主题**却把 body 设成深色 dracula,导致 hljs 输出的深色 token 在深底
97+
/// 上几乎不可见——这是"配色割裂"的直接根因
98+
/// - **移动端排版**:加 `-webkit-text-size-adjust:100%``padding:12px`
99+
/// `line-height:1.55`、`font-family: ui-monospace / SFMono-Regular /
100+
/// Menlo / Consolas / monospace`、`font-size:14px`;`pre { overflow-x:
101+
/// auto; -webkit-overflow-scrolling: touch; }`,长行可横向滑动而不撑破
102+
/// viewport
103+
/// - **A/3 内联评论卡片**:`.gsy-review-comment` 保留浅底黄+橙色左边框,
104+
/// 在深底 diff 上刻意"贴纸感"以便凸显评论,与主题解耦——这是有意的
91105
static generateCodeHtml(mdHTML, wrap,
92106
{backgroundColor = GSYColors.white,
93107
String actionColor = GSYColors.actionBlueString,
94108
userBR = true}) {
95-
// ignore: prefer_interpolation_to_compose_strings
96-
return "${"${"<html>\n" + "<head>\n" + "<meta charset=\"utf-8\" />\n" + "<title></title>\n" + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0\"/>" + "<meta name=\“app-mobile-web-app-capable\” content=\“yes\" /> " + "<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/default.min.css\">\n" + "<script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js\"></script>" + "<script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/languages/go.min.js\"></script>" + "<script>hljs.configure({'useBR': ${userBR.toString()}"
97-
"});hljs.initHighlightingOnLoad();</script> " + "<style>" + "body{background: $backgroundColor"};}a {color:$actionColor !important;}.highlight pre, pre { word-wrap: ${wrap ? "break-word" : "normal"}; white-space: ${wrap ? "pre-wrap" : "pre"}; }thead, tr {background:${GSYColors.miWhiteString};}td, th {padding: 5px 10px;font-size: 12px;direction:hor}.highlight {overflow: scroll; background: ${GSYColors.miWhiteString}}tr:nth-child(even) {background:${GSYColors.primaryLightValueString};color:${GSYColors.miWhiteString};}tr:nth-child(odd) {background: ${GSYColors.miWhiteString};color:${GSYColors.primaryLightValueString};}th {font-size: 14px;color:${GSYColors.miWhiteString};background:${GSYColors.primaryLightValueString};}</style></head>\n<body>\n" + mdHTML}</body>\n</html>";
109+
final bool isDark =
110+
backgroundColor == GSYColors.webDraculaBackgroundColorString;
111+
final String hljsTheme = isDark ? 'atom-one-dark' : 'github';
112+
final String textColor = isDark ? '#e5e7eb' : '#24292f';
113+
final String wrapCss = wrap
114+
? 'word-wrap: break-word; white-space: pre-wrap;'
115+
: 'word-wrap: normal; white-space: pre;';
116+
return '''<html>
117+
<head>
118+
<meta charset="utf-8" />
119+
<title></title>
120+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"/>
121+
<meta name="apple-mobile-web-app-capable" content="yes" />
122+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/$hljsTheme.min.css">
123+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js"></script>
124+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/languages/go.min.js"></script>
125+
<script>hljs.configure({'useBR': ${userBR.toString()}});hljs.initHighlightingOnLoad();</script>
126+
<style>
127+
html, body { margin:0; padding:0; -webkit-text-size-adjust:100%; }
128+
body {
129+
background: $backgroundColor;
130+
color: $textColor;
131+
padding: 12px;
132+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
133+
font-size: 14px;
134+
line-height: 1.55;
135+
}
136+
a { color: $actionColor !important; word-break: break-all; }
137+
.highlight pre, pre {
138+
$wrapCss
139+
overflow-x: auto;
140+
-webkit-overflow-scrolling: touch;
141+
background: transparent;
142+
}
143+
code { font-family: inherit; }
144+
.highlight { overflow: auto; background: transparent; }
145+
table { border-collapse: collapse; width: 100%; }
146+
thead, tr { background: ${GSYColors.miWhiteString}; }
147+
td, th { padding: 5px 10px; font-size: 12px; }
148+
tr:nth-child(even) { background: ${GSYColors.primaryLightValueString}; color: ${GSYColors.miWhiteString}; }
149+
tr:nth-child(odd) { background: ${GSYColors.miWhiteString}; color: ${GSYColors.primaryLightValueString}; }
150+
th { font-size: 14px; color: ${GSYColors.miWhiteString}; background: ${GSYColors.primaryLightValueString}; }
151+
.gsy-review-comment { max-width: 100%; box-sizing: border-box; }
152+
</style>
153+
</head>
154+
<body>
155+
$mdHTML
156+
</body>
157+
</html>''';
98158
}
99159

100160
/// 解析 GitHub PR / commit diff 的 `patch` 字符串为高亮 HTML。

lib/page/issue/pull_request_files_page.dart

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:gsy_github_app_flutter/common/repositories/issue_repository.dart
55
import 'package:gsy_github_app_flutter/common/style/gsy_style.dart';
66
import 'package:gsy_github_app_flutter/common/toast.dart';
77
import 'package:gsy_github_app_flutter/common/utils/common_utils.dart';
8+
import 'package:gsy_github_app_flutter/common/utils/comment_markdown.dart';
89
import 'package:gsy_github_app_flutter/common/utils/html_utils.dart';
910
import 'package:gsy_github_app_flutter/common/utils/navigator_utils.dart';
1011
import 'package:gsy_github_app_flutter/model/commitFile.dart';
@@ -177,16 +178,17 @@ class _PullRequestFilesPageState extends State<PullRequestFilesPage>
177178
);
178179
}
179180

180-
/// A/3:把当前文件的 review comment 折叠成 {addLine: html片段}。
181+
/// A/3 + A/4:把当前文件的 review comment 折叠成 {addLine: html片段}。
181182
///
182183
/// 关键点:
183184
/// - key 采用 [PullReviewComment.line](**新版本 blob 行号**,非
184185
/// [PullReviewComment.displayLine])。原因见下方 outdated 规则
185186
/// - 同一行可能有多条评论(review 多轮 / 多人),value 里拼接**多个**
186187
/// `<div class="gsy-review-comment">` 兄弟节点
187-
/// - body 走**极简 escape**:`< > & " '` 五字符 + `\n→<br>`
188-
/// 不接 markdown 解析(会引入前端 md 库或本地 markdown 依赖,成本大);
189-
/// 对 fixture #938 的短英文段落足够
188+
/// - **A/4**:body 走 [renderCommentBodyToInlineHtml](复用
189+
/// `package:markdown`),支持 fenced-code / inline-code / autolinks;
190+
/// fixture PR #938 的 ```suggestion 块能正确渲染。默认 escape 未白名单的
191+
/// 裸 HTML(XSS 底线)。author 一律走 [_htmlEscape](纯文本 escape)
190192
/// - **outdated / removed-side 评论**(`line == null``originalLine != null`
191193
/// **不进 webview 内联**:那种评论指向已被后续 push 冲掉的旧 blob 行号,
192194
/// 若拿 `originalLine``curAddNumber`(新 blob 行号)比较会**位置错乱**
@@ -203,8 +205,9 @@ class _PullRequestFilesPageState extends State<PullRequestFilesPage>
203205
if (line == null) continue;
204206
final buf = buckets.putIfAbsent(line, () => StringBuffer());
205207
final String author = _htmlEscape(c.user?.login ?? '');
206-
final String body = _htmlEscape((c.body ?? '').trim())
207-
.replaceAll('\n', '<br>');
208+
// A/4:body 走真正的 markdown 渲染(fenced-code / inline-code /
209+
// autolinks),fixture PR #938 的 ```suggestion 块能正确显示
210+
final String body = renderCommentBodyToInlineHtml(c.body);
208211
// 内联样式而非全靠 class,防某些 webview 平台丢失 <style>(保险起见)
209212
buf.write('<div class="gsy-review-comment" '
210213
'style="background:#fff8e1;color:#222;border-left:3px solid #d97706;'
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:gsy_github_app_flutter/common/utils/comment_markdown.dart';
3+
4+
/// A/4 单测:验证 [renderCommentBodyToInlineHtml] 的行为契约。
5+
///
6+
/// 覆盖点(按重要性排序):
7+
/// 1. **fenced code block**(fixture PR #938 的 ```suggestion 场景)
8+
/// 2. **inline code**
9+
/// 3. **autolinks**(GitHub 会自动把裸 URL 转链接)
10+
/// 4. **XSS 底线**:裸 `<script>` 会被 escape 成字面
11+
/// 5. **纯文本 fallback**:没有任何 markdown 语法时依然安全输出
12+
/// 6. **边界**:null / 空串 / 仅空白 → 空串
13+
void main() {
14+
test('null / 空串 / 仅空白 → 空串', () {
15+
expect(renderCommentBodyToInlineHtml(null), '');
16+
expect(renderCommentBodyToInlineHtml(''), '');
17+
expect(renderCommentBodyToInlineHtml(' \n '), '');
18+
});
19+
20+
test('fenced code block 三反引号被渲染为 <pre><code>', () {
21+
// fixture #938 的 Copilot suggestion 就是这种形式
22+
const src = '```suggestion\n'
23+
'implementation("androidx.appcompat:appcompat:1.6.1")\n'
24+
'```';
25+
final out = renderCommentBodyToInlineHtml(src);
26+
expect(out.contains('<pre>'), isTrue, reason: '应该有 <pre>');
27+
expect(out.contains('<code'), isTrue, reason: '应该有 <code>');
28+
expect(out.contains('appcompat:1.6.1'), isTrue,
29+
reason: '代码内容应原样保留');
30+
// 语言 tag 应以 class="language-suggestion" 形式落到 <code>
31+
expect(out.contains('language-suggestion'), isTrue,
32+
reason: 'suggestion 语言 tag 应保留在 class 里');
33+
});
34+
35+
test('inline code 单反引号被渲染为 <code>', () {
36+
const src = '请把 `foo()` 改成 `bar()`';
37+
final out = renderCommentBodyToInlineHtml(src);
38+
expect(out.contains('<code>foo()</code>'), isTrue);
39+
expect(out.contains('<code>bar()</code>'), isTrue);
40+
});
41+
42+
test('autolinks:裸 URL 被转为 <a>', () {
43+
const src = 'see https://example.com/x for details';
44+
final out = renderCommentBodyToInlineHtml(src);
45+
expect(out.contains('<a href="https://example.com/x">'), isTrue,
46+
reason: '裸 URL 应转 <a>');
47+
expect(out.contains('example.com/x</a>'), isTrue);
48+
});
49+
50+
test('XSS 底线:裸 <script> 被 escape 而非执行', () {
51+
const src = '<script>alert(1)</script>';
52+
final out = renderCommentBodyToInlineHtml(src);
53+
// 关键:输出里绝对不能出现真的可执行 <script> 起始标签
54+
expect(out.contains('<script>'), isFalse,
55+
reason: '<script> 起始标签必须被 escape');
56+
// 应该以字面形式出现(&lt;script&gt;)
57+
expect(out.contains('&lt;script&gt;'), isTrue,
58+
reason: '<script> 应被 escape 成字面');
59+
});
60+
61+
test('XSS 底线:<img onerror> 等属性型载荷也被 escape', () {
62+
const src = '<img src=x onerror=alert(1)>';
63+
final out = renderCommentBodyToInlineHtml(src);
64+
expect(out.contains('<img'), isFalse,
65+
reason: '裸 <img> 也必须被 escape 掉');
66+
expect(out.contains('onerror'), isTrue,
67+
reason: '文本内容 onerror 会保留为纯字符(属可接受)');
68+
// 关键:不能有可执行的 <img ... onerror=...> 组合
69+
expect(out.contains('<img src=x onerror'), isFalse);
70+
});
71+
72+
test('纯文本 fallback:无 markdown 语法时也能安全输出', () {
73+
const src = 'Hello, world. 你好,世界。';
74+
final out = renderCommentBodyToInlineHtml(src);
75+
expect(out.contains('Hello, world'), isTrue);
76+
expect(out.contains('你好'), isTrue);
77+
// 应被裹在 <p> 里(inlineOnly=false 时块级默认段落)
78+
expect(out.contains('<p>'), isTrue);
79+
});
80+
81+
test('多段落之间保留结构(换行 → <p> 或 <br>)', () {
82+
const src = 'first paragraph\n\nsecond paragraph';
83+
final out = renderCommentBodyToInlineHtml(src);
84+
expect(out.contains('first paragraph'), isTrue);
85+
expect(out.contains('second paragraph'), isTrue);
86+
// 两段应被分成两个 <p>
87+
final pCount = '<p>'.allMatches(out).length;
88+
expect(pCount, greaterThanOrEqualTo(2),
89+
reason: '空行分隔应生成多个 <p>');
90+
});
91+
92+
test('fixture PR #938 Copilot suggestion 完整语义还原', () {
93+
// 模拟 Copilot 挂在 build.gradle L84 的真实 body 形态:
94+
// 一段前言 + ```suggestion 代码块
95+
const src = 'Consider using the AndroidX AppCompat library:\n\n'
96+
'```suggestion\n'
97+
'implementation("androidx.appcompat:appcompat:1.6.1")\n'
98+
'```';
99+
final out = renderCommentBodyToInlineHtml(src);
100+
expect(out.contains('Consider using'), isTrue);
101+
expect(out.contains('<pre>'), isTrue);
102+
expect(out.contains('language-suggestion'), isTrue);
103+
expect(out.contains('appcompat:1.6.1'), isTrue);
104+
// 前言应在代码块之前
105+
final idxIntro = out.indexOf('Consider');
106+
final idxPre = out.indexOf('<pre>');
107+
expect(idxPre, greaterThan(idxIntro));
108+
});
109+
}

0 commit comments

Comments
 (0)