@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.
@@ -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 (!keyRe.test(rawKey ?? ''))
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 === 'Identifier' &&
130
- node.key.name === 'timeout' &&
131
- node.value.type === 'Literal' &&
132
- typeof node.value.value === 'number' &&
133
- !allowed.has(node.value.value)) {
134
- context.report({ node, messageId: 'timeout' });
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.callee.type === 'MemberExpression' && !node.callee.computed) {
156
- if (node.callee.object.type === 'Identifier' &&
157
- node.callee.object.name === 'test' &&
158
- node.callee.property.type === 'Identifier' &&
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.callee.type === 'MemberExpression' &&
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
- if (tests > 1) {
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.callee.type === 'Identifier' && node.callee.name === 'test') {
320
+ if (isBareTestCall(node) || isTestModifierCall(node))
209
321
  hasTest = true;
210
- }
211
- if (node.callee.type === 'MemberExpression' &&
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.callee.type === 'Identifier' && node.callee.name === 'test') {
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.type === 'MemberExpression' &&
252
- node.callee.object.type === 'Identifier' &&
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.0",
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",