@captain_z/zsk-skills 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/README.md +263 -0
- package/bundles.yaml +104 -0
- package/design-handoff/.gitkeep +0 -0
- package/design-handoff/figma-to-code/SKILL.md +265 -0
- package/design-handoff/ue-mcp/SKILL.md +184 -0
- package/frontend/.gitkeep +0 -0
- package/frontend/a11y-web/SKILL.md +169 -0
- package/frontend/api-contract-ts/SKILL.md +275 -0
- package/frontend/css-bem/SKILL.md +268 -0
- package/frontend/design-frontend/SKILL.md +163 -0
- package/frontend/dor-dod-frontend/SKILL.md +114 -0
- package/frontend/feature-tasks-frontend/SKILL.md +246 -0
- package/frontend/i18n/SKILL.md +296 -0
- package/frontend/nfr-web/SKILL.md +258 -0
- package/frontend/performance-web/SKILL.md +299 -0
- package/frontend/react-components/SKILL.md +211 -0
- package/frontend/react-naming/SKILL.md +224 -0
- package/frontend/review-frontend/SKILL.md +126 -0
- package/frontend/security-web/SKILL.md +286 -0
- package/frontend/spec-frontend/SKILL.md +141 -0
- package/frontend/testing-web/SKILL.md +252 -0
- package/frontend/typescript/SKILL.md +219 -0
- package/meta/.gitkeep +0 -0
- package/meta/philosophy/SKILL.md +221 -0
- package/package.json +24 -0
- package/quality/.gitkeep +0 -0
- package/quality/a11y-principles/SKILL.md +155 -0
- package/quality/code-hygiene/SKILL.md +126 -0
- package/quality/release/SKILL.md +209 -0
- package/quality/security-owasp/SKILL.md +157 -0
- package/quality/testing-pyramid/SKILL.md +173 -0
- package/sdlc/.gitkeep +0 -0
- package/sdlc/archive/SKILL.md +267 -0
- package/sdlc/bugfix/SKILL.md +181 -0
- package/sdlc/bugfix-tasks/SKILL.md +232 -0
- package/sdlc/coding/SKILL.md +177 -0
- package/sdlc/design/SKILL.md +299 -0
- package/sdlc/dor-dod/SKILL.md +120 -0
- package/sdlc/feature/SKILL.md +162 -0
- package/sdlc/proposal/SKILL.md +271 -0
- package/sdlc/refactor/SKILL.md +220 -0
- package/sdlc/refactor-tasks/SKILL.md +265 -0
- package/sdlc/reviewing/SKILL.md +197 -0
- package/sdlc/spec/SKILL.md +235 -0
- package/sdlc/task/SKILL.md +116 -0
- package/sdlc/task-evidence/SKILL.md +178 -0
- package/sdlc/task-structure/SKILL.md +153 -0
- package/sdlc/task-tracking/SKILL.md +192 -0
- package/sdlc/verify/SKILL.md +181 -0
- package/system/.gitkeep +0 -0
- package/system/adr/SKILL.md +169 -0
- package/system/architecture/SKILL.md +182 -0
- package/system/glossary/SKILL.md +141 -0
- package/system/nfr-baseline/SKILL.md +156 -0
- package/system/project-constraints-template/SKILL.md +241 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: zsk:security-web
|
|
3
|
+
description: Web frontend security — XSS (React default escape +
|
|
4
|
+
dangerouslySetInnerHTML red line + DOMPurify exception), URL protocol
|
|
5
|
+
whitelist, Storage red lines (no sensitive data in Local/SessionStorage), CSRF
|
|
6
|
+
token integration, console log scrubbing for production, CSP meta/header
|
|
7
|
+
consumption, SRI for third-party scripts, clickjacking (frame-ancestors
|
|
8
|
+
awareness), postMessage origin checks. Frontend UI is the convenience defense;
|
|
9
|
+
backend is final.
|
|
10
|
+
category: standard
|
|
11
|
+
domain: frontend
|
|
12
|
+
tier: optional
|
|
13
|
+
related:
|
|
14
|
+
- ../i18n/SKILL.md
|
|
15
|
+
- ../../quality/security-owasp/SKILL.md
|
|
16
|
+
triggers:
|
|
17
|
+
- dangerouslySetInnerHTML
|
|
18
|
+
- XSS React
|
|
19
|
+
- DOMPurify
|
|
20
|
+
- LocalStorage sensitive
|
|
21
|
+
- CSRF token
|
|
22
|
+
- console production
|
|
23
|
+
- CSP frontend
|
|
24
|
+
- SRI
|
|
25
|
+
- postMessage origin
|
|
26
|
+
- clickjacking frontend
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# Security Web · Web 前端安全红线
|
|
30
|
+
|
|
31
|
+
> **范围**:React / Web **前端专属**安全实现 — XSS / URL / Storage / CSRF / console / CSP / SRI / postMessage
|
|
32
|
+
> **原则层**(OWASP Top 10、CVE 响应、供应链)→ [`quality/security-owasp`](../../quality/security-owasp/SKILL.md)
|
|
33
|
+
> **核心认知**:**前端是便利性防线,后端是最终防线**。前端不能替代后端鉴权,但必须防止自身成为攻击入口。
|
|
34
|
+
|
|
35
|
+
## 1. XSS 防护
|
|
36
|
+
|
|
37
|
+
### React 默认已转义
|
|
38
|
+
|
|
39
|
+
JSX 插值自动 HTML 转义,**正常使用不会产生 XSS**:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
<div>{userInput}</div> // 安全:自动转义
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 红线:`dangerouslySetInnerHTML`
|
|
46
|
+
|
|
47
|
+
**默认禁用**。需要时必须:
|
|
48
|
+
|
|
49
|
+
1. **ADR 记录**:为什么不能用普通 JSX
|
|
50
|
+
2. **内容白名单化**:`DOMPurify.sanitize(html)`
|
|
51
|
+
3. **内容源可信**:来自受信后端(经过校验)而非用户输入
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import DOMPurify from 'dompurify';
|
|
55
|
+
|
|
56
|
+
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(richText) }} />
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 其他 XSS 入口
|
|
60
|
+
|
|
61
|
+
- ❌ `element.innerHTML = userInput`
|
|
62
|
+
- ❌ `document.write(userInput)`
|
|
63
|
+
- ❌ `eval(...)` / `new Function(...)`(项目 lint 必须禁用 `no-eval` / `no-new-func`)
|
|
64
|
+
|
|
65
|
+
### URL / 跳转协议白名单
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
// ❌ 不校验
|
|
69
|
+
<a href={userUrl}>link</a>
|
|
70
|
+
|
|
71
|
+
// ✅ 校验
|
|
72
|
+
function isSafeUrl(url: string): boolean {
|
|
73
|
+
try {
|
|
74
|
+
const u = new URL(url, window.location.origin);
|
|
75
|
+
return ['http:', 'https:', 'mailto:'].includes(u.protocol);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
<a href={isSafeUrl(userUrl) ? userUrl : '#'}>link</a>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**禁止 `javascript:` 协议**。`data:` URL 仅限预设的 MIME(如图片 base64)。
|
|
85
|
+
|
|
86
|
+
### `window.location = userInput` 必须白名单
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const ALLOWED_HOSTS = ['example.com', 'cdn.example.com'];
|
|
90
|
+
function safeRedirect(url: string) {
|
|
91
|
+
const u = new URL(url, window.location.origin);
|
|
92
|
+
if (ALLOWED_HOSTS.includes(u.host)) window.location.href = u.toString();
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## 2. Storage 红线
|
|
97
|
+
|
|
98
|
+
### 禁止写入的字段
|
|
99
|
+
|
|
100
|
+
以下**绝对不进** `localStorage` / `sessionStorage`:
|
|
101
|
+
|
|
102
|
+
- ❌ 密码 / PIN / 2FA 密钥
|
|
103
|
+
- ❌ Access Token / Refresh Token(除非业务明确评估过 + ADR;**优先用 HttpOnly Cookie**)
|
|
104
|
+
- ❌ 身份证号 / 银行卡号 / 手机号(明文)
|
|
105
|
+
- ❌ 用户私密内容(IM 消息明文 / 草稿敏感内容)
|
|
106
|
+
- ❌ 后端返回的 SSO session ID
|
|
107
|
+
|
|
108
|
+
### 原因
|
|
109
|
+
|
|
110
|
+
- Storage 可被任何同源脚本读取(含被注入的第三方脚本 / XSS payload)
|
|
111
|
+
- 浏览器跨 tab 共享,存活周期长
|
|
112
|
+
- 无 `HttpOnly` 保护
|
|
113
|
+
|
|
114
|
+
### 合规用法
|
|
115
|
+
|
|
116
|
+
- UI 偏好(主题 / 语言 / 侧栏折叠)
|
|
117
|
+
- 非敏感草稿(若需加密存储,用 `IndexedDB` + Web Crypto)
|
|
118
|
+
- **最多缓存 ID**(`userId` 级别),详情数据走后端
|
|
119
|
+
|
|
120
|
+
### 离开登录态清理
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// 登出时
|
|
124
|
+
localStorage.removeItem('app:user-pref');
|
|
125
|
+
sessionStorage.clear();
|
|
126
|
+
// Cookie 由后端 Set-Cookie Max-Age=0 清理
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## 3. CSRF 防护
|
|
130
|
+
|
|
131
|
+
### 前端侧职责
|
|
132
|
+
|
|
133
|
+
- 所有修改类请求(POST / PUT / DELETE / PATCH)自动注入 CSRF Token
|
|
134
|
+
- 建议统一封装在 `{{config.paths.services_entry}}`,业务代码不手写
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// services/request.ts
|
|
138
|
+
request.interceptors.request.use((config) => {
|
|
139
|
+
if (['post', 'put', 'delete', 'patch'].includes(config.method ?? '')) {
|
|
140
|
+
config.headers['X-CSRF-Token'] = getCsrfToken(); // 从 Cookie / meta / 接口获取
|
|
141
|
+
}
|
|
142
|
+
return config;
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Cookie 配合
|
|
147
|
+
|
|
148
|
+
后端 Set-Cookie 时:`SameSite=Lax` 或 `Strict` + `Secure` + `HttpOnly`(登录态 Cookie)。
|
|
149
|
+
|
|
150
|
+
前端不能写 `HttpOnly`,只能要求后端配置到位。
|
|
151
|
+
|
|
152
|
+
## 4. console 脱敏
|
|
153
|
+
|
|
154
|
+
### 生产环境红线
|
|
155
|
+
|
|
156
|
+
- ❌ `console.log`(lint 禁用 `no-console`,允许 `warn` / `error`)
|
|
157
|
+
- ❌ 在 warn/error 中打印明文:密码 / Token / PII
|
|
158
|
+
|
|
159
|
+
### 脱敏工具
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// utils/scrub.ts
|
|
163
|
+
const SENSITIVE_KEYS = /^(password|token|pwd|secret|authorization|cookie|session)/i;
|
|
164
|
+
export function scrub(obj: unknown): unknown {
|
|
165
|
+
if (typeof obj !== 'object' || !obj) return obj;
|
|
166
|
+
return Object.fromEntries(
|
|
167
|
+
Object.entries(obj).map(([k, v]) =>
|
|
168
|
+
SENSITIVE_KEYS.test(k) ? [k, '***'] : [k, scrub(v)]
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 使用
|
|
174
|
+
console.error('Request failed', scrub(payload));
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### 错误上报同样规则
|
|
178
|
+
|
|
179
|
+
Sentry / 自研上报前必须经脱敏过滤器;`breadcrumbs` 里不能有原始请求体。
|
|
180
|
+
|
|
181
|
+
## 5. CSP(Content Security Policy)
|
|
182
|
+
|
|
183
|
+
CSP 主要由**后端 header** 或 `<meta>` 设置。前端责任:不写违反 CSP 的代码(内联 `<script>` / `<style>` / `eval` / 内联 `style="..."` 属性,除非 CSP 允许 nonce / hash);第三方脚本(分析 / 地图 / 客服)需加入白名单并在 PR 描述声明域名。
|
|
184
|
+
|
|
185
|
+
**推荐严格 CSP**:`default-src 'self'`;`script-src 'self' 'nonce-{random}'`;`style-src 'self' 'nonce-{random}'`;`img-src 'self' data: https:`;`connect-src 'self' https://api.example.com`;`frame-ancestors 'none'`;`object-src 'none'`;`base-uri 'self'`。
|
|
186
|
+
|
|
187
|
+
## 6. SRI(Subresource Integrity)
|
|
188
|
+
|
|
189
|
+
第三方 CDN 脚本 / 样式必须带 SRI:
|
|
190
|
+
|
|
191
|
+
```html
|
|
192
|
+
<script
|
|
193
|
+
src="https://cdn.example.com/lib.js"
|
|
194
|
+
integrity="sha384-abc..."
|
|
195
|
+
crossorigin="anonymous"
|
|
196
|
+
></script>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
构建工具链(Vite / Webpack)自带生成能力(`vite-plugin-sri` / `webpack-subresource-integrity`)。
|
|
200
|
+
|
|
201
|
+
## 7. 点击劫持(前端可感知的部分)
|
|
202
|
+
|
|
203
|
+
**主要防护在后端 header**:`X-Frame-Options: DENY` 或 CSP `frame-ancestors 'none'`。
|
|
204
|
+
|
|
205
|
+
前端可做:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// 顶层脚本(如果被 iframe 嵌入则跳出)
|
|
209
|
+
if (window.top !== window.self) {
|
|
210
|
+
window.top!.location = window.self.location;
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
但不如 header 可靠。**主方案是后端 header**,前端这招是兜底。
|
|
215
|
+
|
|
216
|
+
## 8. postMessage 跨域通信
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
// ❌ 接收方不校验来源
|
|
220
|
+
window.addEventListener('message', (e) => {
|
|
221
|
+
handlePayload(e.data); // 任何源都能发
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ✅ 校验 origin
|
|
225
|
+
window.addEventListener('message', (e) => {
|
|
226
|
+
if (e.origin !== 'https://trusted.example.com') return;
|
|
227
|
+
if (typeof e.data !== 'object' || e.data === null) return;
|
|
228
|
+
handlePayload(e.data);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// 发送方
|
|
232
|
+
target.postMessage(payload, 'https://trusted.example.com'); // 不用 '*'
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## 9. 表单安全
|
|
236
|
+
|
|
237
|
+
- **`autocomplete` 属性正确设置**:密码用 `new-password` / `current-password`,信用卡用 `cc-number` 等
|
|
238
|
+
- **不在 URL 查询参数传敏感信息**(会进浏览器历史 / 日志)
|
|
239
|
+
- **文件上传**:前端只做便利校验(`accept` 属性 / MIME 快检),**MIME / 大小 / 病毒扫描必须后端**
|
|
240
|
+
|
|
241
|
+
## 10. 第三方依赖安全
|
|
242
|
+
|
|
243
|
+
- Lint `no-restricted-imports` 禁用已知不安全库(项目 SYSTEM-SPEC 配置)
|
|
244
|
+
- 引入前 `npm view {pkg}` 看维护 / 下载量 / 最近发布;**锁版本**(lock file 必须提交)
|
|
245
|
+
- CI `npm audit --audit-level=high`;高危 CVE **24h 内响应**(升级 / 绕过 / 豁免 + ADR)。CVE 响应 SLA 见 [`quality/security-owasp`](../../quality/security-owasp/SKILL.md)
|
|
246
|
+
|
|
247
|
+
## 11. 生产构建红线
|
|
248
|
+
|
|
249
|
+
| 项 | 规则 |
|
|
250
|
+
| --- | --- |
|
|
251
|
+
| Source Map | 不上传到公网(仅上报平台私有) |
|
|
252
|
+
| 调试工具 | React DevTools / Redux DevTools 仅 DEV 启用 |
|
|
253
|
+
| 日志 | 生产无 `console.log` |
|
|
254
|
+
| 内联脚本 | 无(除 CSP 允许的 nonce/hash) |
|
|
255
|
+
| 环境变量 | 前端 `VITE_*` / `NEXT_PUBLIC_*` 只能放**非敏感**值 |
|
|
256
|
+
|
|
257
|
+
### 环境变量误区
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
❌ VITE_API_SECRET=xxx # 前端 env 会进 bundle,等于公开
|
|
261
|
+
✅ 敏感值只放后端 env
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## 反模式(禁止)
|
|
265
|
+
|
|
266
|
+
- ❌ `dangerouslySetInnerHTML` 无 DOMPurify / 无 ADR
|
|
267
|
+
- ❌ Token / 密码进 Local/SessionStorage(除非 ADR 评估)
|
|
268
|
+
- ❌ `href={userUrl}` / `window.location = userInput` 不校验协议
|
|
269
|
+
- ❌ 修改类请求不注入 CSRF Token
|
|
270
|
+
- ❌ 生产 `console.log` / 明文打印敏感字段
|
|
271
|
+
- ❌ `postMessage` 不校验 origin
|
|
272
|
+
- ❌ 把 API Secret 放前端 env
|
|
273
|
+
- ❌ 第三方 CDN 脚本无 SRI
|
|
274
|
+
|
|
275
|
+
## 质量门禁
|
|
276
|
+
|
|
277
|
+
- [ ] 无新增 `dangerouslySetInnerHTML`(或经 ADR + DOMPurify)
|
|
278
|
+
- [ ] 无敏感字段进 Storage
|
|
279
|
+
- [ ] URL / 跳转有协议白名单
|
|
280
|
+
- [ ] CSRF Token 在 service 层统一注入
|
|
281
|
+
- [ ] 生产 `console.log` 零出现
|
|
282
|
+
- [ ] 日志 / 上报经脱敏
|
|
283
|
+
- [ ] 第三方脚本带 SRI
|
|
284
|
+
- [ ] `postMessage` 校验 origin
|
|
285
|
+
- [ ] `npm audit` 无 high / critical
|
|
286
|
+
- [ ] 环境变量无敏感值暴露前端
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: zsk:spec-frontend
|
|
3
|
+
description: Frontend component/page spec.md authoring — adds Component Public
|
|
4
|
+
Contract (Props / Events / TS types), UE state matrix with a11y attributes,
|
|
5
|
+
UE/MCP reference, and the 6-category Frontend NFR (performance / a11y /
|
|
6
|
+
security / i18n / compat / observability). Extends the generic zsk:spec
|
|
7
|
+
framework.
|
|
8
|
+
category: stage
|
|
9
|
+
domain: frontend
|
|
10
|
+
tier: optional
|
|
11
|
+
stage: 2
|
|
12
|
+
variants:
|
|
13
|
+
- feature
|
|
14
|
+
- bugfix
|
|
15
|
+
- refactor
|
|
16
|
+
related:
|
|
17
|
+
- ../../sdlc/spec/SKILL.md
|
|
18
|
+
- ../../design-handoff/ue-mcp/SKILL.md
|
|
19
|
+
- ../design-frontend/SKILL.md
|
|
20
|
+
- ../a11y-web/SKILL.md
|
|
21
|
+
- ../i18n/SKILL.md
|
|
22
|
+
- ../nfr-web/SKILL.md
|
|
23
|
+
- ../api-contract-ts/SKILL.md
|
|
24
|
+
triggers:
|
|
25
|
+
- frontend spec
|
|
26
|
+
- Component Public Contract
|
|
27
|
+
- Props Event Contract
|
|
28
|
+
- UE state matrix
|
|
29
|
+
- frontend NFR six categories
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
# Stage 2: Spec · Frontend 补丁
|
|
33
|
+
|
|
34
|
+
> **用途**:当模块是**前端组件 / 页面**时,在 [`zsk:spec`](../../sdlc/spec/SKILL.md) 通用骨架上追加的条款
|
|
35
|
+
> **范围**:Component Public Contract、UE 状态矩阵、UE/MCP 引用、Frontend NFR 六类
|
|
36
|
+
|
|
37
|
+
## 追加章节(相对 `zsk:spec` 的通用 Feature 模板)
|
|
38
|
+
|
|
39
|
+
在通用骨架之上追加下面这些章节,最终 `docs/{module}/spec.md` 同时包含两部分。
|
|
40
|
+
|
|
41
|
+
### A. Component Public Contract
|
|
42
|
+
|
|
43
|
+
#### A.1 Props
|
|
44
|
+
|
|
45
|
+
- Props 名称、TS 类型、是否必填、默认值
|
|
46
|
+
- 受控 / 非受控边界(value + onChange / defaultValue)
|
|
47
|
+
- 明确不支持的输入
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
export interface {Module}Props {
|
|
51
|
+
data: {Module}Node[];
|
|
52
|
+
selectedId?: string;
|
|
53
|
+
onSelect?: (id: string, node: {Module}Node) => void;
|
|
54
|
+
// ...
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**禁止**把"Props 以后看实现再补"留到 Design 或 Coding。
|
|
59
|
+
|
|
60
|
+
#### A.2 Event / Output
|
|
61
|
+
|
|
62
|
+
- 事件回调签名(参数类型、返回值、副作用)
|
|
63
|
+
- 命名 `on` 前缀(见 [`zsk:react-naming`](../react-naming/SKILL.md))
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
export interface {Module}ChangeValue {
|
|
67
|
+
// ...
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
#### A.3 受控 / 非受控声明
|
|
72
|
+
|
|
73
|
+
明确组件属于:
|
|
74
|
+
- **受控**(外部 value + onChange)
|
|
75
|
+
- **非受控**(内部 state + defaultValue)
|
|
76
|
+
- **混合**(必须有 `value === undefined ? 内部 : 外部` 兜底)
|
|
77
|
+
|
|
78
|
+
### B. UI / UE Contract(状态矩阵)
|
|
79
|
+
|
|
80
|
+
至少覆盖适用项:`loading / empty / error / hover / focus / focus-visible / selected / disabled`
|
|
81
|
+
组件特有:`search-hit` / `search-empty` / `lazy-loading` 等
|
|
82
|
+
|
|
83
|
+
矩阵列:
|
|
84
|
+
|
|
85
|
+
| 状态 | 触发条件 | 用户可见表现 | 允许操作 | a11y 属性 |
|
|
86
|
+
| --- | --- | --- | --- | --- |
|
|
87
|
+
|
|
88
|
+
> a11y 属性参考 [`quality/a11y-principles`](../../quality/a11y-principles/SKILL.md) 的 aria 表
|
|
89
|
+
|
|
90
|
+
### C. UE / MCP Context
|
|
91
|
+
|
|
92
|
+
- Figma 输入 → 引用 `docs/{module}/ue-mcp.md`([`zsk:ue-mcp`](../../design-handoff/ue-mcp/SKILL.md)),本文件只摘要 URL + Node ID
|
|
93
|
+
- 本节不重复 Figma 细节
|
|
94
|
+
|
|
95
|
+
### D. API Contract(若涉及后端)
|
|
96
|
+
|
|
97
|
+
- 引用 `docs/{module}/api-contract.md`([`zsk:api-contract-ts`](../api-contract-ts/SKILL.md))
|
|
98
|
+
- 本文件只列用到的 endpoint 与类型名
|
|
99
|
+
- **类型定义不在 spec 写**,由后端生成
|
|
100
|
+
|
|
101
|
+
### E. Frontend NFR 六类
|
|
102
|
+
|
|
103
|
+
继承 [`system/nfr-baseline`](../../system/nfr-baseline/SKILL.md)(7 类框架)+ [`frontend/nfr-web`](../nfr-web/SKILL.md)(Web 具体阈值),此处只列偏离项:
|
|
104
|
+
|
|
105
|
+
| 编号 | 类别 | 要求 | 验证方式 |
|
|
106
|
+
| --- | --- | --- | --- |
|
|
107
|
+
| NFR-1 | 性能 | 首屏 < 500ms;交互响应 < 100ms | Lighthouse |
|
|
108
|
+
| NFR-2 | 可访问性 | WCAG 2.1 AA;键盘导航;aria 完备 | axe |
|
|
109
|
+
| NFR-3 | 安全 | 鉴权、越权校验、敏感字段脱敏 | 手工 + 自动扫描 |
|
|
110
|
+
| NFR-4 | 国际化 | 三语 key 完备、文案长度弹性、RTL 声明 | 三语 diff |
|
|
111
|
+
| NFR-5 | 兼容性 | 浏览器矩阵、响应式断点、最小分辨率 | 浏览器矩阵截图 |
|
|
112
|
+
| NFR-6 | 可观测性 | 错误上报、关键交互埋点 | 埋点样本 |
|
|
113
|
+
|
|
114
|
+
## Feature 型 frontend spec 完整结构(组合通用 + 本文件)
|
|
115
|
+
|
|
116
|
+
1. 模块映射与事实源 _(zsk:spec)_
|
|
117
|
+
2. 术语表 _(zsk:spec)_
|
|
118
|
+
3. Why _(zsk:spec)_
|
|
119
|
+
4. 用户故事 _(zsk:spec)_
|
|
120
|
+
5. 功能需求 FR _(zsk:spec)_
|
|
121
|
+
6. 非功能需求 NFR(**本文件 E 节 · 六类**)
|
|
122
|
+
7. **Component Public Contract**(本文件 A 节)
|
|
123
|
+
8. **UI / UE Contract 状态矩阵**(本文件 B 节)
|
|
124
|
+
9. **UE / MCP Context**(本文件 C 节,引用 `ue-mcp.md`)
|
|
125
|
+
10. **API Contract**(本文件 D 节,引用 `api-contract.md`)
|
|
126
|
+
11. 数据模型 _(zsk:spec)_
|
|
127
|
+
12. 行为场景 BDD _(zsk:spec)_
|
|
128
|
+
13. Acceptance Criteria _(zsk:spec)_
|
|
129
|
+
14. Edge Cases & Error Handling _(zsk:spec)_
|
|
130
|
+
15. Impact _(zsk:spec)_
|
|
131
|
+
|
|
132
|
+
## 质量门禁(前端补丁)
|
|
133
|
+
|
|
134
|
+
- [ ] Component Public Contract 完整(Props / Event / 受控边界)
|
|
135
|
+
- [ ] UE 状态矩阵已填(含 a11y 属性列)
|
|
136
|
+
- [ ] 有 Figma 输入的模块已关联 `ue-mcp.md`
|
|
137
|
+
- [ ] 有后端接口的模块已关联 `api-contract.md`
|
|
138
|
+
- [ ] Frontend NFR 六类全覆盖(继承或偏离明确)
|
|
139
|
+
- [ ] 禁止"Props 以后看实现再补"
|
|
140
|
+
|
|
141
|
+
> 通用 spec 门禁(编号 / INVEST / BDD)见 [`zsk:spec`](../../sdlc/spec/SKILL.md)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: zsk:testing-web
|
|
3
|
+
description: Frontend tests for React codebases — full toolchain (Jest/Vitest +
|
|
4
|
+
React Testing Library + renderHook + user-event + MSW + Playwright + Chromatic
|
|
5
|
+
+ jest-axe), test structure (AAA/GWT), mock strategy (MSW for HTTP, manual for
|
|
6
|
+
modules), coverage thresholds (new code / global), and the component test
|
|
7
|
+
template. Implementation layer; cross-stack principles live in
|
|
8
|
+
quality/testing-pyramid.
|
|
9
|
+
category: standard
|
|
10
|
+
domain: frontend
|
|
11
|
+
tier: optional
|
|
12
|
+
related:
|
|
13
|
+
- ../react-components/SKILL.md
|
|
14
|
+
- ../a11y-web/SKILL.md
|
|
15
|
+
- ../api-contract-ts/SKILL.md
|
|
16
|
+
- ../../quality/testing-pyramid/SKILL.md
|
|
17
|
+
- ../../sdlc/verify/SKILL.md
|
|
18
|
+
triggers:
|
|
19
|
+
- Jest Vitest
|
|
20
|
+
- React Testing Library
|
|
21
|
+
- MSW mock service worker
|
|
22
|
+
- Playwright
|
|
23
|
+
- Chromatic visual regression
|
|
24
|
+
- jest-axe
|
|
25
|
+
- user-event
|
|
26
|
+
- renderHook
|
|
27
|
+
- coverage threshold
|
|
28
|
+
- component test template
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
# Testing Web · 前端测试工具链
|
|
32
|
+
|
|
33
|
+
> **范围**:React / Web 测试工具链**实现层** — Jest/Vitest / RTL / MSW / Playwright / Chromatic / axe
|
|
34
|
+
> **原则层**(金字塔比例 / AAA / 证据先于断言 / TDD / Bug 置信度)→ [`quality/testing-pyramid`](../../quality/testing-pyramid/SKILL.md)
|
|
35
|
+
> **前置**:本文件假设你已读过原则层
|
|
36
|
+
|
|
37
|
+
## 1. 工具栈总表
|
|
38
|
+
|
|
39
|
+
| 层级 | 工具 | 职责 |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| 测试运行器 | **Jest** 或 **Vitest**(项目选一) | 单元 + 集成 |
|
|
42
|
+
| 组件渲染 | **`@testing-library/react`** | DOM query + render |
|
|
43
|
+
| 用户交互 | **`@testing-library/user-event` v14** | 点击 / 键盘 / 输入(不要用 `fireEvent`) |
|
|
44
|
+
| Hook 测试 | **`renderHook`**(RTL 14+ 已整合) | 独立测 hook |
|
|
45
|
+
| HTTP Mock | **MSW**(Mock Service Worker) | 拦截 network 层,不拦截模块 |
|
|
46
|
+
| E2E | **Playwright** | 真浏览器 / 跨浏览器 / trace |
|
|
47
|
+
| 视觉回归 | **Chromatic**(Storybook)/ Playwright `toHaveScreenshot` | 截图 diff |
|
|
48
|
+
| a11y 自动化 | **`jest-axe`** / `@axe-core/playwright` | axe-core 引擎 |
|
|
49
|
+
| lint a11y | **`eslint-plugin-jsx-a11y`** | 静态规则 |
|
|
50
|
+
|
|
51
|
+
## 2. 覆盖率目标
|
|
52
|
+
|
|
53
|
+
| 维度 | 阈值 |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| 全量语句覆盖 | ≥ `{{config.quality.testing.unit_coverage_min}}`%(通常 70%) |
|
|
56
|
+
| 新增 / 修改代码 | ≥ `{{config.quality.testing.new_code_coverage}}`%(通常 80%) |
|
|
57
|
+
| 分支覆盖 | ≥ 70% |
|
|
58
|
+
| Bugfix 回归测试 | **必须包含能失败的复现测试**(硬门禁) |
|
|
59
|
+
|
|
60
|
+
**覆盖率不是目的**,AC 到测试的映射才是。覆盖率是最低地板。
|
|
61
|
+
|
|
62
|
+
## 3. Jest / Vitest 基线配置
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// vitest.config.ts
|
|
66
|
+
export default defineConfig({
|
|
67
|
+
test: {
|
|
68
|
+
environment: 'jsdom',
|
|
69
|
+
setupFiles: ['./test/setup.ts'],
|
|
70
|
+
coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'],
|
|
71
|
+
thresholds: { lines: 70, branches: 70, functions: 70, statements: 70 } },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// test/setup.ts — 注入 jest-dom + axe + cleanup
|
|
78
|
+
import '@testing-library/jest-dom/vitest';
|
|
79
|
+
import { expect, afterEach } from 'vitest';
|
|
80
|
+
import { cleanup } from '@testing-library/react';
|
|
81
|
+
import { toHaveNoViolations } from 'jest-axe';
|
|
82
|
+
expect.extend({ toHaveNoViolations });
|
|
83
|
+
afterEach(() => cleanup());
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 4. 组件测试模板
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { render, screen } from '@testing-library/react';
|
|
90
|
+
import userEvent from '@testing-library/user-event';
|
|
91
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
92
|
+
|
|
93
|
+
describe('UserCard', () => {
|
|
94
|
+
const mockUser = { id: '1', name: 'Alice', email: 'a@x.com' };
|
|
95
|
+
|
|
96
|
+
it('renders user name and email', () => {
|
|
97
|
+
render(<UserCard user={mockUser} />);
|
|
98
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
99
|
+
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('calls onSelect when clicked', async () => {
|
|
103
|
+
const onSelect = vi.fn();
|
|
104
|
+
const user = userEvent.setup();
|
|
105
|
+
render(<UserCard user={mockUser} onSelect={onSelect} />);
|
|
106
|
+
await user.click(screen.getByRole('button', { name: /alice/i }));
|
|
107
|
+
expect(onSelect).toHaveBeenCalledWith('1');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**query 优先级**(RTL):`getByRole` > `getByLabelText` > `getByPlaceholderText` > `getByText` > `getByDisplayValue` > `getByAltText` / `getByTitle` > `getByTestId`(兜底)。
|
|
113
|
+
|
|
114
|
+
**user-event vs fireEvent**:默认 `user-event`(模拟真实 focus → mousedown → click 链路);`fireEvent` 仅用于触发特定低级事件(`scroll` 等)。
|
|
115
|
+
|
|
116
|
+
## 5. Hook 测试
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
import { renderHook, act } from '@testing-library/react';
|
|
120
|
+
|
|
121
|
+
it('increments counter', () => {
|
|
122
|
+
const { result } = renderHook(() => useCounter(0));
|
|
123
|
+
act(() => result.current.increment());
|
|
124
|
+
expect(result.current.count).toBe(1);
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
需 Provider 时,`renderHook(() => useX(), { wrapper })` 传 Context Provider 包裹。
|
|
129
|
+
|
|
130
|
+
## 6. MSW (Mock Service Worker)
|
|
131
|
+
|
|
132
|
+
**默认 HTTP mock 方案**,network 层拦截,比 mock 模块更接近真实:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// test/handlers.ts
|
|
136
|
+
import { http, HttpResponse } from 'msw';
|
|
137
|
+
export const handlers = [
|
|
138
|
+
http.get('/api/users', () => HttpResponse.json({ items: [], total: 0 })),
|
|
139
|
+
http.post('/api/users', async ({ request }) => {
|
|
140
|
+
const body = await request.json();
|
|
141
|
+
return HttpResponse.json({ id: '1', ...body }, { status: 201 });
|
|
142
|
+
}),
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// test/setup.ts
|
|
146
|
+
import { setupServer } from 'msw/node';
|
|
147
|
+
export const server = setupServer(...handlers);
|
|
148
|
+
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
|
149
|
+
afterEach(() => server.resetHandlers());
|
|
150
|
+
afterAll(() => server.close());
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
每 test 覆盖 handler:`server.use(http.get('/api/users', () => HttpResponse.json({ msg: 'boom' }, { status: 500 })))`。
|
|
154
|
+
|
|
155
|
+
**禁止手写** `vi.mock('axios' / 'fetch')`(漂移风险 + 难维护)。
|
|
156
|
+
|
|
157
|
+
## 7. 集成测试(组件 + service + 状态)
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
it('loads and displays users', async () => {
|
|
161
|
+
render(
|
|
162
|
+
<QueryClientProvider client={queryClient}>
|
|
163
|
+
<UserList />
|
|
164
|
+
</QueryClientProvider>
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
|
168
|
+
expect(await screen.findByText('Alice')).toBeInTheDocument();
|
|
169
|
+
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## 8. a11y 自动化
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
import { axe } from 'jest-axe';
|
|
177
|
+
|
|
178
|
+
it('is accessible', async () => {
|
|
179
|
+
const { container } = render(<UserCard user={mockUser} />);
|
|
180
|
+
const results = await axe(container);
|
|
181
|
+
expect(results).toHaveNoViolations();
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**硬门禁**:每个组件测试文件至少一条 axe 断言。配合 [`a11y-web`](../a11y-web/SKILL.md) 的运行时扫描。
|
|
186
|
+
|
|
187
|
+
## 9. E2E(Playwright)
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// playwright.config.ts
|
|
191
|
+
export default defineConfig({
|
|
192
|
+
testDir: './e2e',
|
|
193
|
+
use: { baseURL: 'http://localhost:5173', trace: 'retain-on-failure',
|
|
194
|
+
video: 'retain-on-failure', screenshot: 'only-on-failure' },
|
|
195
|
+
projects: [
|
|
196
|
+
{ name: 'chromium', use: devices['Desktop Chrome'] },
|
|
197
|
+
{ name: 'webkit', use: devices['Desktop Safari'] },
|
|
198
|
+
{ name: 'mobile', use: devices['iPhone 13'] },
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('user can log in', async ({ page }) => {
|
|
203
|
+
await page.goto('/login');
|
|
204
|
+
await page.getByLabel('邮箱').fill('user@x.com');
|
|
205
|
+
await page.getByLabel('密码').fill('password');
|
|
206
|
+
await page.getByRole('button', { name: /登录/ }).click();
|
|
207
|
+
await expect(page).toHaveURL('/dashboard');
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**a11y 扫描**:`AxeBuilder({ page }).analyze()` 过滤 `critical` / `serious` 后 `toEqual([])`。
|
|
212
|
+
|
|
213
|
+
## 10. 视觉回归
|
|
214
|
+
|
|
215
|
+
**方案一**:Chromatic(Storybook)— 每组件写 stories(各状态),Chromatic 自动截图 + diff。CI 脚本 `chromatic --exit-zero-on-changes`。
|
|
216
|
+
|
|
217
|
+
**方案二**:Playwright `toHaveScreenshot('name.png', { maxDiffPixelRatio: 0.01 })`。
|
|
218
|
+
|
|
219
|
+
**纪律**:Bugfix / Refactor 必须视觉回归零差异(见 [`review-frontend`](../review-frontend/SKILL.md) 硬门禁);差异超阈值 → 修复或 ADR 说明保留。
|
|
220
|
+
|
|
221
|
+
## 11. Mock 策略总表
|
|
222
|
+
|
|
223
|
+
| 要 mock 的对象 | 工具 |
|
|
224
|
+
| --- | --- |
|
|
225
|
+
| HTTP 请求 | **MSW**(默认) |
|
|
226
|
+
| 第三方 SDK 的行为 | `vi.mock('...')` + `vi.fn()` |
|
|
227
|
+
| 浏览器 API(IntersectionObserver / matchMedia) | 在 `setup.ts` 提供 polyfill / stub |
|
|
228
|
+
| 时间 | `vi.useFakeTimers()` |
|
|
229
|
+
| `window.location` | `Object.defineProperty(window, 'location', ...)` 或用 `jest-location-mock` |
|
|
230
|
+
| localStorage | 默认 jsdom 已提供;需清理 `beforeEach(() => localStorage.clear())` |
|
|
231
|
+
|
|
232
|
+
## 12. 反模式(禁止)
|
|
233
|
+
|
|
234
|
+
- ❌ 用 `getByTestId` 当默认选择器(优先 `getByRole`)
|
|
235
|
+
- ❌ 用 `fireEvent` 代替 `user-event`
|
|
236
|
+
- ❌ 测试实现细节(检查内部 state、私有函数)
|
|
237
|
+
- ❌ 手写 `vi.mock('axios' / 'fetch')` 而不用 MSW
|
|
238
|
+
- ❌ `beforeEach` 里 setState 让 test 互相依赖(测试必须可独立运行)
|
|
239
|
+
- ❌ "修了 bug 不写回归测试"
|
|
240
|
+
- ❌ E2E 覆盖已在单元测过的逻辑(慢 + 脆)
|
|
241
|
+
|
|
242
|
+
## 13. 质量门禁
|
|
243
|
+
|
|
244
|
+
- [ ] 新增 / 修改代码覆盖率 ≥ 80%
|
|
245
|
+
- [ ] 每个 AC 至少 1 条测试
|
|
246
|
+
- [ ] Bugfix 有能失败的复现测试
|
|
247
|
+
- [ ] 至少 1 条 axe 断言(组件级)
|
|
248
|
+
- [ ] Playwright 关键流程(登录 / 核心 CRUD)
|
|
249
|
+
- [ ] Refactor 视觉回归零差异
|
|
250
|
+
- [ ] MSW 作为 HTTP mock 默认方案
|
|
251
|
+
- [ ] user-event 作为交互默认方案
|
|
252
|
+
- [ ] 通用门禁见 [`quality/testing-pyramid`](../../quality/testing-pyramid/SKILL.md)
|