@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
@@ -0,0 +1,773 @@
1
+ #!/usr/bin/env python3
2
+ """浏览器端端到端验证。
3
+
4
+ 断言的是**页面上实际渲染出的内容**,而非接口返回值 ——
5
+ 接口正确但前端渲染错误、或路由时序有误的情况,只有浏览器测试能发现。
6
+ (本项目的"登录后被弹回登录页"与"刷新后会话丢失"两个缺陷正是这样发现的。)
7
+
8
+ 用法:
9
+ KSC_E2E_PASSWORD=<实例口令> python3 scripts/e2e-verify.py
10
+ KSC_BASE_URL=http://host:port KSC_E2E_PASSWORD=xxx python3 scripts/e2e-verify.py
11
+
12
+ 依赖:pip install playwright && playwright install chromium
13
+ """
14
+
15
+ import os
16
+ import re
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
+ # 表格的数据行。
31
+ #
32
+ # AntD 的 Table 会在 tbody 的第一个位置插一个 aria-hidden 的度量行,用来
33
+ # 量出各列的宽度;它没有内容、也不是一条数据。自建的表格没有这一行。
34
+ # 这个选择器对两种实现都成立 —— 迁移过程中两种表格并存,断言不必跟着改。
35
+ DATA_ROWS = "tbody tr:not(.ant-table-measure-row)"
36
+
37
+ results: list[tuple[str, bool, str]] = []
38
+
39
+ # 只跑指定段落,逗号分隔的**标题子串**,例如:
40
+ # KSC_E2E_SECTIONS=pulls,issues python3 scripts/e2e-verify.py
41
+ # 全量约三分钟。改一处界面就要等三分钟的话,人很快就会不再跑它 ——
42
+ # 而这份脚本是前端唯一的一层网(前端没有单元测试)。
43
+ ONLY = [part.strip() for part in os.environ.get("KSC_E2E_SECTIONS", "").split(",") if part.strip()]
44
+
45
+ _skipping = False
46
+
47
+
48
+ def check(name: str, ok: bool, detail: str = "") -> None:
49
+ if _skipping:
50
+ return
51
+ results.append((name, ok, detail))
52
+ print(f"[{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
53
+
54
+
55
+ def section(title: str) -> None:
56
+ global _skipping
57
+ _skipping = bool(ONLY) and not any(part in title for part in ONLY)
58
+ if _skipping:
59
+ print(f"\n─── {title} ───(跳过)")
60
+ return
61
+ print(f"\n─── {title} ───")
62
+
63
+
64
+ def login(page: Page) -> None:
65
+ page.goto(f"{BASE}/login", wait_until="networkidle")
66
+ page.get_by_label("用户名").fill(USERNAME)
67
+ page.get_by_label("密码").fill(PASSWORD)
68
+ page.get_by_role("button", name="登录").click()
69
+ page.wait_for_url("**/dashboard", timeout=15000)
70
+ page.wait_for_load_state("networkidle")
71
+
72
+
73
+ def verify_auth(page: Page) -> None:
74
+ """认证与路由守卫。"""
75
+ section("认证与路由守卫")
76
+
77
+ page.goto(BASE, wait_until="networkidle")
78
+ check("未登录访问 / 被路由守卫拦截", page.url.endswith("/login"), page.url)
79
+
80
+ check("登录页标题渲染", page.get_by_role("heading", name="登录").is_visible())
81
+
82
+ bg = page.evaluate("getComputedStyle(document.body).backgroundColor")
83
+ check("主题令牌生效(非默认白底)", bg != "rgb(255, 255, 255)", bg)
84
+
85
+ # 错误凭据应给出后端返回的提示,且不泄露账号是否存在
86
+ page.get_by_label("用户名").fill(USERNAME)
87
+ page.get_by_label("密码").fill("definitely-wrong-password")
88
+ page.get_by_role("button", name="登录").click()
89
+ alert = page.get_by_role("alert")
90
+ alert.wait_for(state="visible", timeout=8000)
91
+ check("错误凭据显示提示", "用户名或密码错误" in alert.inner_text())
92
+ check("错误后停留在登录页", page.url.endswith("/login"))
93
+
94
+ login(page)
95
+ check("登录后跳转到仪表盘", page.url.endswith("/dashboard"), page.url)
96
+
97
+ page.reload(wait_until="networkidle")
98
+ page.wait_for_timeout(1200)
99
+ check("刷新后会话保持", page.url.endswith("/dashboard"), page.url)
100
+
101
+ cookies = page.context.cookies()
102
+ access = next((c for c in cookies if c["name"] == "ksc_access"), None)
103
+ check("访问令牌 Cookie 为 HttpOnly", bool(access and access.get("httpOnly")))
104
+
105
+ page.goto(f"{BASE}/setup", wait_until="networkidle")
106
+ page.wait_for_timeout(1000)
107
+ check("已初始化后 /setup 被拒", not page.url.endswith("/setup"), page.url)
108
+
109
+
110
+ def verify_pulls(page: Page) -> None:
111
+ """PR 列表与详情。"""
112
+ section("PR 列表与详情")
113
+
114
+ page.goto(f"{BASE}/pulls", wait_until="networkidle")
115
+ page.wait_for_timeout(2500)
116
+ body = page.inner_text("body")
117
+ check("PR 列表渲染", "Pull Request" in body)
118
+
119
+ rows = page.locator(DATA_ROWS)
120
+ count = rows.count()
121
+ check("列表有数据行", count > 0, f"{count} 行")
122
+
123
+ # 门禁筛选:这是列表页的核心交互。
124
+ # 分面栏的每个取值现在是 AntD 的 Checkbox(原来是自绘方块加
125
+ # `<button aria-pressed>`),所以按 role 定位而不是按 button。
126
+ # 可访问名含计数,例如「可合入 196」,按子串匹配即可。
127
+ ready = page.get_by_role("checkbox", name="可合入").first
128
+ if ready.count() > 0:
129
+ ready.click()
130
+ page.wait_for_timeout(1800)
131
+ after = page.locator(DATA_ROWS).count()
132
+ all_ready = after > 0 and all(
133
+ "可合入" in rows.nth(i).inner_text() for i in range(min(after, 5))
134
+ )
135
+ check("门禁筛选「可合入」结果正确", all_ready, f"{after} 行")
136
+ # 勾选后地址栏应带上该筛选 —— 这是"筛选可分享"的前提
137
+ check("筛选写入地址栏", "stage=ready_to_merge" in page.url, page.url)
138
+
139
+ # 用一次干净的导航重置,而不是点"清除"再点一次。
140
+ # 分面栏每一维都有自己的"清除",`has_text="清除"` 会命中第一个维度,
141
+ # 于是门禁筛选残留下来,把后面几步一起带偏 —— 而失败信息还指向别处。
142
+ page.goto(f"{BASE}/pulls", wait_until="networkidle")
143
+ page.wait_for_timeout(1500)
144
+ check("重置后筛选已清空", "stage=" not in page.url, page.url)
145
+ else:
146
+ check("存在门禁筛选条", False)
147
+
148
+ # 搜索
149
+ search = page.get_by_placeholder("按标题或 PR 编号搜索,回车确认")
150
+ search.fill("KVM")
151
+ search.press("Enter")
152
+ page.wait_for_timeout(1800)
153
+ check("搜索有结果", page.locator(DATA_ROWS).count() > 0)
154
+ check("搜索写入地址栏", "q=KVM" in page.url, page.url)
155
+
156
+ # 详情。挑一个带标签的 PR:标签面板在没有标签时不渲染,
157
+ # 用列表第一条会把"这条 PR 恰好没有标签"误报成界面缺陷。
158
+ labelled = _pull_with_labels(page)
159
+ if labelled:
160
+ page.goto(f"{BASE}/pulls/{labelled}", wait_until="networkidle")
161
+ else:
162
+ page.goto(f"{BASE}/pulls", wait_until="networkidle")
163
+ page.wait_for_timeout(1500)
164
+ page.locator(DATA_ROWS).first.locator("a").first.click()
165
+ page.wait_for_url("**/pulls/**", timeout=15000)
166
+ page.wait_for_timeout(2500)
167
+ detail = page.inner_text("body")
168
+ check("进入 PR 详情", "/pulls/" in page.url)
169
+ check("详情含合并门禁面板", "合并门禁" in detail)
170
+ check("详情含元信息面板", "元信息" in detail)
171
+ check("详情含标签面板", "标签" in detail, "取自确实带标签的 PR")
172
+ check("详情含分类面板", "分类" in detail)
173
+ # 逐个断言存在并点击。原先是 `if count() > 0` 才点,
174
+ # 标签改名后会静默通过 —— 那种断言等于没写。
175
+ #
176
+ # AntD 的 Tabs 渲染成 role=tab 而不是 button(自绘的那版是 button),
177
+ # 且可访问名含条数角标,例如「提交 27」,所以按子串匹配。
178
+ for tab in ["概览", "提交", "文件变更", "讨论", "AI 分析"]:
179
+ el = page.get_by_role("tab", name=tab)
180
+ check(f"详情页含「{tab}」标签", el.count() > 0)
181
+ if el.count() > 0:
182
+ el.click()
183
+ page.wait_for_timeout(900)
184
+ check("详情页标签可切换", True)
185
+
186
+ # 切回讨论页签,确认内容确实换了(点击生效不等于面板渲染了)
187
+ page.get_by_role("tab", name="讨论").click()
188
+ page.wait_for_timeout(1200)
189
+ check(
190
+ "切到讨论页签后面板已渲染",
191
+ page.locator("details").count() > 0 or "评审" in page.inner_text("body"),
192
+ )
193
+
194
+
195
+ def verify_issues(page: Page) -> None:
196
+ section("Issue 列表")
197
+
198
+ page.goto(f"{BASE}/issues", wait_until="networkidle")
199
+ page.wait_for_timeout(2500)
200
+ check("Issue 列表渲染", "Issue" in page.inner_text("body"))
201
+ check("列表有数据行", page.locator(DATA_ROWS).count() > 0)
202
+
203
+ # 类型筛选使用 AtomGit 原生 issue_type
204
+ for label in ["内核缺陷", "内核需求", "任务"]:
205
+ button = page.locator("button", has_text=label).first
206
+ if button.count() > 0:
207
+ button.click()
208
+ page.wait_for_timeout(1800)
209
+ rows = page.locator(DATA_ROWS)
210
+ ok = rows.count() > 0 and label in rows.first.inner_text()
211
+ check(f"按类型「{label}」筛选结果正确", ok)
212
+ button.click()
213
+ page.wait_for_timeout(1000)
214
+ break
215
+
216
+
217
+ def verify_dashboard(page: Page) -> None:
218
+ section("总览仪表盘")
219
+
220
+ page.goto(f"{BASE}/dashboard", wait_until="networkidle")
221
+ page.wait_for_timeout(2500)
222
+ body = page.inner_text("body")
223
+ for label in ["可合入", "阻塞中", "待评审", "超 30 天未评审"]:
224
+ check(f"指标卡「{label}」", label in body)
225
+ for block in ["门禁阶段分布", "流转效率", "仓库概况", "评审人工作量"]:
226
+ check(f"区块「{block}」", block in body)
227
+
228
+ # 指标卡与门禁阶段都要能点进去:看到"CI 失败 83"却点不进去,
229
+ # 就得自己去列表页把筛选重选一遍 —— 那正是这个平台要消掉的一步。
230
+ targets = page.locator("a[href*='/pulls?']")
231
+ check("指标卡/阶段行可点进筛选后的列表", targets.count() >= 4, f"{targets.count()} 处")
232
+ stage_rows = page.locator("a[title^='查看处于']")
233
+ check("门禁阶段整行是链接", stage_rows.count() > 0, f"{stage_rows.count()} 行")
234
+ if stage_rows.count() > 0:
235
+ href = stage_rows.first.get_attribute("href")
236
+ check("阶段链接带上该阶段筛选", bool(href) and "stage=" in href, href)
237
+
238
+ # 首页的关注数字与队列页同源:分别统计的话,关掉一条规则后
239
+ # 两个页面会显示两个不同的组数,而且从界面上看不出谁对。
240
+ dashboard_groups = re.search(r"收敛为 (\d+) 组", body)
241
+ check("首页给出收敛后的组数", dashboard_groups is not None)
242
+ if dashboard_groups:
243
+ page.goto(f"{BASE}/attention", wait_until="networkidle")
244
+ page.wait_for_timeout(3000)
245
+ queue_groups = re.search(r"收敛为 (\d+) 组", page.inner_text("body"))
246
+ check(
247
+ "首页与队列页组数一致",
248
+ queue_groups is not None and queue_groups.group(1) == dashboard_groups.group(1),
249
+ f"首页 {dashboard_groups.group(1)} / 队列 {queue_groups and queue_groups.group(1)}",
250
+ )
251
+ page.goto(f"{BASE}/dashboard", wait_until="networkidle")
252
+ page.wait_for_timeout(2000)
253
+
254
+
255
+ def verify_attention(page: Page) -> None:
256
+ """关注队列。设计目标是**收敛**:一条规则一行,而不是每个命中对象一行。
257
+
258
+ 断言因此落在"分组是否给出了判断依据与出路"上,而不是逐条列表能否筛选 ——
259
+ 逐条列表已经被收敛掉了,那是这份页面存在的前提。
260
+ """
261
+ section("关注队列")
262
+
263
+ page.goto(f"{BASE}/attention", wait_until="networkidle")
264
+ page.wait_for_timeout(3000)
265
+ body = page.inner_text("body")
266
+
267
+ check("队列页渲染", "关注队列" in body)
268
+
269
+ # 每组一个「逐条处理」链接,用它数分组
270
+ group_count = page.locator("a", has_text="逐条处理").count()
271
+ check("收敛为分组视图", group_count > 0, f"{group_count} 组")
272
+ if group_count == 0:
273
+ # 也可能是规则被全部关掉 —— 那样页面必须说明原因,而不是留白
274
+ check("队列为空时说明原因", "关注队列为空" in body)
275
+ return
276
+
277
+ check("写明收敛前后条数", "收敛为" in body)
278
+
279
+ # 判定依据与处置建议:只列问题不给出路,队列就只是噪音来源
280
+ check("说明这条规则在提醒什么", "建议:" in body)
281
+ check("分组带严重程度", page.locator("span", has_text=re.compile(r"^(阻塞|严重|警告|提示)$")).count() > 0)
282
+ check("给出最久等待天数", "最久已等" in body)
283
+ check("规则名可读", any(name in body for name in ("评审超期", "CVE 未推进", "Issue 未指派", "长期无活动")))
284
+
285
+ href = page.locator("a", has_text="逐条处理").first.get_attribute("href")
286
+ check("逐条处理指向可筛选的列表", bool(href) and ("/pulls" in href or "/issues" in href), href)
287
+
288
+ # 样例展开后才有逐条的动作 —— 收敛视图默认不铺开,这是有意的
289
+ sample_btn = page.locator("button", has_text="样例").first
290
+ check("提供样例展开入口", sample_btn.count() > 0)
291
+ if sample_btn.count() > 0:
292
+ sample_btn.click()
293
+ page.wait_for_timeout(1200)
294
+ claim = page.locator("button", has_text="认领")
295
+ check("样例行提供认领入口", claim.count() > 0)
296
+ if claim.count() > 0:
297
+ claim.first.click()
298
+ try:
299
+ page.locator("button", has_text="交还").first.wait_for(
300
+ state="visible", timeout=10000
301
+ )
302
+ check("认领后该行变为交还", True)
303
+ except Exception:
304
+ check("认领后该行变为交还", False, page.inner_text("body")[-200:])
305
+
306
+ check("组头统计已认领条数", "已认领" in page.inner_text("body"))
307
+
308
+ release = page.locator("button", has_text="交还").first
309
+ if release.count() > 0:
310
+ release.click()
311
+ page.wait_for_timeout(2000)
312
+ check("交还后恢复为认领", page.locator("button", has_text="认领").count() > 0)
313
+
314
+ # 订阅设置:规则可关、阈值可调、范围可限定
315
+ page.get_by_role("button", name="关注设置").click()
316
+ page.wait_for_timeout(1500)
317
+ settings = page.inner_text("body")
318
+ check("关注设置可展开", "阈值" in settings)
319
+ # 阈值是 AntD 的 InputNumber:它的内层 input 是 type="text" 配
320
+ # role="spinbutton",不再有 type=number
321
+ thresholds = page.get_by_role("spinbutton")
322
+ check("阈值可调", "天" in settings and thresholds.count() > 0, f"{thresholds.count()} 个")
323
+ # 范围选项要**点开浮层**才存在:AntD 的选项按需渲染,不像原生 select
324
+ # 那样把全部 option 一直放在 DOM 里。查页面文本永远查不到。
325
+ # 用属性后缀定位而不是写死某条规则的名字 —— 规则名会改,后缀不会。
326
+ scope = page.locator('[aria-label$="适用范围"]').first
327
+ check("适用范围可限定", scope.count() > 0)
328
+ if scope.count() > 0:
329
+ scope.click()
330
+ page.wait_for_timeout(800)
331
+ choices = page.locator(".ant-select-item-option:visible").all_inner_texts()
332
+ check(
333
+ "范围可选全部/仅 PR/仅 Issue",
334
+ {"全部", "仅 PR", "仅 Issue"} <= set(choices),
335
+ str(choices),
336
+ )
337
+ page.keyboard.press("Escape")
338
+ page.wait_for_timeout(500)
339
+ check("写明改动生效时机", "重算" in settings)
340
+
341
+ first = page.locator("input[type=checkbox]").first
342
+ check("关注设置列出规则开关", first.count() > 0)
343
+ if first.count() > 0:
344
+ was_checked = first.is_checked()
345
+ first.click()
346
+ page.wait_for_timeout(1500)
347
+ check("规则开关可切换", page.locator("input[type=checkbox]").first.is_checked() != was_checked)
348
+ # 复原:这套设置是全局的,验证不能把环境留在改动后的状态
349
+ page.locator("input[type=checkbox]").first.click()
350
+ page.wait_for_timeout(1500)
351
+ check("规则开关可还原", page.locator("input[type=checkbox]").first.is_checked() == was_checked)
352
+
353
+
354
+ def _pull_with_classification(page: Page) -> str | None:
355
+ """挑一个已有分类结果、且不是人工指定的 PR。
356
+
357
+ 不能用列表第一行:列表按"最近更新"排序,而分类任务每 10 分钟才跑一轮,
358
+ 刚同步进来的 PR 会排在最前且尚无分类 —— 那时分类面板显示"尚未分类",
359
+ 根本没有"修改"入口。取第一行测的是"这十分钟里没有新 PR 进来"。
360
+
361
+ 也不能挑人工指定的:那种条目的入口文案是「撤销人工指定」。
362
+ """
363
+ repos = page.request.get(f"{BASE}/api/v1/repositories")
364
+ if repos.status != 200 or not repos.json():
365
+ return None
366
+ repo_id = repos.json()[0]["id"]
367
+ resp = page.request.get(
368
+ f"{BASE}/api/v1/repositories/{repo_id}/classifications",
369
+ params={"per_page": 50},
370
+ )
371
+ if resp.status != 200:
372
+ return None
373
+ for item in resp.json()["items"]:
374
+ if item.get("subject_type") == "pull_request" and item.get("source") != "manual":
375
+ return item["subject_id"]
376
+ return None
377
+
378
+
379
+ def verify_classification(page: Page) -> None:
380
+ """PR 详情页的分类面板与人工覆盖。"""
381
+ section("分类与人工覆盖")
382
+
383
+ classified = _pull_with_classification(page)
384
+ if classified:
385
+ page.goto(f"{BASE}/pulls/{classified}", wait_until="networkidle")
386
+ else:
387
+ page.goto(f"{BASE}/pulls", wait_until="networkidle")
388
+ page.wait_for_timeout(2500)
389
+ page.locator(DATA_ROWS).first.locator("a").first.click()
390
+ page.wait_for_url("**/pulls/**", timeout=15000)
391
+ page.wait_for_timeout(2500)
392
+
393
+ body = page.inner_text("body")
394
+ check("详情页含分类面板", "分类" in body)
395
+ # AI 分析标签必须存在,否则整套 AI 对接在界面上不可达
396
+ check("详情页含 AI 分析标签", "AI 分析" in body)
397
+
398
+ # 点开修改,指定一个类型并保存,随后应标记为人工指定
399
+ edit = page.locator("button", has_text="修改").first
400
+ if edit.count() == 0:
401
+ check("分类面板提供修改入口", False)
402
+ return
403
+
404
+ edit.click()
405
+ page.wait_for_timeout(800)
406
+
407
+ # AntD 的 Select 不是原生 <select>,没有 select_option():点开浮层,
408
+ # 输入类别名过滤,再点命中的选项。
409
+ #
410
+ # 两点注意,都是 AntD 这个组件的信息架构决定的:
411
+ # 一是浮层里的列表是虚拟滚动的,只渲染可视窗口内的几项,所以要先过滤
412
+ # (该 Select 开了 showSearch 且按 label 过滤);
413
+ # 二是浮层里同时存在两套结构 —— 一套给读屏器的隐藏 listbox(role=option
414
+ # 在它们身上,但尺寸为零、点不到),一套可见列表(只带类名,没有 role)。
415
+ # 所以按 role 定位一定超时,只能按可见性加类名。
416
+ page.get_by_label("类型").click()
417
+ page.wait_for_timeout(500)
418
+ page.get_by_label("类型").fill("新功能")
419
+ page.wait_for_timeout(600)
420
+ page.locator(".ant-select-item-option:visible", has_text="新功能").first.click()
421
+ page.wait_for_timeout(500)
422
+ page.get_by_label("子系统").fill("mm")
423
+ page.locator("button", has_text="保存").first.click()
424
+ page.wait_for_timeout(2500)
425
+
426
+ after = page.inner_text("body")
427
+ check("人工指定后显示「人工」标记", "人工" in after)
428
+ check("人工指定后类型生效", "新功能" in after)
429
+ check("人工指定后出现撤销入口", "撤销人工指定" in after)
430
+
431
+ # 撤销后交还给自动分类。「交还」必须立刻生效,而不是等下一轮定时重算 ——
432
+ # 点了按钮类型却没变,使用者只会以为按钮坏了。
433
+ undo = page.locator("button", has_text="撤销人工指定").first
434
+ if undo.count() > 0:
435
+ undo.click()
436
+ page.wait_for_timeout(3000)
437
+ restored = page.inner_text("body")
438
+ check("撤销后不再显示人工标记", "人工" not in restored.split("分类")[-1][:400])
439
+ check("撤销后类型已由规则重算", "新功能" not in restored.split("分类")[-1][:400])
440
+
441
+
442
+ def verify_ai(page: Page) -> None:
443
+ """AI 对接的设置页与分析面板。"""
444
+ section("AI 对接")
445
+
446
+ page.goto(f"{BASE}/settings/ai", wait_until="networkidle")
447
+ page.wait_for_timeout(2500)
448
+ body = page.inner_text("body")
449
+
450
+ check("AI 设置页渲染", "AI 对接" in body)
451
+ check("含模型端点区块", "模型端点" in body)
452
+ check("含任务路由区块", "任务路由" in body)
453
+ # 按任务配模型是成本控制的核心,四项任务都应列出
454
+ for task in ["类型判定", "变更摘要", "风险审查", "Backport 核对"]:
455
+ check(f"任务路由含「{task}」", task in body)
456
+ check("提示按次计费", "按次计费" in body or "计费" in body)
457
+
458
+ # 补判按钮的可用性必须与"是否配了端点"一致。
459
+ # 原先直接断言"禁用",隐含假设环境里没配端点 —— 配过之后就永远失败,
460
+ # 而失败原因是环境而不是代码。这里改为由接口读实际状态再比对。
461
+ classify_button = page.locator("button", has_text="立即补判").first
462
+ if classify_button.count() > 0:
463
+ providers = page.request.get(f"{BASE}/api/v1/ai/providers")
464
+ configured = False
465
+ if providers.ok:
466
+ payload = providers.json()
467
+ rows = payload if isinstance(payload, list) else payload.get("items", [])
468
+ configured = any(row.get("is_enabled", True) for row in rows)
469
+ check(
470
+ "补判按钮可用性与端点配置一致",
471
+ classify_button.is_disabled() is not configured,
472
+ f"已配置端点={configured} 按钮禁用={classify_button.is_disabled()}",
473
+ )
474
+
475
+ # PR 详情页的 AI 分析面板
476
+ page.goto(f"{BASE}/pulls", wait_until="networkidle")
477
+ page.wait_for_timeout(2500)
478
+ page.locator(DATA_ROWS).first.locator("a").first.click()
479
+ page.wait_for_url("**/pulls/**", timeout=15000)
480
+ page.wait_for_timeout(2000)
481
+ # AntD 的页签是 role=tab,不是 button(自绘的那版是 button)
482
+ page.get_by_role("tab", name="AI 分析").click()
483
+ page.wait_for_timeout(1500)
484
+
485
+ ai_body = page.inner_text("body")
486
+ check("AI 面板渲染", "分析" in ai_body)
487
+ check("AI 面板说明不自动调用", "不自动执行" in ai_body or "按次计费" in ai_body)
488
+
489
+
490
+ def verify_settings(page: Page, token: str | None) -> None:
491
+ section("仓库与凭据设置")
492
+
493
+ page.goto(f"{BASE}/settings/general", wait_until="networkidle")
494
+ page.wait_for_timeout(2000)
495
+ body = page.inner_text("body")
496
+ check("凭据区块渲染", "访问凭据" in body)
497
+ check("仓库区块渲染", "纳管仓库" in body)
498
+ check("显示凭据指纹而非明文", "指纹" in body)
499
+ check("显示 webhook 地址", "webhook" in body.lower())
500
+ # 计数来自接口,不硬编码具体数值,避免数据变化后误报
501
+ check("显示 PR 与 Issue 计数", "PR" in body and "Issue" in body)
502
+
503
+ if token:
504
+ leaked = token in page.content()
505
+ check("页面无凭据明文泄漏", not leaked)
506
+
507
+
508
+ def _pull_with_labels(page: Page) -> str | None:
509
+ """挑一个确实带标签的 PR。
510
+
511
+ 取列表第一条会随同步进来的新 PR 而变,而没有标签的 PR 本就不渲染
512
+ 标签面板 —— 那样断言的是"当前排第一的 PR 有没有标签",与代码无关。
513
+ """
514
+ repos = page.request.get(f"{BASE}/api/v1/repositories")
515
+ if repos.status != 200 or not repos.json():
516
+ return None
517
+ repo_id = repos.json()[0]["id"]
518
+ pulls = page.request.get(
519
+ f"{BASE}/api/v1/repositories/{repo_id}/pulls",
520
+ params={"state": "all", "per_page": 50},
521
+ )
522
+ if pulls.status != 200:
523
+ return None
524
+ for item in pulls.json()["items"]:
525
+ if item.get("label_names"):
526
+ return item["id"]
527
+ return None
528
+
529
+
530
+ def verify_versions(page: Page) -> None:
531
+ """版本与分支页。维护者来这里问的是"这个版本现在什么状态、谁负责"。"""
532
+ section("版本与分支")
533
+
534
+ page.goto(f"{BASE}/branches", wait_until="networkidle")
535
+ page.wait_for_timeout(3000)
536
+ body = page.inner_text("body")
537
+ check("版本列表渲染", "版本与分支" in body)
538
+ check("列出负责人", "@" in body, "负责人来自 sig-info.yaml")
539
+
540
+ # 待处理数是判断"该不该现在动手"的依据,必须能点进那批 PR
541
+ pending = page.locator("a[href*='branch=']").first
542
+ check("待处理可点进该分支的 PR", pending.count() > 0)
543
+
544
+ page.locator(f"{DATA_ROWS} a").first.click()
545
+ page.wait_for_url("**/branches/**", timeout=15000)
546
+ page.wait_for_timeout(3000)
547
+ detail = page.inner_text("body")
548
+
549
+ check("详情页给出负责人", "负责人" in detail)
550
+ check("给出上游基线", "上游基线" in detail or "基础版本" in detail)
551
+ check("给出当前 tag", "当前 tag" in detail)
552
+ check("给出合入趋势", "合入趋势" in detail)
553
+
554
+ # 分期总结:用户要的是"每个版本的双周/月度合入总结",不是只有上一期。
555
+ # 期次行仍是原生 <li><button>,但标记从 aria-pressed 换成了 aria-current:
556
+ # 一行是一期、带合入数与贡献者,语义是"当前查看的是这条",
557
+ # 不是"这个开关被按下了"。
558
+ check("给出分期合入总结", "分期合入总结" in detail)
559
+ period_rows = page.locator("li button[aria-current]")
560
+ check("分期总结列出多期", period_rows.count() >= 2, f"{period_rows.count()} 期")
561
+ check("选中的那期被标出", period_rows.count() > 0 and period_rows.first.get_attribute("aria-current") == "true")
562
+
563
+ check("分期带 CVE 数", "CVE" in detail)
564
+ check("含发版说明", "发版说明" in detail)
565
+
566
+ # 粒度切换现在是 AntD 的 Segmented。它的可点击元素是 label,
567
+ # 里面那个 input[type=radio] 是给读屏器与键盘用的、视觉上不可见 ——
568
+ # 按 role 定位会拿到那个 input,直接点它点不动。
569
+ # 页面上有两个切换(合入趋势与分期总结各一个),取第一个。
570
+ monthly = page.locator("label.ant-segmented-item", has_text="按月").first
571
+ check("可切换月/双周", monthly.count() > 0)
572
+ if monthly.count() > 0:
573
+ monthly.click()
574
+ page.wait_for_timeout(2500)
575
+ check("切到按月后仍渲染", "分期合入总结" in page.inner_text("body"))
576
+
577
+
578
+ def verify_meetings(page: Page) -> None:
579
+ """SIG 例会页。"""
580
+ section("SIG 例会")
581
+
582
+ page.goto(f"{BASE}/meetings", wait_until="networkidle")
583
+ page.wait_for_timeout(3000)
584
+ body = page.inner_text("body")
585
+ check("例会列表渲染", "SIG 例会" in body)
586
+ check("给出统计口径", "已开例会" in body and "累计议题" in body)
587
+ check("给出主持轮值", "主持轮值" in body)
588
+ check("列出会议日期", re.search(r"\d{4}-\d{2}-\d{2}", body) is not None)
589
+
590
+ links = page.locator("a[href*='/meetings/']")
591
+ check("会议可点进详情", links.count() > 0, f"{links.count()} 场")
592
+ if links.count() == 0:
593
+ return
594
+
595
+ links.first.click()
596
+ page.wait_for_url("**/meetings/**", timeout=15000)
597
+ page.wait_for_timeout(2500)
598
+ detail = page.inner_text("body")
599
+
600
+ check("详情页给出议题", "议题" in detail)
601
+ check("详情页给出统计", "涉及 PR" in detail)
602
+ check("详情页可回列表", "返回例会" in detail)
603
+ check("详情页可翻上一场", "上一场" in detail or "下一场" in detail)
604
+
605
+
606
+ def verify_no_leak(page: Page, token: str | None) -> None:
607
+ """用真实 token 值比对,而非检查头部名称字符串。"""
608
+ if not token:
609
+ return
610
+ section("凭据泄漏检查")
611
+ for path in ["/pulls", "/issues", "/settings/general", "/dashboard"]:
612
+ page.goto(f"{BASE}{path}", wait_until="networkidle")
613
+ page.wait_for_timeout(1000)
614
+ check(f"{path} 无 token 明文", token not in page.content())
615
+
616
+
617
+ def verify_dom_contract(page: Page) -> None:
618
+ """迁移期间不许偷偷弄丢的东西。
619
+
620
+ 全站 5700 行未迁移代码依赖 Tailwind 的 preflight:box-sizing 归一、
621
+ 元素 margin 归零、列表不画圆点、裸 button 不带 UA 底色。AntD 会注入
622
+ 自己的全局样式,这些一旦被顶掉,界面上只会"看着有点怪",
623
+ 没有任何断言会报错 —— 所以在这里钉成计算值。
624
+ """
625
+ section("DOM 契约")
626
+
627
+ # 把色值统一归一化后再比较:一边是 oklch 令牌,一边是浏览器算出的 rgb,
628
+ # 不归一化就没法比。做法与 antd-theme.ts 的 resolveToken 一致 ——
629
+ # 画一个像素再读回,这是唯一能把 oklch 变成 sRGB 的可靠读法。
630
+ resolve_js = """
631
+ (value) => {
632
+ const c = document.createElement('canvas');
633
+ c.width = 1; c.height = 1;
634
+ const ctx = c.getContext('2d');
635
+ ctx.clearRect(0, 0, 1, 1);
636
+ ctx.fillStyle = 'transparent';
637
+ ctx.fillStyle = value;
638
+ ctx.fillRect(0, 0, 1, 1);
639
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
640
+ return a === 255 ? `rgb(${r}, ${g}, ${b})` : `无法解析(${value})`;
641
+ }
642
+ """
643
+
644
+ page.goto(f"{BASE}/pulls", wait_until="networkidle")
645
+ page.wait_for_timeout(2500)
646
+
647
+ body_bg = page.evaluate(f"({resolve_js})(getComputedStyle(document.body).backgroundColor)")
648
+ surface0 = page.evaluate(
649
+ f"({resolve_js})(getComputedStyle(document.documentElement)"
650
+ f".getPropertyValue('--color-surface-0'))"
651
+ )
652
+ check("body 背景仍是页面底色令牌", body_bg == surface0, f"{body_bg} vs {surface0}")
653
+
654
+ rows = page.locator("table > tbody > tr")
655
+ check("列表页仍是语义表格", rows.count() > 0, f"{rows.count()} 行")
656
+
657
+ # preflight 的几条效果,用**临时插入的探针元素**来测,而不是去页面上
658
+ # 找恰好符合条件的那一个。原先这里拿分页的"上一页"按钮当裸 button 探针,
659
+ # 分页换成 AntD 组件后它就不存在了 —— 断言随之报错,而 preflight
660
+ # 其实好端端的。测的是 CSS 规则,就该直接测规则。
661
+ probes = page.evaluate(
662
+ """
663
+ () => {
664
+ const host = document.createElement('div');
665
+ host.style.cssText = 'position:absolute;left:-9999px;top:0';
666
+ host.innerHTML =
667
+ '<button>x</button><ul><li>x</li></ul><pre>x</pre><span></span>';
668
+ document.body.appendChild(host);
669
+ const read = (sel, prop) => {
670
+ const el = host.querySelector(sel);
671
+ return el ? getComputedStyle(el)[prop] : 'NO_ELEMENT';
672
+ };
673
+ const out = {
674
+ buttonBg: read('button', 'backgroundColor'),
675
+ listStyle: read('ul', 'listStyleType'),
676
+ preMarginTop: read('pre', 'marginTop'),
677
+ // box-sizing 是 preflight 里最要命的一条:丢了它,
678
+ // 全站 w-full + px-* 的盒子都会溢出
679
+ boxSizing: read('span', 'boxSizing'),
680
+ };
681
+ host.remove();
682
+ return out;
683
+ }
684
+ """
685
+ )
686
+ check("裸 button 背景透明(preflight)", probes["buttonBg"] == "rgba(0, 0, 0, 0)", probes["buttonBg"])
687
+ check("列表不画圆点(preflight)", probes["listStyle"] == "none", probes["listStyle"])
688
+ check("pre 无 UA 边距(preflight)", probes["preMarginTop"] == "0px", probes["preMarginTop"])
689
+ check("box-sizing 归一(preflight)", probes["boxSizing"] == "border-box", probes["boxSizing"])
690
+
691
+ # 讨论区 <details>/<ol> 的结构不在这里查:只有存在机器人评论的 PR 才会
692
+ # 渲染它们,而"哪个 PR 有"随同步变化 —— 那样的断言测的是数据。
693
+ # 该结构由 scripts/e2e-pr-detail.py 覆盖,它用显式传入的 KSC_PULL_ID 跑。
694
+
695
+
696
+ def load_token() -> str | None:
697
+ env = os.environ.get("ATOMGIT_TOKEN")
698
+ if env:
699
+ return env
700
+ from urllib.parse import urlparse
701
+
702
+ path = os.path.expanduser("~/.git-credentials")
703
+ if not os.path.exists(path):
704
+ return None
705
+ with open(path, encoding="utf-8") as fh:
706
+ for line in fh:
707
+ parsed = urlparse(line.strip())
708
+ if parsed.hostname == "atomgit.com" and parsed.password:
709
+ return parsed.password
710
+ return None
711
+
712
+
713
+ def main() -> int:
714
+ token = load_token()
715
+ with sync_playwright() as p:
716
+ browser = p.chromium.launch(headless=True)
717
+ context = browser.new_context(viewport={"width": 1500, "height": 1000})
718
+ page = context.new_page()
719
+
720
+ console_errors: list[str] = []
721
+ page.on(
722
+ "console",
723
+ lambda m: console_errors.append(m.text) if m.type == "error" else None,
724
+ )
725
+
726
+ # (段落标题, 执行体)。标题与各 verify_* 里 section() 的调用一致,
727
+ # 单段运行时据此跳过整段 —— 只让 check() 静默没用,
728
+ # 浏览器仍会把每个页面走一遍,省不下时间。
729
+ steps: list[tuple[str, object]] = [
730
+ ("认证与路由守卫", lambda: verify_auth(page)),
731
+ ("PR 列表与详情", lambda: verify_pulls(page)),
732
+ ("Issue 列表", lambda: verify_issues(page)),
733
+ ("关注队列", lambda: verify_attention(page)),
734
+ ("分类与人工覆盖", lambda: verify_classification(page)),
735
+ ("AI 对接", lambda: verify_ai(page)),
736
+ ("总览仪表盘", lambda: verify_dashboard(page)),
737
+ ("版本与分支", lambda: verify_versions(page)),
738
+ ("SIG 例会", lambda: verify_meetings(page)),
739
+ ("仓库与凭据设置", lambda: verify_settings(page, token)),
740
+ ("凭据泄漏检查", lambda: verify_no_leak(page, token)),
741
+ ("DOM 契约", lambda: verify_dom_contract(page)),
742
+ ]
743
+ try:
744
+ # 登录动作住在 verify_auth 里。只跑后段时必须先补一次登录,
745
+ # 否则每个页面都会被路由守卫弹回登录页,报出一堆假失败 ——
746
+ # 那比不跑更糟,会让人去查根本不存在的界面问题。
747
+ if ONLY and not any(part in "认证与路由守卫" for part in ONLY):
748
+ login(page)
749
+
750
+ for title, run in steps:
751
+ if ONLY and not any(part in title for part in ONLY):
752
+ continue
753
+ run()
754
+ except Exception as exc:
755
+ check("脚本执行完成", False, f"{type(exc).__name__}: {exc}")
756
+ finally:
757
+ browser.close()
758
+
759
+ failed = [r for r in results if not r[1]]
760
+ print("\n" + "=" * 60)
761
+ print(f"通过 {len(results) - len(failed)} / {len(results)}")
762
+
763
+ # 未登录时对 /auth/me 的探测会返回 401,属预期行为,不计为错误
764
+ real_errors = [e for e in console_errors if "401" not in e]
765
+ if real_errors:
766
+ print("\n浏览器控制台错误(已排除会话探测的 401):")
767
+ for error in real_errors[:10]:
768
+ print(" -", error[:200])
769
+ return 1 if failed or real_errors else 0
770
+
771
+
772
+ if __name__ == "__main__":
773
+ sys.exit(main())