@toby1123yjh/test-cli 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.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/assets/scripts.js +470 -0
- package/dist/locales/en.json +155 -0
- package/dist/locales/zh.json +155 -0
- package/dist/templates/components/case-detail.ejs +62 -0
- package/dist/templates/components/evidence-tabs.ejs +249 -0
- package/dist/templates/components/header.ejs +27 -0
- package/dist/templates/components/overview.ejs +56 -0
- package/dist/templates/components/sidebar.ejs +40 -0
- package/dist/templates/components/video-player.ejs +9 -0
- package/dist/templates/report.ejs +26 -0
- package/dist/templates/styles/main.css +1168 -0
- package/dist/templates/styles/themes.css +59 -0
- package/dist/test-cli.js +1289 -0
- package/dist/test-cli.js.map +7 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Test CLI Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# test-cli
|
|
2
|
+
|
|
3
|
+
AI 驱动的 E2E 测试 CLI:通过 `/generate`/`/update` 生成可运行的测试脚本,通过 `/run` 执行并输出 Markdown/HTML 报告。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @toby1123yjh/test-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 系统要求
|
|
12
|
+
|
|
13
|
+
- Node.js >= 18
|
|
14
|
+
- 前端 UI 测试需要 Playwright(建议 `npm i -D playwright && npx playwright install`)
|
|
15
|
+
- Git 可选
|
|
16
|
+
|
|
17
|
+
## `.test-cli/` 目录结构
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
.test-cli/
|
|
21
|
+
├── config.yaml
|
|
22
|
+
├── cases/
|
|
23
|
+
│ └── <module>/
|
|
24
|
+
│ ├── case.yaml
|
|
25
|
+
│ └── script/
|
|
26
|
+
│ ├── test.spec.ts # frontend (Playwright)
|
|
27
|
+
│ └── test.api.mjs # backend (Node + fetch)
|
|
28
|
+
└── tests/
|
|
29
|
+
└── <timestamp>/
|
|
30
|
+
├── report.md
|
|
31
|
+
├── report.html
|
|
32
|
+
└── artifacts/
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 快速开始
|
|
36
|
+
|
|
37
|
+
1) 启动交互式 CLI:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
test-cli
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
2) 生成并加载配置(会创建 `.test-cli/config.yaml`、`.test-cli/cases/`、`.test-cli/tests/`):
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
/config
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
3) 编辑 `.test-cli/config.yaml`(示例):
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
project:
|
|
53
|
+
name: "my-app"
|
|
54
|
+
baseUrl: "http://localhost:3000"
|
|
55
|
+
|
|
56
|
+
provider:
|
|
57
|
+
# openai | anthropic | gemini | deepseek
|
|
58
|
+
provider: "openai"
|
|
59
|
+
model: "gpt-4o-mini"
|
|
60
|
+
# apiKey: ${OPENAI_API_KEY}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
4) 生成测试:
|
|
64
|
+
|
|
65
|
+
- 前端:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
/generate login https://example.com/login
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- 后端(需要 `project.baseUrl` 或在提示里包含 URL):
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
/generate api
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
5) 运行测试(模块可选;不传则跑所有可运行模块):
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
/run
|
|
81
|
+
/run login
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
报告输出在 `.test-cli/tests/<timestamp>/{report.md, report.html}`。
|
|
85
|
+
|
|
86
|
+
## Slash Commands
|
|
87
|
+
|
|
88
|
+
| 命令 | 说明 |
|
|
89
|
+
|------|------|
|
|
90
|
+
| `/help` | 显示命令列表 |
|
|
91
|
+
| `/clear` | 清屏 |
|
|
92
|
+
| `/config` | 创建/重载 `.test-cli/config.yaml` 并准备目录 |
|
|
93
|
+
| `/generate <module> [url] [notes...]` | 生成 `case.yaml` + 可运行脚本 |
|
|
94
|
+
| `/update <module> [notes...]` | 更新 `case.yaml` 并重新生成脚本 |
|
|
95
|
+
| `/validate [file] [--dir <dir>]` | 校验 `case.yaml`(主要用于 frontend case) |
|
|
96
|
+
| `/run [module] [--mode frontend|backend] [--filter ...] [--timeout ...]` | 执行脚本并生成报告 |
|
|
97
|
+
| `/new` | 已废弃(请使用 `/generate`) |
|
|
98
|
+
|
|
99
|
+
## CLI(非交互)
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# 与 /run 等价
|
|
103
|
+
test-cli run [module] [--mode frontend|backend] [--filter "..."]
|
|
104
|
+
|
|
105
|
+
# CI 模式:执行 YAML 测试用例(不依赖生成脚本)
|
|
106
|
+
test-cli run --ci [--filter "..."]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## 开发
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pnpm install
|
|
113
|
+
pnpm test
|
|
114
|
+
pnpm typecheck
|
|
115
|
+
pnpm build
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## 许可证
|
|
119
|
+
|
|
120
|
+
MIT
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
2
|
+
const initSidebarDrawer = () => {
|
|
3
|
+
const root = document.documentElement;
|
|
4
|
+
const sidebar = document.querySelector(".sidebar");
|
|
5
|
+
const toggle = document.querySelector(".sidebar-toggle");
|
|
6
|
+
const backdrop = document.querySelector(".sidebar-backdrop");
|
|
7
|
+
|
|
8
|
+
if (!(sidebar instanceof HTMLElement)) return;
|
|
9
|
+
if (!(toggle instanceof HTMLButtonElement)) return;
|
|
10
|
+
if (!(backdrop instanceof HTMLElement)) return;
|
|
11
|
+
|
|
12
|
+
const mediaQuery = typeof window.matchMedia === "function" ? window.matchMedia("(max-width: 900px)") : null;
|
|
13
|
+
const isMobile = () => (mediaQuery ? mediaQuery.matches : window.innerWidth <= 900);
|
|
14
|
+
|
|
15
|
+
const setSidebarInert = (inert) => {
|
|
16
|
+
if ("inert" in sidebar) {
|
|
17
|
+
sidebar.inert = inert;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const focusableSelector = 'a[href], button, input, select, textarea, [tabindex]';
|
|
22
|
+
sidebar.querySelectorAll(focusableSelector).forEach((el) => {
|
|
23
|
+
if (!(el instanceof HTMLElement)) return;
|
|
24
|
+
|
|
25
|
+
if (inert) {
|
|
26
|
+
if (!el.hasAttribute("data-prev-tabindex")) {
|
|
27
|
+
el.setAttribute("data-prev-tabindex", el.getAttribute("tabindex") ?? "");
|
|
28
|
+
}
|
|
29
|
+
el.setAttribute("tabindex", "-1");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!el.hasAttribute("data-prev-tabindex")) return;
|
|
34
|
+
const prev = el.getAttribute("data-prev-tabindex") ?? "";
|
|
35
|
+
if (prev === "") el.removeAttribute("tabindex");
|
|
36
|
+
else el.setAttribute("tabindex", prev);
|
|
37
|
+
el.removeAttribute("data-prev-tabindex");
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const setOpen = (open) => {
|
|
42
|
+
const mobile = isMobile();
|
|
43
|
+
const nextOpen = mobile && open;
|
|
44
|
+
|
|
45
|
+
root.classList.toggle("sidebar-open", nextOpen);
|
|
46
|
+
toggle.setAttribute("aria-expanded", String(nextOpen));
|
|
47
|
+
|
|
48
|
+
if (!mobile) {
|
|
49
|
+
sidebar.removeAttribute("aria-hidden");
|
|
50
|
+
setSidebarInert(false);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
sidebar.setAttribute("aria-hidden", String(!nextOpen));
|
|
55
|
+
setSidebarInert(!nextOpen);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const close = () => {
|
|
59
|
+
setOpen(false);
|
|
60
|
+
toggle.focus();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
toggle.addEventListener("click", () => {
|
|
64
|
+
if (!isMobile()) return;
|
|
65
|
+
|
|
66
|
+
const nextOpen = !root.classList.contains("sidebar-open");
|
|
67
|
+
setOpen(nextOpen);
|
|
68
|
+
|
|
69
|
+
if (nextOpen) {
|
|
70
|
+
const search = sidebar.querySelector(".sidebar-search");
|
|
71
|
+
if (search instanceof HTMLInputElement) search.focus();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
backdrop.addEventListener("click", () => {
|
|
76
|
+
if (!root.classList.contains("sidebar-open")) return;
|
|
77
|
+
setOpen(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
document.addEventListener("keydown", (event) => {
|
|
81
|
+
if (event.key !== "Escape") return;
|
|
82
|
+
if (!root.classList.contains("sidebar-open")) return;
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
close();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
sidebar.addEventListener("click", (event) => {
|
|
88
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
89
|
+
if (!target) return;
|
|
90
|
+
|
|
91
|
+
const caseLink = target.closest(".case-item a");
|
|
92
|
+
if (!caseLink) return;
|
|
93
|
+
|
|
94
|
+
if (isMobile() && root.classList.contains("sidebar-open")) {
|
|
95
|
+
setOpen(false);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const handleViewportChange = () => {
|
|
100
|
+
if (!isMobile()) setOpen(false);
|
|
101
|
+
else setOpen(root.classList.contains("sidebar-open"));
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
handleViewportChange();
|
|
105
|
+
|
|
106
|
+
if (mediaQuery) {
|
|
107
|
+
if (typeof mediaQuery.addEventListener === "function") {
|
|
108
|
+
mediaQuery.addEventListener("change", handleViewportChange);
|
|
109
|
+
} else if (typeof mediaQuery.addListener === "function") {
|
|
110
|
+
mediaQuery.addListener(handleViewportChange);
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
window.addEventListener("resize", handleViewportChange);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const initSidebar = () => {
|
|
118
|
+
const sidebar = document.querySelector(".sidebar");
|
|
119
|
+
if (!sidebar) return;
|
|
120
|
+
|
|
121
|
+
const searchInput = sidebar.querySelector(".sidebar-search");
|
|
122
|
+
if (searchInput instanceof HTMLInputElement) {
|
|
123
|
+
searchInput.addEventListener("input", () => {
|
|
124
|
+
const query = searchInput.value.trim().toLowerCase();
|
|
125
|
+
const items = sidebar.querySelectorAll(".case-item");
|
|
126
|
+
|
|
127
|
+
items.forEach((item) => {
|
|
128
|
+
const nameEl = item.querySelector(".case-name");
|
|
129
|
+
const name = (nameEl?.textContent ?? item.textContent ?? "").trim().toLowerCase();
|
|
130
|
+
item.hidden = query.length > 0 && !name.includes(query);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
searchInput.addEventListener("keydown", (event) => {
|
|
135
|
+
if (event.key !== "Escape") return;
|
|
136
|
+
if (searchInput.value.length === 0) return;
|
|
137
|
+
searchInput.value = "";
|
|
138
|
+
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
sidebar.addEventListener("click", (event) => {
|
|
143
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
144
|
+
if (!target) return;
|
|
145
|
+
|
|
146
|
+
const toggleBtn = target.closest(".toggle-btn");
|
|
147
|
+
if (toggleBtn) {
|
|
148
|
+
const moduleGroup = toggleBtn.closest(".module-group");
|
|
149
|
+
if (moduleGroup) {
|
|
150
|
+
const nextCollapsed = !moduleGroup.classList.contains("collapsed");
|
|
151
|
+
moduleGroup.classList.toggle("collapsed", nextCollapsed);
|
|
152
|
+
const casesList = moduleGroup.querySelector(".module-group__cases");
|
|
153
|
+
if (casesList instanceof HTMLElement) {
|
|
154
|
+
casesList.hidden = nextCollapsed;
|
|
155
|
+
}
|
|
156
|
+
toggleBtn.setAttribute("aria-expanded", String(!nextCollapsed));
|
|
157
|
+
toggleBtn.textContent = nextCollapsed ? "▸" : "▾";
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const caseLink = target.closest(".case-item a");
|
|
163
|
+
if (!caseLink) return;
|
|
164
|
+
|
|
165
|
+
const caseItem = caseLink.closest(".case-item");
|
|
166
|
+
if (!caseItem) return;
|
|
167
|
+
|
|
168
|
+
sidebar.querySelectorAll(".case-item.active").forEach((el) => el.classList.remove("active"));
|
|
169
|
+
caseItem.classList.add("active");
|
|
170
|
+
sidebar.querySelectorAll('.case-link[aria-current="true"]').forEach((el) => el.removeAttribute("aria-current"));
|
|
171
|
+
caseLink.setAttribute("aria-current", "true");
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const initEvidenceTabs = () => {
|
|
176
|
+
const containers = document.querySelectorAll(".evidence-tabs");
|
|
177
|
+
containers.forEach((container) => {
|
|
178
|
+
const tabButtons = Array.from(container.querySelectorAll(".tab-btn"));
|
|
179
|
+
const tabContents = Array.from(container.querySelectorAll(".tab-content"));
|
|
180
|
+
|
|
181
|
+
const getMarkedParser = () => {
|
|
182
|
+
const globalMarked = globalThis.marked;
|
|
183
|
+
if (globalMarked && typeof globalMarked.parse === "function") return globalMarked.parse;
|
|
184
|
+
if (typeof globalMarked === "function") return globalMarked;
|
|
185
|
+
if (globalMarked && typeof globalMarked.marked === "function") return globalMarked.marked;
|
|
186
|
+
return undefined;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const renderAiTab = () => {
|
|
190
|
+
const aiContent = container.querySelector('.tab-content[data-tab="ai"]');
|
|
191
|
+
if (!(aiContent instanceof HTMLElement)) return;
|
|
192
|
+
if (aiContent.getAttribute("data-rendered") === "true") return;
|
|
193
|
+
|
|
194
|
+
const source = aiContent.querySelector(".ai-markdown-source");
|
|
195
|
+
const output = aiContent.querySelector(".ai-markdown-output");
|
|
196
|
+
if (!(source instanceof HTMLTextAreaElement) || !(output instanceof HTMLElement)) return;
|
|
197
|
+
|
|
198
|
+
const markdown = source.value ?? "";
|
|
199
|
+
if (markdown.trim().length === 0) {
|
|
200
|
+
aiContent.setAttribute("data-rendered", "true");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const parseMarkdown = getMarkedParser();
|
|
205
|
+
if (parseMarkdown) {
|
|
206
|
+
const safeMarkdown = markdown.replace(/</g, "<").replace(/>/g, ">");
|
|
207
|
+
output.innerHTML = parseMarkdown(safeMarkdown);
|
|
208
|
+
output.querySelectorAll("a[href]").forEach((link) => {
|
|
209
|
+
const href = link.getAttribute("href") ?? "";
|
|
210
|
+
if (/^\s*javascript:/i.test(href) || /^\s*data:/i.test(href)) {
|
|
211
|
+
link.setAttribute("href", "#");
|
|
212
|
+
}
|
|
213
|
+
link.setAttribute("rel", "noopener noreferrer");
|
|
214
|
+
});
|
|
215
|
+
output.querySelectorAll("img[src]").forEach((img) => {
|
|
216
|
+
const src = img.getAttribute("src") ?? "";
|
|
217
|
+
if (/^\s*javascript:/i.test(src)) {
|
|
218
|
+
img.removeAttribute("src");
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
} else {
|
|
222
|
+
output.textContent = markdown;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
aiContent.setAttribute("data-rendered", "true");
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const setActiveTab = (tab) => {
|
|
229
|
+
tabButtons.forEach((btn) => {
|
|
230
|
+
const isActive = btn.dataset.tab === tab;
|
|
231
|
+
btn.classList.toggle("active", isActive);
|
|
232
|
+
btn.setAttribute("aria-selected", String(isActive));
|
|
233
|
+
if (btn instanceof HTMLElement) {
|
|
234
|
+
btn.tabIndex = isActive ? 0 : -1;
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
tabContents.forEach((content) => {
|
|
238
|
+
const isActive = content.dataset.tab === tab;
|
|
239
|
+
content.hidden = !isActive;
|
|
240
|
+
content.classList.toggle("active", isActive);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (tab === "ai") renderAiTab();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const defaultTab = container.getAttribute("data-default-tab") ?? tabButtons[0]?.dataset.tab ?? "network";
|
|
247
|
+
setActiveTab(defaultTab);
|
|
248
|
+
|
|
249
|
+
const closeOpenRequestDetails = () => {
|
|
250
|
+
const openDetails = Array.from(container.querySelectorAll("tr.request-detail:not([hidden])"));
|
|
251
|
+
if (openDetails.length === 0) return false;
|
|
252
|
+
|
|
253
|
+
openDetails.forEach((row) => {
|
|
254
|
+
if (row instanceof HTMLTableRowElement) row.hidden = true;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
container.querySelectorAll('.request-toggle[aria-expanded="true"]').forEach((btn) => {
|
|
258
|
+
if (!(btn instanceof HTMLButtonElement)) return;
|
|
259
|
+
btn.setAttribute("aria-expanded", "false");
|
|
260
|
+
btn.setAttribute("aria-label", "展开请求详情");
|
|
261
|
+
btn.textContent = "▸";
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return true;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
tabButtons.forEach((btn, index) => {
|
|
268
|
+
btn.addEventListener("keydown", (event) => {
|
|
269
|
+
const key = event.key;
|
|
270
|
+
|
|
271
|
+
if (key === "ArrowRight" || key === "ArrowDown") {
|
|
272
|
+
event.preventDefault();
|
|
273
|
+
tabButtons[(index + 1) % tabButtons.length]?.focus();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (key === "ArrowLeft" || key === "ArrowUp") {
|
|
278
|
+
event.preventDefault();
|
|
279
|
+
tabButtons[(index - 1 + tabButtons.length) % tabButtons.length]?.focus();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (key === "Home") {
|
|
284
|
+
event.preventDefault();
|
|
285
|
+
tabButtons[0]?.focus();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (key === "End") {
|
|
290
|
+
event.preventDefault();
|
|
291
|
+
tabButtons[tabButtons.length - 1]?.focus();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (key === "Enter" || key === " ") {
|
|
296
|
+
event.preventDefault();
|
|
297
|
+
btn.click();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
container.addEventListener("keydown", (event) => {
|
|
303
|
+
if (event.key !== "Escape") return;
|
|
304
|
+
if (!closeOpenRequestDetails()) return;
|
|
305
|
+
event.preventDefault();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const filter = container.querySelector(".request-filter");
|
|
309
|
+
const applyRequestFilter = () => {
|
|
310
|
+
if (!(filter instanceof HTMLSelectElement)) return;
|
|
311
|
+
|
|
312
|
+
const selected = filter.value;
|
|
313
|
+
const rows = Array.from(container.querySelectorAll("tr.request-row"));
|
|
314
|
+
|
|
315
|
+
rows.forEach((row) => {
|
|
316
|
+
const status = Number(row.getAttribute("data-status") ?? "0");
|
|
317
|
+
const isFailed = status >= 400;
|
|
318
|
+
const shouldShow =
|
|
319
|
+
selected === "all" || (selected === "failed" && isFailed) || (selected === "success" && !isFailed);
|
|
320
|
+
|
|
321
|
+
row.hidden = !shouldShow;
|
|
322
|
+
|
|
323
|
+
const requestId = row.getAttribute("data-request-id");
|
|
324
|
+
if (!requestId) return;
|
|
325
|
+
|
|
326
|
+
const detailRow = container.querySelector(`tr.request-detail[data-request-id="${requestId}"]`);
|
|
327
|
+
if (detailRow instanceof HTMLTableRowElement) {
|
|
328
|
+
detailRow.hidden = true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const toggleBtn = row.querySelector(".request-toggle");
|
|
332
|
+
if (toggleBtn instanceof HTMLButtonElement) {
|
|
333
|
+
toggleBtn.setAttribute("aria-expanded", "false");
|
|
334
|
+
toggleBtn.setAttribute("aria-label", "展开请求详情");
|
|
335
|
+
toggleBtn.textContent = "▸";
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
if (filter instanceof HTMLSelectElement) {
|
|
341
|
+
filter.addEventListener("change", applyRequestFilter);
|
|
342
|
+
applyRequestFilter();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const consoleFilter = container.querySelector(".console-level-filter");
|
|
346
|
+
const applyConsoleFilter = () => {
|
|
347
|
+
if (!(consoleFilter instanceof HTMLSelectElement)) return;
|
|
348
|
+
|
|
349
|
+
const selected = consoleFilter.value;
|
|
350
|
+
const items = Array.from(container.querySelectorAll(".console-item"));
|
|
351
|
+
|
|
352
|
+
items.forEach((item) => {
|
|
353
|
+
const level = item.getAttribute("data-level") ?? "";
|
|
354
|
+
item.hidden = selected !== "all" && level !== selected;
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
if (consoleFilter instanceof HTMLSelectElement) {
|
|
359
|
+
consoleFilter.addEventListener("change", applyConsoleFilter);
|
|
360
|
+
applyConsoleFilter();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
container.addEventListener("click", (event) => {
|
|
364
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
365
|
+
if (!target) return;
|
|
366
|
+
|
|
367
|
+
const tabBtn = target.closest(".tab-btn");
|
|
368
|
+
if (tabBtn instanceof HTMLButtonElement) {
|
|
369
|
+
const tab = tabBtn.dataset.tab;
|
|
370
|
+
if (tab) setActiveTab(tab);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const toggleBtn = target.closest(".request-toggle");
|
|
375
|
+
if (!(toggleBtn instanceof HTMLButtonElement)) return;
|
|
376
|
+
|
|
377
|
+
const row = toggleBtn.closest("tr.request-row");
|
|
378
|
+
if (!(row instanceof HTMLTableRowElement)) return;
|
|
379
|
+
|
|
380
|
+
const requestId = row.getAttribute("data-request-id");
|
|
381
|
+
if (!requestId) return;
|
|
382
|
+
|
|
383
|
+
const detailRow = container.querySelector(`tr.request-detail[data-request-id="${requestId}"]`);
|
|
384
|
+
if (!(detailRow instanceof HTMLTableRowElement)) return;
|
|
385
|
+
|
|
386
|
+
const nextOpen = detailRow.hidden;
|
|
387
|
+
detailRow.hidden = !nextOpen;
|
|
388
|
+
toggleBtn.setAttribute("aria-expanded", String(nextOpen));
|
|
389
|
+
toggleBtn.setAttribute("aria-label", nextOpen ? "折叠请求详情" : "展开请求详情");
|
|
390
|
+
toggleBtn.textContent = nextOpen ? "▾" : "▸";
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const initThemeToggle = () => {
|
|
396
|
+
const STORAGE_KEY = "verify-cli.report.theme";
|
|
397
|
+
|
|
398
|
+
const button = document.querySelector(".theme-toggle");
|
|
399
|
+
if (!(button instanceof HTMLButtonElement)) return;
|
|
400
|
+
|
|
401
|
+
const iconEl = button.querySelector(".theme-toggle__icon");
|
|
402
|
+
|
|
403
|
+
const mediaQuery = typeof window.matchMedia === "function" ? window.matchMedia("(prefers-color-scheme: dark)") : null;
|
|
404
|
+
|
|
405
|
+
const getSystemTheme = () => (mediaQuery?.matches ? "dark" : "light");
|
|
406
|
+
|
|
407
|
+
const getThemeMode = () => document.documentElement.getAttribute("data-theme") ?? "auto";
|
|
408
|
+
|
|
409
|
+
const getEffectiveTheme = () => {
|
|
410
|
+
const mode = getThemeMode();
|
|
411
|
+
if (mode === "dark" || mode === "light") return mode;
|
|
412
|
+
return getSystemTheme();
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const updateIcon = () => {
|
|
416
|
+
const effective = getEffectiveTheme();
|
|
417
|
+
const next = effective === "dark" ? "light" : "dark";
|
|
418
|
+
const icon = next === "dark" ? "🌙" : "☀️";
|
|
419
|
+
if (iconEl instanceof HTMLElement) {
|
|
420
|
+
iconEl.textContent = icon;
|
|
421
|
+
} else {
|
|
422
|
+
button.textContent = icon;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const applyStoredTheme = () => {
|
|
427
|
+
try {
|
|
428
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
429
|
+
if (stored === "light" || stored === "dark" || stored === "auto") {
|
|
430
|
+
document.documentElement.setAttribute("data-theme", stored);
|
|
431
|
+
}
|
|
432
|
+
} catch {
|
|
433
|
+
// ignore
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
applyStoredTheme();
|
|
438
|
+
updateIcon();
|
|
439
|
+
|
|
440
|
+
button.addEventListener("click", () => {
|
|
441
|
+
const effective = getEffectiveTheme();
|
|
442
|
+
const next = effective === "dark" ? "light" : "dark";
|
|
443
|
+
document.documentElement.setAttribute("data-theme", next);
|
|
444
|
+
try {
|
|
445
|
+
localStorage.setItem(STORAGE_KEY, next);
|
|
446
|
+
} catch {
|
|
447
|
+
// ignore
|
|
448
|
+
}
|
|
449
|
+
updateIcon();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const handleSystemThemeChange = () => {
|
|
453
|
+
const mode = getThemeMode();
|
|
454
|
+
if (mode !== "dark" && mode !== "light") updateIcon();
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
if (mediaQuery) {
|
|
458
|
+
if (typeof mediaQuery.addEventListener === "function") {
|
|
459
|
+
mediaQuery.addEventListener("change", handleSystemThemeChange);
|
|
460
|
+
} else if (typeof mediaQuery.addListener === "function") {
|
|
461
|
+
mediaQuery.addListener(handleSystemThemeChange);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
initSidebarDrawer();
|
|
467
|
+
initSidebar();
|
|
468
|
+
initEvidenceTabs();
|
|
469
|
+
initThemeToggle();
|
|
470
|
+
});
|