@schalkneethling/toolkit 0.5.1 → 0.6.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/dist/index.mjs.map +1 -1
- package/hooks/auto-approve-safe-commands/hook.mjs +5 -1
- package/hooks/auto-approve-safe-commands/hook.mts +7 -6
- package/hooks/block-dangerous-commands/hook.mjs +3 -3
- package/hooks/block-dangerous-commands/hook.mts +10 -22
- package/package.json +1 -1
- package/skills/css-tokens/SKILL.md +1 -1
- package/skills/css-tokens/references/tokens.css +6 -10
- package/skills/frontend-security/SKILL.md +3 -0
- package/skills/frontend-security/references/csp-configuration.md +68 -51
- package/skills/frontend-security/references/csrf-protection.md +74 -70
- package/skills/frontend-security/references/dom-security.md +36 -29
- package/skills/frontend-security/references/file-upload-security.md +101 -69
- package/skills/frontend-security/references/framework-patterns.md +42 -40
- package/skills/frontend-security/references/input-validation.md +36 -31
- package/skills/frontend-security/references/jwt-security.md +68 -84
- package/skills/frontend-security/references/nodejs-npm-security.md +63 -55
- package/skills/frontend-security/references/xss-prevention.md +38 -36
- package/skills/frontend-testing/SKILL.md +31 -38
- package/skills/frontend-testing/references/accessibility-testing.md +56 -62
- package/skills/frontend-testing/references/aria-snapshots.md +35 -34
- package/skills/frontend-testing/references/locator-strategies.md +37 -40
- package/skills/frontend-testing/references/visual-regression.md +29 -23
- package/skills/more-secure-dependabot-config/SKILL.md +120 -0
- package/skills/more-secure-dependabot-config/references/ecosystem.md +35 -0
- package/skills/npm-publishing-best-practices/SKILL.md +316 -0
- package/skills/semantic-html/SKILL.md +5 -21
- package/skills/semantic-html/references/heading-patterns.md +1 -5
|
@@ -54,33 +54,35 @@ vm.runInThisContext(userInput);
|
|
|
54
54
|
require(userInput);
|
|
55
55
|
|
|
56
56
|
// DANGEROUS - setTimeout/setInterval with strings
|
|
57
|
-
setTimeout(userInput, 1000);
|
|
57
|
+
setTimeout(userInput, 1000); // Executes as code
|
|
58
58
|
|
|
59
59
|
// SAFE - pass functions instead
|
|
60
|
-
setTimeout(() => {
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
/* code */
|
|
62
|
+
}, 1000);
|
|
61
63
|
```
|
|
62
64
|
|
|
63
65
|
### Child Process Injection
|
|
64
66
|
|
|
65
67
|
```javascript
|
|
66
68
|
// DANGEROUS - command injection
|
|
67
|
-
const { exec } = require(
|
|
68
|
-
exec(`ls ${userInput}`);
|
|
69
|
+
const { exec } = require("child_process");
|
|
70
|
+
exec(`ls ${userInput}`); // Shell injection
|
|
69
71
|
|
|
70
72
|
// SAFER - use execFile with arguments array
|
|
71
|
-
const { execFile } = require(
|
|
72
|
-
execFile(
|
|
73
|
+
const { execFile } = require("child_process");
|
|
74
|
+
execFile("ls", [userInput], callback); // Arguments not interpreted by shell
|
|
73
75
|
|
|
74
76
|
// SAFEST - use spawn with shell: false
|
|
75
|
-
const { spawn } = require(
|
|
76
|
-
spawn(
|
|
77
|
+
const { spawn } = require("child_process");
|
|
78
|
+
spawn("ls", [userInput], { shell: false });
|
|
77
79
|
```
|
|
78
80
|
|
|
79
81
|
### File System
|
|
80
82
|
|
|
81
83
|
```javascript
|
|
82
|
-
const path = require(
|
|
83
|
-
const fs = require(
|
|
84
|
+
const path = require("path");
|
|
85
|
+
const fs = require("fs");
|
|
84
86
|
|
|
85
87
|
// DANGEROUS - path traversal
|
|
86
88
|
const filePath = `/uploads/${userInput}`;
|
|
@@ -92,8 +94,8 @@ function safeReadFile(userInput, baseDir) {
|
|
|
92
94
|
const relativePath = path.relative(basePath, safePath);
|
|
93
95
|
|
|
94
96
|
// Verify path is within allowed directory
|
|
95
|
-
if (relativePath.startsWith(
|
|
96
|
-
throw new Error(
|
|
97
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
98
|
+
throw new Error("Invalid file path");
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
return fs.readFileSync(safePath);
|
|
@@ -105,14 +107,14 @@ function safeReadFile(userInput, baseDir) {
|
|
|
105
107
|
### Rate Limiting
|
|
106
108
|
|
|
107
109
|
```javascript
|
|
108
|
-
const rateLimit = require(
|
|
110
|
+
const rateLimit = require("express-rate-limit");
|
|
109
111
|
|
|
110
112
|
const limiter = rateLimit({
|
|
111
113
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
112
114
|
max: 100, // Limit each IP to 100 requests per window
|
|
113
|
-
message:
|
|
115
|
+
message: "Too many requests",
|
|
114
116
|
standardHeaders: true,
|
|
115
|
-
legacyHeaders: false
|
|
117
|
+
legacyHeaders: false,
|
|
116
118
|
});
|
|
117
119
|
|
|
118
120
|
app.use(limiter);
|
|
@@ -121,28 +123,28 @@ app.use(limiter);
|
|
|
121
123
|
const authLimiter = rateLimit({
|
|
122
124
|
windowMs: 60 * 60 * 1000, // 1 hour
|
|
123
125
|
max: 5, // 5 attempts per hour
|
|
124
|
-
message:
|
|
126
|
+
message: "Too many login attempts",
|
|
125
127
|
});
|
|
126
128
|
|
|
127
|
-
app.use(
|
|
129
|
+
app.use("/api/login", authLimiter);
|
|
128
130
|
```
|
|
129
131
|
|
|
130
132
|
### Request Size Limits
|
|
131
133
|
|
|
132
134
|
```javascript
|
|
133
|
-
const express = require(
|
|
135
|
+
const express = require("express");
|
|
134
136
|
const app = express();
|
|
135
137
|
|
|
136
138
|
// Limit JSON body size
|
|
137
|
-
app.use(express.json({ limit:
|
|
139
|
+
app.use(express.json({ limit: "100kb" }));
|
|
138
140
|
|
|
139
141
|
// Limit URL-encoded body
|
|
140
|
-
app.use(express.urlencoded({ extended: true, limit:
|
|
142
|
+
app.use(express.urlencoded({ extended: true, limit: "100kb" }));
|
|
141
143
|
|
|
142
144
|
// Limit file uploads
|
|
143
|
-
const multer = require(
|
|
145
|
+
const multer = require("multer");
|
|
144
146
|
const upload = multer({
|
|
145
|
-
limits: { fileSize: 5 * 1024 * 1024 } // 5MB
|
|
147
|
+
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
|
146
148
|
});
|
|
147
149
|
```
|
|
148
150
|
|
|
@@ -160,27 +162,29 @@ server.headersTimeout = 66000;
|
|
|
160
162
|
## Secure Headers
|
|
161
163
|
|
|
162
164
|
```javascript
|
|
163
|
-
const helmet = require(
|
|
164
|
-
|
|
165
|
-
app.use(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
165
|
+
const helmet = require("helmet");
|
|
166
|
+
|
|
167
|
+
app.use(
|
|
168
|
+
helmet({
|
|
169
|
+
contentSecurityPolicy: {
|
|
170
|
+
directives: {
|
|
171
|
+
defaultSrc: ["'self'"],
|
|
172
|
+
scriptSrc: ["'self'"],
|
|
173
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
174
|
+
imgSrc: ["'self'", "data:", "https:"],
|
|
175
|
+
objectSrc: ["'none'"],
|
|
176
|
+
upgradeInsecureRequests: [],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
hsts: {
|
|
180
|
+
maxAge: 31536000,
|
|
181
|
+
includeSubDomains: true,
|
|
182
|
+
preload: true,
|
|
183
|
+
},
|
|
184
|
+
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
|
|
185
|
+
frameguard: { action: "deny" },
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
184
188
|
```
|
|
185
189
|
|
|
186
190
|
## Error Handling
|
|
@@ -193,19 +197,22 @@ app.use((err, req, res, next) => {
|
|
|
193
197
|
|
|
194
198
|
// Send generic message to client
|
|
195
199
|
res.status(500).json({
|
|
196
|
-
error:
|
|
200
|
+
error: "An unexpected error occurred",
|
|
197
201
|
});
|
|
198
202
|
});
|
|
199
203
|
|
|
200
204
|
// Async error wrapper
|
|
201
|
-
const asyncHandler = fn => (req, res, next) => {
|
|
205
|
+
const asyncHandler = (fn) => (req, res, next) => {
|
|
202
206
|
Promise.resolve(fn(req, res, next)).catch(next);
|
|
203
207
|
};
|
|
204
208
|
|
|
205
|
-
app.get(
|
|
206
|
-
|
|
207
|
-
res
|
|
208
|
-
|
|
209
|
+
app.get(
|
|
210
|
+
"/data",
|
|
211
|
+
asyncHandler(async (req, res) => {
|
|
212
|
+
const data = await fetchData();
|
|
213
|
+
res.json(data);
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
209
216
|
```
|
|
210
217
|
|
|
211
218
|
## Environment Variables
|
|
@@ -216,8 +223,8 @@ app.get('/data', asyncHandler(async (req, res) => {
|
|
|
216
223
|
const apiKey = process.env.API_KEY;
|
|
217
224
|
|
|
218
225
|
// Validate required env vars at startup
|
|
219
|
-
const required = [
|
|
220
|
-
required.forEach(varName => {
|
|
226
|
+
const required = ["API_KEY", "DB_URL", "SESSION_SECRET"];
|
|
227
|
+
required.forEach((varName) => {
|
|
221
228
|
if (!process.env[varName]) {
|
|
222
229
|
console.error(`Missing required env var: ${varName}`);
|
|
223
230
|
process.exit(1);
|
|
@@ -230,16 +237,16 @@ required.forEach(varName => {
|
|
|
230
237
|
```javascript
|
|
231
238
|
// DANGEROUS - evil regex (catastrophic backtracking)
|
|
232
239
|
const evilRegex = /^(a+)+$/;
|
|
233
|
-
evilRegex.test(
|
|
240
|
+
evilRegex.test("aaaaaaaaaaaaaaaaaaaaaaaaaaa!"); // Hangs
|
|
234
241
|
|
|
235
242
|
// Heuristic only: safe-regex can have false positives/negatives for ReDoS
|
|
236
|
-
const safe = require(
|
|
243
|
+
const safe = require("safe-regex");
|
|
237
244
|
if (!safe(userProvidedRegex)) {
|
|
238
|
-
throw new Error(
|
|
245
|
+
throw new Error("Unsafe regex pattern");
|
|
239
246
|
}
|
|
240
247
|
|
|
241
248
|
// Preferred for untrusted patterns: use RE2 for guaranteed linear time
|
|
242
|
-
const RE2 = require(
|
|
249
|
+
const RE2 = require("re2");
|
|
243
250
|
const pattern = new RE2(userProvidedRegex);
|
|
244
251
|
```
|
|
245
252
|
|
|
@@ -257,5 +264,6 @@ const pattern = new RE2(userProvidedRegex);
|
|
|
257
264
|
- [ ] Use `npm-shrinkwrap.json` for published packages
|
|
258
265
|
|
|
259
266
|
OWASP References:
|
|
267
|
+
|
|
260
268
|
- https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html
|
|
261
269
|
- https://cheatsheetseries.owasp.org/cheatsheets/NPM_Security_Cheat_Sheet.html
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
Apply context-appropriate encoding for all untrusted data:
|
|
6
6
|
|
|
7
|
-
| Context
|
|
8
|
-
|
|
9
|
-
| HTML Body
|
|
10
|
-
| HTML Attribute | Attribute Encoding
|
|
11
|
-
| JavaScript
|
|
12
|
-
| CSS
|
|
13
|
-
| URL Parameter
|
|
7
|
+
| Context | Encoding Method | Example |
|
|
8
|
+
| -------------- | -------------------- | --------------------- |
|
|
9
|
+
| HTML Body | HTML Entity Encoding | `<script>` |
|
|
10
|
+
| HTML Attribute | Attribute Encoding | `"onclick"` |
|
|
11
|
+
| JavaScript | JavaScript Encoding | `\x3cscript\x3e` |
|
|
12
|
+
| CSS | CSS Encoding | `\3c script\3e` |
|
|
13
|
+
| URL Parameter | URL Encoding | `%3Cscript%3E` |
|
|
14
14
|
|
|
15
15
|
## Safe vs Unsafe Sinks
|
|
16
16
|
|
|
@@ -18,37 +18,37 @@ Apply context-appropriate encoding for all untrusted data:
|
|
|
18
18
|
|
|
19
19
|
```javascript
|
|
20
20
|
// Execution sinks - NEVER use with user input
|
|
21
|
-
element.innerHTML = userInput;
|
|
22
|
-
element.outerHTML = userInput;
|
|
23
|
-
document.write(userInput);
|
|
24
|
-
document.writeln(userInput);
|
|
21
|
+
element.innerHTML = userInput; // XSS
|
|
22
|
+
element.outerHTML = userInput; // XSS
|
|
23
|
+
document.write(userInput); // XSS
|
|
24
|
+
document.writeln(userInput); // XSS
|
|
25
25
|
|
|
26
26
|
// JavaScript execution sinks
|
|
27
|
-
eval(userInput);
|
|
28
|
-
new Function(userInput);
|
|
29
|
-
setTimeout(userInput, time);
|
|
30
|
-
setInterval(userInput, time);
|
|
27
|
+
eval(userInput); // XSS
|
|
28
|
+
new Function(userInput); // XSS
|
|
29
|
+
setTimeout(userInput, time); // XSS if string
|
|
30
|
+
setInterval(userInput, time); // XSS if string
|
|
31
31
|
|
|
32
32
|
// URL sinks
|
|
33
|
-
location.href = userInput;
|
|
34
|
-
location.assign(userInput);
|
|
35
|
-
location.replace(userInput);
|
|
36
|
-
window.open(userInput);
|
|
33
|
+
location.href = userInput; // XSS
|
|
34
|
+
location.assign(userInput); // XSS
|
|
35
|
+
location.replace(userInput); // XSS
|
|
36
|
+
window.open(userInput); // XSS
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
### Safe Alternatives
|
|
40
40
|
|
|
41
41
|
```javascript
|
|
42
42
|
// Safe text insertion
|
|
43
|
-
element.textContent = userInput;
|
|
44
|
-
element.innerText = userInput;
|
|
43
|
+
element.textContent = userInput; // Safe
|
|
44
|
+
element.innerText = userInput; // Safe
|
|
45
45
|
|
|
46
46
|
// Safe attribute setting (for safe attributes)
|
|
47
|
-
element.setAttribute(
|
|
47
|
+
element.setAttribute("title", userInput); // Safe for non-event attributes
|
|
48
48
|
|
|
49
49
|
// Safe URL handling
|
|
50
50
|
const url = new URL(userInput, window.location.origin);
|
|
51
|
-
if (url.protocol ===
|
|
51
|
+
if (url.protocol === "https:") {
|
|
52
52
|
location.href = url.href;
|
|
53
53
|
}
|
|
54
54
|
```
|
|
@@ -59,19 +59,19 @@ When HTML must be rendered, use sanitization:
|
|
|
59
59
|
|
|
60
60
|
```javascript
|
|
61
61
|
// Using DOMPurify (recommended)
|
|
62
|
-
import DOMPurify from
|
|
62
|
+
import DOMPurify from "dompurify";
|
|
63
63
|
element.innerHTML = DOMPurify.sanitize(userInput);
|
|
64
64
|
|
|
65
65
|
// With configuration
|
|
66
66
|
const clean = DOMPurify.sanitize(dirty, {
|
|
67
|
-
ALLOWED_TAGS: [
|
|
68
|
-
ALLOWED_ATTR: [
|
|
67
|
+
ALLOWED_TAGS: ["b", "i", "em", "strong", "a"],
|
|
68
|
+
ALLOWED_ATTR: ["href", "title"],
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
// Browser Sanitizer API (when available)
|
|
72
72
|
const sanitizer = new Sanitizer({
|
|
73
|
-
allowElements: [
|
|
74
|
-
allowAttributes: {
|
|
73
|
+
allowElements: ["b", "i", "em", "strong", "a"],
|
|
74
|
+
allowAttributes: { href: ["a"] },
|
|
75
75
|
});
|
|
76
76
|
element.setHTML(userInput, { sanitizer });
|
|
77
77
|
```
|
|
@@ -131,7 +131,7 @@ const safeUrl = userInput.startsWith('https://') ? userInput : '#';
|
|
|
131
131
|
function isValidUrl(input) {
|
|
132
132
|
try {
|
|
133
133
|
const url = new URL(input);
|
|
134
|
-
return [
|
|
134
|
+
return ["http:", "https:"].includes(url.protocol);
|
|
135
135
|
} catch {
|
|
136
136
|
return false;
|
|
137
137
|
}
|
|
@@ -139,12 +139,14 @@ function isValidUrl(input) {
|
|
|
139
139
|
|
|
140
140
|
// Prevent javascript: URLs
|
|
141
141
|
function sanitizeHref(input) {
|
|
142
|
-
if (!input) return
|
|
142
|
+
if (!input) return "#";
|
|
143
143
|
const trimmed = input.trim().toLowerCase();
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
if (
|
|
145
|
+
trimmed.startsWith("javascript:") ||
|
|
146
|
+
trimmed.startsWith("data:") ||
|
|
147
|
+
trimmed.startsWith("vbscript:")
|
|
148
|
+
) {
|
|
149
|
+
return "#";
|
|
148
150
|
}
|
|
149
151
|
return input;
|
|
150
152
|
}
|
|
@@ -156,8 +158,8 @@ Always set appropriate Content-Type headers:
|
|
|
156
158
|
|
|
157
159
|
```javascript
|
|
158
160
|
// Express.js
|
|
159
|
-
res.setHeader(
|
|
160
|
-
res.setHeader(
|
|
161
|
+
res.setHeader("Content-Type", "application/json");
|
|
162
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
161
163
|
```
|
|
162
164
|
|
|
163
165
|
OWASP Reference: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
|
|
@@ -16,7 +16,7 @@ This principle guides testing decisions, but isn't the whole picture:
|
|
|
16
16
|
- **Acceptance criteria tests** verify the system does what users/stakeholders need. These should be stable across refactors.
|
|
17
17
|
- **Implementation tests** verify the pieces are robust — edge cases, error handling, complex logic. These may change when you refactor.
|
|
18
18
|
|
|
19
|
-
Both have value. The anti-pattern to avoid is tests that
|
|
19
|
+
Both have value. The anti-pattern to avoid is tests that _only_ mirror implementation without validating meaningful behavior.
|
|
20
20
|
|
|
21
21
|
## When to Load References
|
|
22
22
|
|
|
@@ -36,6 +36,7 @@ Load reference files based on test type:
|
|
|
36
36
|
Before writing any test, identify what the code should do from the user's perspective.
|
|
37
37
|
|
|
38
38
|
Ask for or extract criteria from:
|
|
39
|
+
|
|
39
40
|
- Ticket description or user story
|
|
40
41
|
- Figma annotations
|
|
41
42
|
- Functional requirements
|
|
@@ -48,11 +49,13 @@ Document criteria as a checklist. These become your first tests.
|
|
|
48
49
|
### Step 2: Map Criteria to Test Cases
|
|
49
50
|
|
|
50
51
|
For each criterion, identify:
|
|
52
|
+
|
|
51
53
|
- **Happy path**: Normal expected behavior
|
|
52
54
|
- **Edge cases**: Boundary conditions
|
|
53
55
|
- **Error cases**: Invalid inputs, failures
|
|
54
56
|
|
|
55
57
|
Example mapping:
|
|
58
|
+
|
|
56
59
|
```text
|
|
57
60
|
Criterion: "User can filter products by category"
|
|
58
61
|
├─ Happy path: Select category, products filter correctly
|
|
@@ -85,14 +88,14 @@ The distinction: acceptance tests should rarely change on refactor; implementati
|
|
|
85
88
|
|
|
86
89
|
### Step 4: Choose Test Type
|
|
87
90
|
|
|
88
|
-
| Scenario
|
|
89
|
-
|
|
90
|
-
| Pure logic (no DOM)
|
|
91
|
-
| Component behavior
|
|
92
|
-
| User flows, real browser
|
|
93
|
-
| Semantic structure validation | ARIA snapshot
|
|
94
|
-
| Visual appearance
|
|
95
|
-
| Accessibility compliance
|
|
91
|
+
| Scenario | Test Type | Tool |
|
|
92
|
+
| ----------------------------- | ------------------ | -------------------------------- |
|
|
93
|
+
| Pure logic (no DOM) | Unit test | Vitest |
|
|
94
|
+
| Component behavior | Unit test with DOM | Vitest + Testing Library |
|
|
95
|
+
| User flows, real browser | E2E test | Playwright |
|
|
96
|
+
| Semantic structure validation | ARIA snapshot | Playwright `toMatchAriaSnapshot` |
|
|
97
|
+
| Visual appearance | VRT | Playwright screenshots |
|
|
98
|
+
| Accessibility compliance | A11y test | Playwright + axe-core |
|
|
96
99
|
|
|
97
100
|
**ARIA snapshots** are particularly valuable for E2E tests. A single snapshot can replace multiple individual assertions while validating the accessibility tree structure.
|
|
98
101
|
|
|
@@ -114,10 +117,10 @@ describe("calculateDiscount", () => {
|
|
|
114
117
|
it("applies 20% discount to order total", () => {
|
|
115
118
|
// Arrange - Set up test data matching criterion
|
|
116
119
|
const order = { total: 100, membership: "premium" };
|
|
117
|
-
|
|
120
|
+
|
|
118
121
|
// Act - Call the function
|
|
119
122
|
const result = calculateDiscount(order);
|
|
120
|
-
|
|
123
|
+
|
|
121
124
|
// Assert - Verify expected outcome from requirements
|
|
122
125
|
expect(result).toBe(80);
|
|
123
126
|
});
|
|
@@ -136,24 +139,14 @@ describe("LoginForm", () => {
|
|
|
136
139
|
it("displays error message to user", async () => {
|
|
137
140
|
const user = userEvent.setup();
|
|
138
141
|
render(<LoginForm />);
|
|
139
|
-
|
|
142
|
+
|
|
140
143
|
// Interact using accessible queries
|
|
141
|
-
await user.type(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
await user.type(
|
|
146
|
-
screen.getByLabelText(/password/i),
|
|
147
|
-
"wrong"
|
|
148
|
-
);
|
|
149
|
-
await user.click(
|
|
150
|
-
screen.getByRole("button", { name: /sign in/i })
|
|
151
|
-
);
|
|
152
|
-
|
|
144
|
+
await user.type(screen.getByLabelText(/email/i), "invalid@test.com");
|
|
145
|
+
await user.type(screen.getByLabelText(/password/i), "wrong");
|
|
146
|
+
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
|
147
|
+
|
|
153
148
|
// Assert on user-visible outcome
|
|
154
|
-
expect(
|
|
155
|
-
await screen.findByRole("alert")
|
|
156
|
-
).toHaveTextContent(/invalid credentials/i);
|
|
149
|
+
expect(await screen.findByRole("alert")).toHaveTextContent(/invalid credentials/i);
|
|
157
150
|
});
|
|
158
151
|
});
|
|
159
152
|
});
|
|
@@ -168,10 +161,10 @@ test.describe("Product Catalog", () => {
|
|
|
168
161
|
test.describe("filtering by category", () => {
|
|
169
162
|
test("shows only matching products", async ({ page }) => {
|
|
170
163
|
await page.goto("/products");
|
|
171
|
-
|
|
164
|
+
|
|
172
165
|
// Use semantic locators
|
|
173
166
|
await page.getByRole("combobox", { name: /category/i }).selectOption("Electronics");
|
|
174
|
-
|
|
167
|
+
|
|
175
168
|
// Assert count, then spot-check first/last
|
|
176
169
|
const products = page.getByRole("article");
|
|
177
170
|
await expect(products).toHaveCount(5);
|
|
@@ -188,14 +181,12 @@ When you need to verify all items, use `Promise.all` for parallel assertions:
|
|
|
188
181
|
test("all products match filter", async ({ page }) => {
|
|
189
182
|
await page.goto("/products");
|
|
190
183
|
await page.getByRole("combobox", { name: /category/i }).selectOption("Electronics");
|
|
191
|
-
|
|
184
|
+
|
|
192
185
|
const products = await page.getByRole("article").all();
|
|
193
|
-
|
|
186
|
+
|
|
194
187
|
// Parallel assertions — faster than sequential await in a loop
|
|
195
188
|
await Promise.all(
|
|
196
|
-
products.map(product =>
|
|
197
|
-
expect(product.getByText(/electronics/i)).toBeVisible()
|
|
198
|
-
)
|
|
189
|
+
products.map((product) => expect(product.getByText(/electronics/i)).toBeVisible()),
|
|
199
190
|
);
|
|
200
191
|
});
|
|
201
192
|
```
|
|
@@ -208,7 +199,7 @@ ARIA snapshots consolidate multiple assertions into one, validating semantic str
|
|
|
208
199
|
test.describe("Login Page", () => {
|
|
209
200
|
test("has correct form structure", async ({ page }) => {
|
|
210
201
|
await page.goto("/login");
|
|
211
|
-
|
|
202
|
+
|
|
212
203
|
// One snapshot replaces 5+ individual assertions
|
|
213
204
|
await expect(page.getByRole("main")).toMatchAriaSnapshot(`
|
|
214
205
|
- heading "Sign In" [level=1]
|
|
@@ -218,11 +209,11 @@ test.describe("Login Page", () => {
|
|
|
218
209
|
- link "Forgot password?"
|
|
219
210
|
`);
|
|
220
211
|
});
|
|
221
|
-
|
|
212
|
+
|
|
222
213
|
test("shows validation errors on empty submit", async ({ page }) => {
|
|
223
214
|
await page.goto("/login");
|
|
224
215
|
await page.getByRole("button", { name: /sign in/i }).click();
|
|
225
|
-
|
|
216
|
+
|
|
226
217
|
await expect(page.getByRole("form")).toMatchAriaSnapshot(`
|
|
227
218
|
- textbox "Email"
|
|
228
219
|
- text "Email is required"
|
|
@@ -279,7 +270,7 @@ it("returns validation errors for malformed email", () => {
|
|
|
279
270
|
});
|
|
280
271
|
```
|
|
281
272
|
|
|
282
|
-
The key distinction: implementation tests should verify
|
|
273
|
+
The key distinction: implementation tests should verify _meaningful_ behavior of units, not just that code paths execute.
|
|
283
274
|
|
|
284
275
|
### Circular Validation
|
|
285
276
|
|
|
@@ -324,11 +315,13 @@ page.getByRole("button", { name: /submit order/i });
|
|
|
324
315
|
When a test fails, the investigation depends on the test type:
|
|
325
316
|
|
|
326
317
|
**Acceptance test fails:**
|
|
318
|
+
|
|
327
319
|
1. Check the code under test first — likely a real bug
|
|
328
320
|
2. Verify the test still matches current requirements — requirements may have changed
|
|
329
321
|
3. Only update the test after confirming the new behavior is correct
|
|
330
322
|
|
|
331
323
|
**Implementation test fails:**
|
|
324
|
+
|
|
332
325
|
1. If you're refactoring, the test may legitimately need updating
|
|
333
326
|
2. If you're not refactoring, check the code — likely a bug
|
|
334
327
|
3. Consider whether the test is too tightly coupled to implementation
|