@qaguardian/playwright-score 0.1.0 → 0.1.9
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 +43 -7
- package/dist/cli.js +23 -4
- package/dist/eslint-runner.d.ts +2 -0
- package/dist/eslint-runner.js +109 -8
- package/dist/formatters/markdown.js +10 -0
- package/dist/formatters/sarif.js +1 -1
- package/dist/formatters/text.js +10 -0
- package/dist/fs-util.d.ts +6 -0
- package/dist/fs-util.js +22 -0
- package/dist/index.js +164 -31
- package/dist/metrics.d.ts +28 -7
- package/dist/metrics.js +232 -45
- package/dist/profiles.js +7 -18
- package/dist/rules/base-plugin.d.ts +14 -0
- package/dist/rules/base-plugin.js +77 -0
- package/dist/rules/guardian-plugin.js +145 -44
- package/dist/types.d.ts +9 -0
- package/package.json +1 -1
|
@@ -13,6 +13,76 @@ const DUMMY_SECRETS = new Set([
|
|
|
13
13
|
'qwerty',
|
|
14
14
|
'',
|
|
15
15
|
].map((s) => s.toLowerCase()));
|
|
16
|
+
// Playwright's actual modifier API is test.fixme() — not test.fix().
|
|
17
|
+
const TEST_MODIFIERS = new Set(['only', 'skip', 'fixme', 'slow']);
|
|
18
|
+
const DESCRIBE_MODIFIERS = new Set(['serial', 'parallel', 'only', 'fixme']);
|
|
19
|
+
/** Matches a bare test(...) call. */
|
|
20
|
+
function isBareTestCall(node) {
|
|
21
|
+
return node.callee?.type === 'Identifier' && node.callee.name === 'test';
|
|
22
|
+
}
|
|
23
|
+
/** Matches test.describe(...) and test.describe.serial/parallel/only/fixme(...). */
|
|
24
|
+
function isDescribeCall(node) {
|
|
25
|
+
const callee = node.callee;
|
|
26
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed)
|
|
27
|
+
return false;
|
|
28
|
+
const obj = callee.object;
|
|
29
|
+
const prop = callee.property;
|
|
30
|
+
if (!obj || !prop || prop.type !== 'Identifier')
|
|
31
|
+
return false;
|
|
32
|
+
// test.describe(...)
|
|
33
|
+
if (obj.type === 'Identifier' && obj.name === 'test' && prop.name === 'describe') {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
// test.describe.serial/parallel/only/fixme(...)
|
|
37
|
+
if (obj.type === 'MemberExpression' &&
|
|
38
|
+
!obj.computed &&
|
|
39
|
+
obj.object?.type === 'Identifier' &&
|
|
40
|
+
obj.object.name === 'test' &&
|
|
41
|
+
obj.property?.type === 'Identifier' &&
|
|
42
|
+
obj.property.name === 'describe' &&
|
|
43
|
+
DESCRIBE_MODIFIERS.has(prop.name ?? '')) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
/** Matches specifically test.describe.serial(...). */
|
|
49
|
+
function isSerialDescribeCall(node) {
|
|
50
|
+
const callee = node.callee;
|
|
51
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed)
|
|
52
|
+
return false;
|
|
53
|
+
const obj = callee.object;
|
|
54
|
+
const prop = callee.property;
|
|
55
|
+
return (obj?.type === 'MemberExpression' &&
|
|
56
|
+
!obj.computed &&
|
|
57
|
+
obj.object?.type === 'Identifier' &&
|
|
58
|
+
obj.object.name === 'test' &&
|
|
59
|
+
obj.property?.type === 'Identifier' &&
|
|
60
|
+
obj.property.name === 'describe' &&
|
|
61
|
+
prop?.type === 'Identifier' &&
|
|
62
|
+
prop.name === 'serial');
|
|
63
|
+
}
|
|
64
|
+
/** Matches test.only/skip/fixme/slow(...) (a modifier form of a real test declaration). */
|
|
65
|
+
function isTestModifierCall(node) {
|
|
66
|
+
const callee = node.callee;
|
|
67
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed)
|
|
68
|
+
return false;
|
|
69
|
+
const obj = callee.object;
|
|
70
|
+
const prop = callee.property;
|
|
71
|
+
return (obj?.type === 'Identifier' &&
|
|
72
|
+
obj.name === 'test' &&
|
|
73
|
+
prop?.type === 'Identifier' &&
|
|
74
|
+
TEST_MODIFIERS.has(prop.name ?? ''));
|
|
75
|
+
}
|
|
76
|
+
/** Matches test.step(...). */
|
|
77
|
+
function isTestStepCall(node) {
|
|
78
|
+
const callee = node.callee;
|
|
79
|
+
return (callee?.type === 'MemberExpression' &&
|
|
80
|
+
!callee.computed &&
|
|
81
|
+
callee.object?.type === 'Identifier' &&
|
|
82
|
+
callee.object.name === 'test' &&
|
|
83
|
+
callee.property?.type === 'Identifier' &&
|
|
84
|
+
callee.property.name === 'step');
|
|
85
|
+
}
|
|
16
86
|
function hasDisableNextLine(sourceCode, line, ruleId) {
|
|
17
87
|
const lines = sourceCode.getText().split(/\r?\n/);
|
|
18
88
|
if (line < 2)
|
|
@@ -69,6 +139,42 @@ const noDateNowId = {
|
|
|
69
139
|
};
|
|
70
140
|
},
|
|
71
141
|
};
|
|
142
|
+
// Whole words only (after splitting camelCase/snake_case/kebab-case), not a
|
|
143
|
+
// bare substring match — a substring match on "auth" flags "authorName",
|
|
144
|
+
// "isAuthenticated", every "Author"/"authenticate"/"authorize" field in a
|
|
145
|
+
// typical app under test, which is a lot of unrelated real-world test data.
|
|
146
|
+
const SENSITIVE_WORDS = new Set([
|
|
147
|
+
'password',
|
|
148
|
+
'passwd',
|
|
149
|
+
'secret',
|
|
150
|
+
'secrets',
|
|
151
|
+
'token',
|
|
152
|
+
'tokens',
|
|
153
|
+
'auth',
|
|
154
|
+
'authorization',
|
|
155
|
+
'credential',
|
|
156
|
+
'credentials',
|
|
157
|
+
]);
|
|
158
|
+
function splitIdentifierWords(identifier) {
|
|
159
|
+
return identifier
|
|
160
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
161
|
+
.replace(/[_-]+/g, ' ')
|
|
162
|
+
.split(/\s+/)
|
|
163
|
+
.filter(Boolean)
|
|
164
|
+
.map((w) => w.toLowerCase());
|
|
165
|
+
}
|
|
166
|
+
function looksLikeSensitiveKey(rawKey) {
|
|
167
|
+
const words = splitIdentifierWords(rawKey);
|
|
168
|
+
if (words.some((w) => SENSITIVE_WORDS.has(w)))
|
|
169
|
+
return true;
|
|
170
|
+
// apiKey / api_key / API-KEY — "key" alone is too generic (primaryKey,
|
|
171
|
+
// sortKey, ...) so only flag it directly adjacent to "api".
|
|
172
|
+
for (let i = 0; i < words.length - 1; i++) {
|
|
173
|
+
if (words[i] === 'api' && words[i + 1] === 'key')
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
72
178
|
const noHardcodedSecrets = {
|
|
73
179
|
meta: {
|
|
74
180
|
type: 'suggestion',
|
|
@@ -80,7 +186,6 @@ const noHardcodedSecrets = {
|
|
|
80
186
|
},
|
|
81
187
|
create(context) {
|
|
82
188
|
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
83
|
-
const keyRe = /password|passwd|secret|token|api[_-]?key|auth/i;
|
|
84
189
|
function check(node, rawKey) {
|
|
85
190
|
if (node.type !== 'Literal' || typeof node.value !== 'string')
|
|
86
191
|
return;
|
|
@@ -89,7 +194,7 @@ const noHardcodedSecrets = {
|
|
|
89
194
|
return;
|
|
90
195
|
if (value.length < 6)
|
|
91
196
|
return;
|
|
92
|
-
if (!
|
|
197
|
+
if (!looksLikeSensitiveKey(rawKey ?? ''))
|
|
93
198
|
return;
|
|
94
199
|
const line = node.loc?.start.line ?? 1;
|
|
95
200
|
if (hasDisableNextLine(sourceCode, line, 'guardian/no-hardcoded-secrets'))
|
|
@@ -126,13 +231,26 @@ const noGenericLongTimeout = {
|
|
|
126
231
|
const allowed = new Set([2000, 20000]);
|
|
127
232
|
return {
|
|
128
233
|
Property(node) {
|
|
129
|
-
if (node.key.type
|
|
130
|
-
node.key.name
|
|
131
|
-
node.value.type
|
|
132
|
-
typeof node.value.value
|
|
133
|
-
|
|
134
|
-
|
|
234
|
+
if (node.key.type !== 'Identifier' ||
|
|
235
|
+
node.key.name !== 'timeout' ||
|
|
236
|
+
node.value.type !== 'Literal' ||
|
|
237
|
+
typeof node.value.value !== 'number' ||
|
|
238
|
+
allowed.has(node.value.value)) {
|
|
239
|
+
return;
|
|
135
240
|
}
|
|
241
|
+
// Only flag timeout options passed inline to a call, e.g.
|
|
242
|
+
// locator.click({ timeout }), expect(x).toBeVisible({ timeout }),
|
|
243
|
+
// test('x', { timeout }, fn) — not unrelated business data that
|
|
244
|
+
// happens to have a field named "timeout" (session config, a
|
|
245
|
+
// fixture payload, a mocked API response body, ...).
|
|
246
|
+
const objectExpr = node.parent;
|
|
247
|
+
const call = objectExpr?.parent;
|
|
248
|
+
if (objectExpr?.type !== 'ObjectExpression' ||
|
|
249
|
+
call?.type !== 'CallExpression' ||
|
|
250
|
+
!call.arguments?.includes(objectExpr)) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
context.report({ node, messageId: 'timeout' });
|
|
136
254
|
},
|
|
137
255
|
};
|
|
138
256
|
},
|
|
@@ -150,27 +268,16 @@ const oneDescribeOneTest = {
|
|
|
150
268
|
create(context) {
|
|
151
269
|
let describes = 0;
|
|
152
270
|
let tests = 0;
|
|
271
|
+
let usesSerialDescribe = false;
|
|
153
272
|
return {
|
|
154
273
|
CallExpression(node) {
|
|
155
|
-
if (node
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
node.callee.property.name === 'describe') {
|
|
160
|
-
describes++;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (node.callee.type === 'Identifier' && node.callee.name === 'test') {
|
|
164
|
-
tests++;
|
|
274
|
+
if (isDescribeCall(node)) {
|
|
275
|
+
describes++;
|
|
276
|
+
if (isSerialDescribeCall(node))
|
|
277
|
+
usesSerialDescribe = true;
|
|
165
278
|
}
|
|
166
|
-
if (node
|
|
167
|
-
!node.callee.computed &&
|
|
168
|
-
node.callee.object.type === 'Identifier' &&
|
|
169
|
-
node.callee.object.name === 'test' &&
|
|
170
|
-
node.callee.property.type === 'Identifier' &&
|
|
171
|
-
['only', 'skip', 'fix', 'slow'].includes(node.callee.property.name)) {
|
|
279
|
+
if (isBareTestCall(node) || isTestModifierCall(node))
|
|
172
280
|
tests++;
|
|
173
|
-
}
|
|
174
281
|
},
|
|
175
282
|
'Program:exit'() {
|
|
176
283
|
if (describes > 1) {
|
|
@@ -180,7 +287,12 @@ const oneDescribeOneTest = {
|
|
|
180
287
|
data: { count: String(describes) },
|
|
181
288
|
});
|
|
182
289
|
}
|
|
183
|
-
|
|
290
|
+
// test.describe.serial(...) is Playwright's documented mechanism
|
|
291
|
+
// for one user journey split into multiple ordered, dependent
|
|
292
|
+
// test() calls — the same "one scenario per file" intent as this
|
|
293
|
+
// rule, expressed a different way. Don't nag a pattern the
|
|
294
|
+
// framework itself recommends for exactly this case.
|
|
295
|
+
if (tests > 1 && !usesSerialDescribe) {
|
|
184
296
|
context.report({
|
|
185
297
|
loc: { line: 1, column: 0 },
|
|
186
298
|
messageId: 'tests',
|
|
@@ -205,20 +317,10 @@ const requireTestStep = {
|
|
|
205
317
|
let hasStep = false;
|
|
206
318
|
return {
|
|
207
319
|
CallExpression(node) {
|
|
208
|
-
if (node
|
|
320
|
+
if (isBareTestCall(node) || isTestModifierCall(node))
|
|
209
321
|
hasTest = true;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
!node.callee.computed &&
|
|
213
|
-
node.callee.object.type === 'Identifier' &&
|
|
214
|
-
node.callee.object.name === 'test' &&
|
|
215
|
-
node.callee.property.type === 'Identifier') {
|
|
216
|
-
if (['only', 'skip', 'fix', 'slow'].includes(node.callee.property.name)) {
|
|
217
|
-
hasTest = true;
|
|
218
|
-
}
|
|
219
|
-
if (node.callee.property.name === 'step')
|
|
220
|
-
hasStep = true;
|
|
221
|
-
}
|
|
322
|
+
if (isTestStepCall(node))
|
|
323
|
+
hasStep = true;
|
|
222
324
|
},
|
|
223
325
|
'Program:exit'() {
|
|
224
326
|
if (hasTest && !hasStep) {
|
|
@@ -242,14 +344,13 @@ const requireExpect = {
|
|
|
242
344
|
let hasExpect = false;
|
|
243
345
|
return {
|
|
244
346
|
CallExpression(node) {
|
|
245
|
-
if (node
|
|
347
|
+
if (isBareTestCall(node) || isTestModifierCall(node))
|
|
246
348
|
hasTest = true;
|
|
247
|
-
|
|
248
|
-
if (node.callee.type === 'Identifier' && node.callee.name === 'expect') {
|
|
349
|
+
if (node.callee?.type === 'Identifier' && node.callee.name === 'expect') {
|
|
249
350
|
hasExpect = true;
|
|
250
351
|
}
|
|
251
|
-
if (node.callee
|
|
252
|
-
node.callee.object
|
|
352
|
+
if (node.callee?.type === 'MemberExpression' &&
|
|
353
|
+
node.callee.object?.type === 'Identifier' &&
|
|
253
354
|
node.callee.object.name === 'expect') {
|
|
254
355
|
hasExpect = true;
|
|
255
356
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -41,6 +41,15 @@ export interface ScoreResult {
|
|
|
41
41
|
summary: ScoreSummary;
|
|
42
42
|
dimensions: ScoreDimensions;
|
|
43
43
|
findings: Finding[];
|
|
44
|
+
/**
|
|
45
|
+
* Files matched by a directory/glob input but skipped because they show
|
|
46
|
+
* positive evidence of being a non-Playwright test (an import from
|
|
47
|
+
* vitest/jest/mocha/jasmine/@testing-library, or React Testing Library's
|
|
48
|
+
* screen.getBy/toBeInTheDocument() APIs). Repo-relative paths. Files passed
|
|
49
|
+
* explicitly by the caller are always scored and never appear here, even
|
|
50
|
+
* if they'd otherwise be skipped.
|
|
51
|
+
*/
|
|
52
|
+
skippedFiles?: string[];
|
|
44
53
|
}
|
|
45
54
|
export interface ScoreOptions {
|
|
46
55
|
paths: string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qaguardian/playwright-score",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Deterministic, AI-free Playwright spec quality score (sqs-v1). Lint + score with standard and guardian profiles.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|