@kernel-sig/console 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/.env.example +50 -0
  2. package/LICENSE +127 -0
  3. package/Makefile +66 -0
  4. package/README.md +279 -0
  5. package/backend/.dockerignore +12 -0
  6. package/backend/Dockerfile +43 -0
  7. package/backend/alembic/env.py +75 -0
  8. package/backend/alembic/script.py.mako +25 -0
  9. package/backend/alembic/versions/0001_initial.py +88 -0
  10. package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
  11. package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
  12. package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
  13. package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
  14. package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
  15. package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
  16. package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
  17. package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
  18. package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
  19. package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
  20. package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
  21. package/backend/alembic.ini +40 -0
  22. package/backend/app/__init__.py +0 -0
  23. package/backend/app/api/__init__.py +0 -0
  24. package/backend/app/api/deps.py +74 -0
  25. package/backend/app/api/v1/__init__.py +0 -0
  26. package/backend/app/api/v1/ai.py +386 -0
  27. package/backend/app/api/v1/analytics.py +48 -0
  28. package/backend/app/api/v1/attention.py +142 -0
  29. package/backend/app/api/v1/auth.py +106 -0
  30. package/backend/app/api/v1/classification.py +192 -0
  31. package/backend/app/api/v1/health.py +29 -0
  32. package/backend/app/api/v1/issues.py +143 -0
  33. package/backend/app/api/v1/pulls.py +388 -0
  34. package/backend/app/api/v1/repositories.py +273 -0
  35. package/backend/app/api/v1/router.py +32 -0
  36. package/backend/app/api/v1/sig.py +565 -0
  37. package/backend/app/api/v1/users.py +87 -0
  38. package/backend/app/api/v1/webhooks.py +135 -0
  39. package/backend/app/core/__init__.py +0 -0
  40. package/backend/app/core/config.py +72 -0
  41. package/backend/app/core/crypto.py +74 -0
  42. package/backend/app/core/db.py +79 -0
  43. package/backend/app/core/exceptions.py +60 -0
  44. package/backend/app/core/logging.py +69 -0
  45. package/backend/app/core/permissions.py +87 -0
  46. package/backend/app/core/queue.py +57 -0
  47. package/backend/app/core/security.py +69 -0
  48. package/backend/app/domain/__init__.py +0 -0
  49. package/backend/app/domain/attention.py +618 -0
  50. package/backend/app/domain/classification.py +689 -0
  51. package/backend/app/domain/meeting.py +408 -0
  52. package/backend/app/domain/release.py +163 -0
  53. package/backend/app/domain/review.py +416 -0
  54. package/backend/app/domain/sig.py +178 -0
  55. package/backend/app/domain/sig_info.py +201 -0
  56. package/backend/app/integrations/__init__.py +0 -0
  57. package/backend/app/integrations/atomgit/__init__.py +0 -0
  58. package/backend/app/integrations/atomgit/client.py +505 -0
  59. package/backend/app/integrations/atomgit/models.py +311 -0
  60. package/backend/app/integrations/llm/__init__.py +0 -0
  61. package/backend/app/integrations/llm/prompts.py +222 -0
  62. package/backend/app/integrations/llm/provider.py +326 -0
  63. package/backend/app/main.py +242 -0
  64. package/backend/app/middleware/__init__.py +0 -0
  65. package/backend/app/middleware/audit.py +129 -0
  66. package/backend/app/middleware/request_context.py +42 -0
  67. package/backend/app/models/__init__.py +112 -0
  68. package/backend/app/models/ai.py +214 -0
  69. package/backend/app/models/attention.py +131 -0
  70. package/backend/app/models/attention_settings.py +61 -0
  71. package/backend/app/models/audit.py +53 -0
  72. package/backend/app/models/base.py +35 -0
  73. package/backend/app/models/classification.py +144 -0
  74. package/backend/app/models/credential.py +56 -0
  75. package/backend/app/models/issue.py +132 -0
  76. package/backend/app/models/meeting.py +189 -0
  77. package/backend/app/models/pull_request.py +288 -0
  78. package/backend/app/models/repository.py +196 -0
  79. package/backend/app/models/sig.py +194 -0
  80. package/backend/app/models/user.py +44 -0
  81. package/backend/app/schemas/__init__.py +0 -0
  82. package/backend/app/schemas/ai.py +120 -0
  83. package/backend/app/schemas/attention.py +43 -0
  84. package/backend/app/schemas/auth.py +26 -0
  85. package/backend/app/schemas/classification.py +58 -0
  86. package/backend/app/schemas/common.py +43 -0
  87. package/backend/app/schemas/pull_request.py +214 -0
  88. package/backend/app/schemas/repository.py +94 -0
  89. package/backend/app/schemas/sig.py +246 -0
  90. package/backend/app/schemas/user.py +79 -0
  91. package/backend/app/services/__init__.py +0 -0
  92. package/backend/app/services/ai_service.py +569 -0
  93. package/backend/app/services/analytics_service.py +390 -0
  94. package/backend/app/services/attention_queue.py +451 -0
  95. package/backend/app/services/attention_service.py +432 -0
  96. package/backend/app/services/auth_service.py +74 -0
  97. package/backend/app/services/classification_service.py +658 -0
  98. package/backend/app/services/credential_service.py +105 -0
  99. package/backend/app/services/pull_query.py +273 -0
  100. package/backend/app/services/release_service.py +446 -0
  101. package/backend/app/services/repository_service.py +132 -0
  102. package/backend/app/services/sig_service.py +385 -0
  103. package/backend/app/services/sync_service.py +752 -0
  104. package/backend/app/services/user_service.py +68 -0
  105. package/backend/app/worker.py +389 -0
  106. package/backend/entrypoint.sh +10 -0
  107. package/backend/pyproject.toml +68 -0
  108. package/backend/tests/test_analysis_api.py +154 -0
  109. package/backend/tests/test_atomgit_client.py +360 -0
  110. package/backend/tests/test_atomgit_models.py +257 -0
  111. package/backend/tests/test_attention.py +291 -0
  112. package/backend/tests/test_audit_middleware.py +135 -0
  113. package/backend/tests/test_classification.py +498 -0
  114. package/backend/tests/test_config.py +41 -0
  115. package/backend/tests/test_crypto.py +68 -0
  116. package/backend/tests/test_exceptions.py +62 -0
  117. package/backend/tests/test_health.py +63 -0
  118. package/backend/tests/test_llm_provider.py +320 -0
  119. package/backend/tests/test_meeting_domain.py +169 -0
  120. package/backend/tests/test_permissions.py +69 -0
  121. package/backend/tests/test_pull_query_wiring.py +66 -0
  122. package/backend/tests/test_pull_schemas.py +82 -0
  123. package/backend/tests/test_release_domain.py +82 -0
  124. package/backend/tests/test_review_parser.py +301 -0
  125. package/backend/tests/test_schemas_user.py +97 -0
  126. package/backend/tests/test_security.py +92 -0
  127. package/cli/index.js +338 -0
  128. package/compose.yaml +105 -0
  129. package/frontend/.dockerignore +4 -0
  130. package/frontend/Dockerfile +27 -0
  131. package/frontend/index.html +14 -0
  132. package/frontend/nginx.conf +47 -0
  133. package/frontend/package-lock.json +5020 -0
  134. package/frontend/package.json +35 -0
  135. package/frontend/src/api/ai.ts +124 -0
  136. package/frontend/src/api/analytics.ts +66 -0
  137. package/frontend/src/api/attention.ts +181 -0
  138. package/frontend/src/api/auth.ts +74 -0
  139. package/frontend/src/api/classification.ts +170 -0
  140. package/frontend/src/api/issues.ts +90 -0
  141. package/frontend/src/api/pulls.ts +233 -0
  142. package/frontend/src/api/repositories.ts +95 -0
  143. package/frontend/src/api/sig.ts +232 -0
  144. package/frontend/src/app/antd-theme.ts +94 -0
  145. package/frontend/src/app/providers.tsx +59 -0
  146. package/frontend/src/app/router.tsx +411 -0
  147. package/frontend/src/app/search.ts +30 -0
  148. package/frontend/src/components/ClassificationBadge.tsx +58 -0
  149. package/frontend/src/components/GateBadge.tsx +13 -0
  150. package/frontend/src/components/SeverityBadge.tsx +20 -0
  151. package/frontend/src/components/layout/AppShell.tsx +16 -0
  152. package/frontend/src/components/layout/AuthLayout.tsx +32 -0
  153. package/frontend/src/components/layout/Sidebar.tsx +223 -0
  154. package/frontend/src/components/layout/TopBar.tsx +47 -0
  155. package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
  156. package/frontend/src/components/pulls/FacetRail.tsx +199 -0
  157. package/frontend/src/components/pulls/LabelChips.tsx +87 -0
  158. package/frontend/src/components/ui/alert.tsx +30 -0
  159. package/frontend/src/components/ui/badge.tsx +53 -0
  160. package/frontend/src/components/ui/button.tsx +51 -0
  161. package/frontend/src/components/ui/card.tsx +64 -0
  162. package/frontend/src/components/ui/chart-theme.ts +65 -0
  163. package/frontend/src/components/ui/data-table.tsx +39 -0
  164. package/frontend/src/components/ui/echart.tsx +70 -0
  165. package/frontend/src/components/ui/empty-state.tsx +22 -0
  166. package/frontend/src/components/ui/input.tsx +39 -0
  167. package/frontend/src/components/ui/lazy-chart.tsx +21 -0
  168. package/frontend/src/components/ui/skeleton.tsx +19 -0
  169. package/frontend/src/hooks/use-current-repository.ts +44 -0
  170. package/frontend/src/hooks/use-current-user.ts +38 -0
  171. package/frontend/src/lib/api-client.ts +93 -0
  172. package/frontend/src/lib/css-color.ts +60 -0
  173. package/frontend/src/lib/utils.ts +45 -0
  174. package/frontend/src/main.tsx +22 -0
  175. package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
  176. package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
  177. package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
  178. package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
  179. package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
  180. package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
  181. package/frontend/src/pages/login/LoginPage.tsx +95 -0
  182. package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
  183. package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
  184. package/frontend/src/pages/members/MembersPage.tsx +534 -0
  185. package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
  186. package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
  187. package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
  188. package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
  189. package/frontend/src/pages/setup/SetupPage.tsx +144 -0
  190. package/frontend/src/styles/globals.css +147 -0
  191. package/frontend/tsconfig.json +22 -0
  192. package/frontend/vite.config.ts +57 -0
  193. package/package.json +48 -0
  194. package/scripts/e2e-auth-flow.py +131 -0
  195. package/scripts/e2e-pr-detail.py +128 -0
  196. package/scripts/e2e-verify.py +773 -0
  197. package/scripts/verify-ai-pipeline.py +616 -0
  198. package/scripts/verify-analysis-pipeline.py +303 -0
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@kernel-sig/console",
3
+ "version": "0.1.0",
4
+ "description": "openEuler Kernel SIG 的 PR/Issue 管理平台:一条命令在本机起一整套实例",
5
+ "bin": {
6
+ "ksc": "./cli/index.js"
7
+ },
8
+ "files": [
9
+ "cli/",
10
+ "backend/app/**/*.py",
11
+ "backend/alembic/**/*.py",
12
+ "backend/alembic/*.py",
13
+ "backend/alembic/script.py.mako",
14
+ "backend/tests/**/*.py",
15
+ "backend/alembic.ini",
16
+ "backend/pyproject.toml",
17
+ "backend/Dockerfile",
18
+ "backend/.dockerignore",
19
+ "backend/entrypoint.sh",
20
+ "frontend/src/**/*.ts",
21
+ "frontend/src/**/*.tsx",
22
+ "frontend/src/**/*.css",
23
+ "frontend/index.html",
24
+ "frontend/nginx.conf",
25
+ "frontend/package.json",
26
+ "frontend/package-lock.json",
27
+ "frontend/tsconfig.json",
28
+ "frontend/vite.config.ts",
29
+ "frontend/Dockerfile",
30
+ "frontend/.dockerignore",
31
+ "compose.yaml",
32
+ "scripts/**/*.py",
33
+ "Makefile",
34
+ "README.md",
35
+ ".env.example"
36
+ ],
37
+ "keywords": [
38
+ "openeuler",
39
+ "kernel",
40
+ "sig",
41
+ "atomgit",
42
+ "pull-request"
43
+ ],
44
+ "license": "MulanPSL-2.0",
45
+ "engines": {
46
+ "node": ">=18"
47
+ }
48
+ }
@@ -0,0 +1,131 @@
1
+ """Kernel SIG Console 登录流程的浏览器端验证。
2
+
3
+ 覆盖:路由守卫、登录页渲染、凭据校验、会话保持、退出登录、权限提示。
4
+ 每一步都断言实际观察到的状态,不做「看起来没问题」的判断。
5
+ """
6
+
7
+ import os
8
+ import sys
9
+
10
+ from playwright.sync_api import Page, expect, sync_playwright
11
+
12
+ BASE = os.environ.get("KSC_BASE_URL", "http://localhost:18080")
13
+ USERNAME = os.environ.get("KSC_E2E_USERNAME", "admin")
14
+
15
+ # 口令只从环境变量取,不给默认值。这个脚本登的是**你自己的实例**,
16
+ # 在源码里写死任何一个口令,都等于随代码一起公开它。
17
+ PASSWORD = os.environ.get("KSC_E2E_PASSWORD")
18
+ if not PASSWORD:
19
+ sys.exit("请用 KSC_E2E_PASSWORD 环境变量提供实例口令")
20
+
21
+ results: list[tuple[str, bool, str]] = []
22
+
23
+
24
+ def check(name: str, condition: bool, detail: str = "") -> None:
25
+ results.append((name, condition, detail))
26
+ mark = "PASS" if condition else "FAIL"
27
+ print(f"[{mark}] {name}" + (f" — {detail}" if detail else ""))
28
+
29
+
30
+ def run(page: Page) -> None:
31
+ # --- 1. 未登录访问根路径应落到登录页 ---
32
+ page.goto(BASE, wait_until="networkidle")
33
+ check("未登录访问 / 被路由守卫拦截", page.url.endswith("/login"), f"当前 URL: {page.url}")
34
+
35
+ # --- 2. 登录页渲染 ---
36
+ expect(page.get_by_role("heading", name="登录")).to_be_visible()
37
+ check("登录页标题渲染", True)
38
+ check("用户名输入框存在", page.get_by_label("用户名").is_visible())
39
+ check("密码输入框存在", page.get_by_label("密码").is_visible())
40
+ check("登录按钮存在", page.get_by_role("button", name="登录").is_visible())
41
+
42
+ # --- 3. 深色主题令牌生效 ---
43
+ bg = page.evaluate("getComputedStyle(document.body).backgroundColor")
44
+ check("主题背景已应用(非白底)", bg not in ("rgb(255, 255, 255)", "rgba(0, 0, 0, 0)"), bg)
45
+
46
+ # --- 4. 错误密码应显示后端返回的错误信息 ---
47
+ page.get_by_label("用户名").fill(USERNAME)
48
+ page.get_by_label("密码").fill("definitely-wrong-password")
49
+ page.get_by_role("button", name="登录").click()
50
+ error_alert = page.get_by_role("alert")
51
+ expect(error_alert).to_be_visible(timeout=8000)
52
+ text = error_alert.inner_text()
53
+ check("错误密码显示错误提示", "用户名或密码错误" in text, text.strip())
54
+ check("错误后仍停留在登录页", page.url.endswith("/login"))
55
+
56
+ # --- 5. 正确凭据登录 ---
57
+ page.get_by_label("密码").fill(PASSWORD)
58
+ page.get_by_role("button", name="登录").click()
59
+ page.wait_for_url("**/dashboard", timeout=12000)
60
+ check("登录后跳转到仪表盘", page.url.endswith("/dashboard"), page.url)
61
+
62
+ # --- 6. 仪表盘内容 ---
63
+ expect(page.get_by_role("heading", name="总览")).to_be_visible()
64
+ check("仪表盘标题渲染", True)
65
+ body = page.inner_text("body")
66
+ check("侧栏显示登录用户名", USERNAME in body or "管理员" in body)
67
+
68
+ # --- 7. 会话保持:刷新后不被踢出 ---
69
+ page.reload(wait_until="networkidle")
70
+ page.wait_for_timeout(1200)
71
+ check("刷新后会话保持", page.url.endswith("/dashboard"), f"当前 URL: {page.url}")
72
+
73
+ # --- 8. 会话 Cookie 为 HttpOnly ---
74
+ cookies = page.context.cookies()
75
+ access = next((c for c in cookies if c["name"] == "ksc_access"), None)
76
+ check("访问令牌 Cookie 存在", access is not None)
77
+ if access:
78
+ check("访问令牌 Cookie 为 HttpOnly", access.get("httpOnly", False))
79
+
80
+ # --- 9. 已初始化后 /setup 应被拒绝 ---
81
+ page.goto(f"{BASE}/setup", wait_until="networkidle")
82
+ page.wait_for_timeout(1000)
83
+ check("/setup 在已初始化后被拒", not page.url.endswith("/setup"), f"当前 URL: {page.url}")
84
+
85
+ # --- 10. 退出登录 ---
86
+ page.goto(f"{BASE}/dashboard", wait_until="networkidle")
87
+ page.wait_for_selector('button[aria-label="退出登录"]', timeout=8000)
88
+ page.click('button[aria-label="退出登录"]')
89
+ page.wait_for_url("**/login", timeout=10000)
90
+ check("退出后回到登录页", page.url.endswith("/login"), page.url)
91
+
92
+ # --- 11. 退出后受保护路由再次被拦截 ---
93
+ page.goto(f"{BASE}/dashboard", wait_until="networkidle")
94
+ page.wait_for_timeout(1200)
95
+ check("退出后 /dashboard 被拦截", page.url.endswith("/login"), page.url)
96
+
97
+ page.screenshot(path="/tmp/ksp-final.png", full_page=True)
98
+
99
+
100
+ def main() -> int:
101
+ with sync_playwright() as p:
102
+ browser = p.chromium.launch(headless=True)
103
+ context = browser.new_context(viewport={"width": 1440, "height": 900})
104
+ page = context.new_page()
105
+
106
+ console_errors: list[str] = []
107
+ page.on(
108
+ "console",
109
+ lambda msg: console_errors.append(msg.text) if msg.type == "error" else None,
110
+ )
111
+
112
+ try:
113
+ run(page)
114
+ except Exception as exc:
115
+ check("脚本执行完成", False, f"{type(exc).__name__}: {exc}")
116
+ page.screenshot(path="/tmp/ksp-failure.png", full_page=True)
117
+ finally:
118
+ browser.close()
119
+
120
+ print("\n" + "=" * 60)
121
+ failed = [r for r in results if not r[1]]
122
+ print(f"通过 {len(results) - len(failed)} / {len(results)}")
123
+ if console_errors:
124
+ print("\n浏览器控制台错误:")
125
+ for err in console_errors[:10]:
126
+ print(" -", err[:200])
127
+ return 1 if failed else 0
128
+
129
+
130
+ if __name__ == "__main__":
131
+ sys.exit(main())
@@ -0,0 +1,128 @@
1
+ """PR 详情页的浏览器端验证。
2
+
3
+ 覆盖本次新增的三块能力:上游标签按原色展示、讨论时间线、机器人评论折叠。
4
+ 其中最关键的一条是**评论正文必须按纯文本渲染** —— 上游正文含
5
+ ``<table>`` 与 ``<a href>``,若哪天有人改用 innerHTML,浏览器会把这些
6
+ 标签解析成真实 DOM,这个脚本会立刻失败。
7
+
8
+ 用法:
9
+ KSC_BASE_URL=http://host:port KSC_PULL_ID=<uuid> \
10
+ python3 scripts/e2e-pr-detail.py
11
+
12
+ 直接按 id 进详情页,不走列表 —— 列表默认只列 open 的 30 条,
13
+ 目标 PR 未必在第一页,靠翻页找样本会让脚本本身变脆。
14
+ """
15
+
16
+ import os
17
+ import sys
18
+
19
+ from playwright.sync_api import Page, sync_playwright
20
+
21
+ BASE = os.environ.get("KSC_BASE_URL", "http://localhost:18080")
22
+ USERNAME = os.environ.get("KSC_E2E_USERNAME", "admin")
23
+
24
+ # 口令只从环境变量取,不给默认值。这个脚本登的是**你自己的实例**,
25
+ # 在源码里写死任何一个口令,都等于随代码一起公开它。
26
+ PASSWORD = os.environ.get("KSC_E2E_PASSWORD")
27
+ if not PASSWORD:
28
+ sys.exit("请用 KSC_E2E_PASSWORD 环境变量提供实例口令")
29
+
30
+ PULL_ID = os.environ.get("KSC_PULL_ID", "")
31
+
32
+ results: list[tuple[str, bool, str]] = []
33
+
34
+
35
+ def check(name: str, condition: bool, detail: str = "") -> None:
36
+ results.append((name, condition, detail))
37
+ print(f"[{'PASS' if condition else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
38
+
39
+
40
+ def login(page: Page) -> None:
41
+ page.goto(f"{BASE}/login", wait_until="networkidle")
42
+ page.get_by_label("用户名").fill(USERNAME)
43
+ page.get_by_label("密码").fill(PASSWORD)
44
+ page.get_by_role("button", name="登录").click()
45
+ page.wait_for_url(lambda url: "/login" not in url, timeout=15000)
46
+
47
+
48
+ def run(page: Page) -> None:
49
+ login(page)
50
+
51
+ if not PULL_ID:
52
+ check("提供了 KSC_PULL_ID", False, "未设置环境变量")
53
+ return
54
+
55
+ # --- 进入目标 PR 详情 ---
56
+ page.goto(f"{BASE}/pulls/{PULL_ID}", wait_until="networkidle")
57
+
58
+ check("已进入 PR 详情页", "/pulls/" in page.url, page.url)
59
+ check("详情页渲染了标题", page.locator("h1").count() > 0)
60
+
61
+ # --- 标签:以原色渲染 ---
62
+ labels_heading = page.get_by_text("标签", exact=True)
63
+ check("侧栏「标签」面板存在", labels_heading.count() > 0)
64
+
65
+ chips = page.get_by_text("openeuler-cla/yes", exact=True)
66
+ check("标签 chip 渲染", chips.count() > 0, f"{chips.count()} 个")
67
+ if chips.count() > 0:
68
+ color = chips.first.evaluate("el => getComputedStyle(el).color")
69
+ # 上游色 #25e42b 亮度足够,应原样作为前景色(rgb 37,228,43)
70
+ check("标签沿用上游色而非中性色", color == "rgb(37, 228, 43)", color)
71
+
72
+ # --- 讨论标签页 ---
73
+ # 页签现在是 AntD 的 Tabs:role=tab,不是 button
74
+ discussion = page.get_by_role("tab", name="讨论")
75
+ check("「讨论」标签页存在", discussion.count() > 0)
76
+ check("「评审」旧标签页已移除", page.get_by_role("tab", name="评审", exact=True).count() == 0)
77
+ discussion.click()
78
+ page.wait_for_timeout(600)
79
+
80
+ details = page.locator("details")
81
+ check("机器人评论折叠为 <details>", details.count() > 0, f"{details.count()} 条")
82
+
83
+ if details.count() > 0:
84
+ check("折叠块默认收起", details.first.evaluate("el => !el.open"))
85
+
86
+ summaries = page.locator("details > summary")
87
+ check("折叠块标注机器人身份", summaries.first.inner_text().find("机器人") >= 0)
88
+
89
+ # --- 正文纯文本渲染(注入面检查)---
90
+ # 用 textContent 而非 innerText:折叠块内容不参与渲染,
91
+ # innerText 读不到,会被误判成"标签没渲染"
92
+ body_text = page.locator("ol").first.text_content() or ""
93
+ check("上游 HTML 标签以字面量呈现", "<table>" in body_text)
94
+ check("未被解析成真实表格元素", page.locator("ol table").count() == 0)
95
+ check("未被解析成真实链接元素(正文内)", page.locator("ol p a").count() == 0)
96
+
97
+ # --- 展开一条机器人评论,确认正文可见 ---
98
+ if details.count() > 0:
99
+ details.first.locator("summary").click()
100
+ page.wait_for_timeout(200)
101
+ check("展开后正文可读", details.first.evaluate("el => el.open"))
102
+
103
+ # --- 侧栏分类面板 ---
104
+ check("侧栏「分类」面板存在", page.get_by_text("分类", exact=True).count() > 0)
105
+
106
+
107
+ def main() -> int:
108
+ with sync_playwright() as p:
109
+ browser = p.chromium.launch(headless=True)
110
+ page = browser.new_page(viewport={"width": 1440, "height": 900})
111
+ try:
112
+ run(page)
113
+ except Exception as exc: # noqa: BLE001 — 脚本要报出失败细节而非堆栈
114
+ check("执行过程未抛异常", False, f"{type(exc).__name__}: {exc}")
115
+ page.screenshot(path="/tmp/pr-detail-failure.png", full_page=True)
116
+ print("失败截图:/tmp/pr-detail-failure.png")
117
+ finally:
118
+ browser.close()
119
+
120
+ failed = [name for name, ok, _ in results if not ok]
121
+ print(f"\n{len(results) - len(failed)}/{len(results)} 通过")
122
+ if failed:
123
+ print("失败项:" + "、".join(failed))
124
+ return 1 if failed else 0
125
+
126
+
127
+ if __name__ == "__main__":
128
+ sys.exit(main())