@qaguardian/playwright-score 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/METHODOLOGY.md +178 -0
- package/README.md +109 -0
- package/bin/playwright-score.js +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +113 -0
- package/dist/eslint-runner.d.ts +10 -0
- package/dist/eslint-runner.js +120 -0
- package/dist/formatters/json.d.ts +2 -0
- package/dist/formatters/json.js +3 -0
- package/dist/formatters/markdown.d.ts +2 -0
- package/dist/formatters/markdown.js +26 -0
- package/dist/formatters/sarif.d.ts +3 -0
- package/dist/formatters/sarif.js +55 -0
- package/dist/formatters/text.d.ts +2 -0
- package/dist/formatters/text.js +32 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +95 -0
- package/dist/metrics.d.ts +17 -0
- package/dist/metrics.js +79 -0
- package/dist/profiles.d.ts +15 -0
- package/dist/profiles.js +130 -0
- package/dist/rules/guardian-plugin.d.ts +29 -0
- package/dist/rules/guardian-plugin.js +285 -0
- package/dist/score-engine.d.ts +29 -0
- package/dist/score-engine.js +114 -0
- package/dist/sloc.d.ts +4 -0
- package/dist/sloc.js +38 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
const DUMMY_SECRETS = new Set([
|
|
2
|
+
'password',
|
|
3
|
+
'password123',
|
|
4
|
+
'test',
|
|
5
|
+
'test123',
|
|
6
|
+
'secret',
|
|
7
|
+
'changeme',
|
|
8
|
+
'admin',
|
|
9
|
+
'admin123',
|
|
10
|
+
'pass',
|
|
11
|
+
'pwd',
|
|
12
|
+
'123456',
|
|
13
|
+
'qwerty',
|
|
14
|
+
'',
|
|
15
|
+
].map((s) => s.toLowerCase()));
|
|
16
|
+
function hasDisableNextLine(sourceCode, line, ruleId) {
|
|
17
|
+
const lines = sourceCode.getText().split(/\r?\n/);
|
|
18
|
+
if (line < 2)
|
|
19
|
+
return false;
|
|
20
|
+
const prev = lines[line - 2] ?? '';
|
|
21
|
+
return (prev.includes('spec-score-disable-next-line') &&
|
|
22
|
+
(prev.includes(ruleId) || prev.includes('guardian/')));
|
|
23
|
+
}
|
|
24
|
+
const noWaitForLoadState = {
|
|
25
|
+
meta: {
|
|
26
|
+
type: 'problem',
|
|
27
|
+
docs: { description: 'Disallow page.waitForLoadState()' },
|
|
28
|
+
messages: {
|
|
29
|
+
banned: 'waitForLoadState is banned under guardian profile. Use web-first assertions.',
|
|
30
|
+
},
|
|
31
|
+
schema: [],
|
|
32
|
+
},
|
|
33
|
+
create(context) {
|
|
34
|
+
return {
|
|
35
|
+
CallExpression(node) {
|
|
36
|
+
const callee = node.callee;
|
|
37
|
+
if (callee.type === 'MemberExpression' &&
|
|
38
|
+
!callee.computed &&
|
|
39
|
+
callee.property.type === 'Identifier' &&
|
|
40
|
+
callee.property.name === 'waitForLoadState') {
|
|
41
|
+
context.report({ node, messageId: 'banned' });
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const noDateNowId = {
|
|
48
|
+
meta: {
|
|
49
|
+
type: 'problem',
|
|
50
|
+
docs: { description: 'Disallow Date.now() for unique ids' },
|
|
51
|
+
messages: {
|
|
52
|
+
banned: 'Date.now() is banned for unique ids. Use crypto.randomUUID().split("-")[0].',
|
|
53
|
+
},
|
|
54
|
+
schema: [],
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
return {
|
|
58
|
+
CallExpression(node) {
|
|
59
|
+
const callee = node.callee;
|
|
60
|
+
if (callee.type === 'MemberExpression' &&
|
|
61
|
+
!callee.computed &&
|
|
62
|
+
callee.object.type === 'Identifier' &&
|
|
63
|
+
callee.object.name === 'Date' &&
|
|
64
|
+
callee.property.type === 'Identifier' &&
|
|
65
|
+
callee.property.name === 'now') {
|
|
66
|
+
context.report({ node, messageId: 'banned' });
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
const noHardcodedSecrets = {
|
|
73
|
+
meta: {
|
|
74
|
+
type: 'suggestion',
|
|
75
|
+
docs: { description: 'Flag likely hardcoded secrets (with allowlist)' },
|
|
76
|
+
messages: {
|
|
77
|
+
secret: 'Possible hardcoded secret value. Use env/fixtures or allowlist dummy data.',
|
|
78
|
+
},
|
|
79
|
+
schema: [],
|
|
80
|
+
},
|
|
81
|
+
create(context) {
|
|
82
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
83
|
+
const keyRe = /password|passwd|secret|token|api[_-]?key|auth/i;
|
|
84
|
+
function check(node, rawKey) {
|
|
85
|
+
if (node.type !== 'Literal' || typeof node.value !== 'string')
|
|
86
|
+
return;
|
|
87
|
+
const value = String(node.value);
|
|
88
|
+
if (DUMMY_SECRETS.has(value.toLowerCase()))
|
|
89
|
+
return;
|
|
90
|
+
if (value.length < 6)
|
|
91
|
+
return;
|
|
92
|
+
if (!keyRe.test(rawKey ?? ''))
|
|
93
|
+
return;
|
|
94
|
+
const line = node.loc?.start.line ?? 1;
|
|
95
|
+
if (hasDisableNextLine(sourceCode, line, 'guardian/no-hardcoded-secrets'))
|
|
96
|
+
return;
|
|
97
|
+
context.report({ node: node, messageId: 'secret' });
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
Property(node) {
|
|
101
|
+
if (node.key.type === 'Identifier') {
|
|
102
|
+
check(node.value, node.key.name);
|
|
103
|
+
}
|
|
104
|
+
else if (node.key.type === 'Literal') {
|
|
105
|
+
check(node.value, String(node.key.value));
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
VariableDeclarator(node) {
|
|
109
|
+
if (node.id.type === 'Identifier' && node.init) {
|
|
110
|
+
check(node.init, node.id.name);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const noGenericLongTimeout = {
|
|
117
|
+
meta: {
|
|
118
|
+
type: 'suggestion',
|
|
119
|
+
docs: { description: 'Only allow timeout 2000 or 20000' },
|
|
120
|
+
messages: {
|
|
121
|
+
timeout: 'Avoid generic timeouts; allowed values under guardian: 2000 (audit) or 20000 (backend).',
|
|
122
|
+
},
|
|
123
|
+
schema: [],
|
|
124
|
+
},
|
|
125
|
+
create(context) {
|
|
126
|
+
const allowed = new Set([2000, 20000]);
|
|
127
|
+
return {
|
|
128
|
+
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' });
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
const oneDescribeOneTest = {
|
|
141
|
+
meta: {
|
|
142
|
+
type: 'suggestion',
|
|
143
|
+
docs: { description: 'Prefer one test.describe and one test per file' },
|
|
144
|
+
messages: {
|
|
145
|
+
describes: 'Guardian profile prefers exactly one test.describe() per file (found {{count}}).',
|
|
146
|
+
tests: 'Guardian profile prefers exactly one test() per file (found {{count}}).',
|
|
147
|
+
},
|
|
148
|
+
schema: [],
|
|
149
|
+
},
|
|
150
|
+
create(context) {
|
|
151
|
+
let describes = 0;
|
|
152
|
+
let tests = 0;
|
|
153
|
+
return {
|
|
154
|
+
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++;
|
|
165
|
+
}
|
|
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)) {
|
|
172
|
+
tests++;
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
'Program:exit'() {
|
|
176
|
+
if (describes > 1) {
|
|
177
|
+
context.report({
|
|
178
|
+
loc: { line: 1, column: 0 },
|
|
179
|
+
messageId: 'describes',
|
|
180
|
+
data: { count: String(describes) },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (tests > 1) {
|
|
184
|
+
context.report({
|
|
185
|
+
loc: { line: 1, column: 0 },
|
|
186
|
+
messageId: 'tests',
|
|
187
|
+
data: { count: String(tests) },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
const requireTestStep = {
|
|
195
|
+
meta: {
|
|
196
|
+
type: 'suggestion',
|
|
197
|
+
docs: { description: 'Warn when file uses test() but no test.step()' },
|
|
198
|
+
messages: {
|
|
199
|
+
missing: 'Consider wrapping actions in test.step() for clearer structure.',
|
|
200
|
+
},
|
|
201
|
+
schema: [],
|
|
202
|
+
},
|
|
203
|
+
create(context) {
|
|
204
|
+
let hasTest = false;
|
|
205
|
+
let hasStep = false;
|
|
206
|
+
return {
|
|
207
|
+
CallExpression(node) {
|
|
208
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'test') {
|
|
209
|
+
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
|
+
}
|
|
222
|
+
},
|
|
223
|
+
'Program:exit'() {
|
|
224
|
+
if (hasTest && !hasStep) {
|
|
225
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'missing' });
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
const requireExpect = {
|
|
232
|
+
meta: {
|
|
233
|
+
type: 'problem',
|
|
234
|
+
docs: { description: 'Require expect() in files with test()' },
|
|
235
|
+
messages: {
|
|
236
|
+
missing: 'Guardian profile requires at least one expect() in the spec.',
|
|
237
|
+
},
|
|
238
|
+
schema: [],
|
|
239
|
+
},
|
|
240
|
+
create(context) {
|
|
241
|
+
let hasTest = false;
|
|
242
|
+
let hasExpect = false;
|
|
243
|
+
return {
|
|
244
|
+
CallExpression(node) {
|
|
245
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'test') {
|
|
246
|
+
hasTest = true;
|
|
247
|
+
}
|
|
248
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'expect') {
|
|
249
|
+
hasExpect = true;
|
|
250
|
+
}
|
|
251
|
+
if (node.callee.type === 'MemberExpression' &&
|
|
252
|
+
node.callee.object.type === 'Identifier' &&
|
|
253
|
+
node.callee.object.name === 'expect') {
|
|
254
|
+
hasExpect = true;
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
'Program:exit'() {
|
|
258
|
+
if (hasTest && !hasExpect) {
|
|
259
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'missing' });
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
export const guardianPlugin = {
|
|
266
|
+
meta: { name: 'guardian', version: '0.1.0' },
|
|
267
|
+
rules: {
|
|
268
|
+
'no-wait-for-load-state': noWaitForLoadState,
|
|
269
|
+
'no-date-now-id': noDateNowId,
|
|
270
|
+
'no-hardcoded-secrets': noHardcodedSecrets,
|
|
271
|
+
'no-generic-long-timeout': noGenericLongTimeout,
|
|
272
|
+
'one-describe-one-test': oneDescribeOneTest,
|
|
273
|
+
'require-test-step': requireTestStep,
|
|
274
|
+
'require-expect': requireExpect,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
export const guardianRuleConfigs = {
|
|
278
|
+
'guardian/no-wait-for-load-state': 'error',
|
|
279
|
+
'guardian/no-date-now-id': 'error',
|
|
280
|
+
'guardian/no-hardcoded-secrets': 'warn',
|
|
281
|
+
'guardian/no-generic-long-timeout': 'warn',
|
|
282
|
+
'guardian/one-describe-one-test': 'warn',
|
|
283
|
+
'guardian/require-test-step': 'warn',
|
|
284
|
+
'guardian/require-expect': 'error',
|
|
285
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DimensionName, Finding, Grade, ProfileName, ScoreResult } from './types.js';
|
|
2
|
+
/** sqs-v1 frozen constants — change requires sqs-v2 */
|
|
3
|
+
export declare const SQS_V1: {
|
|
4
|
+
readonly scoreVersion: "sqs-v1";
|
|
5
|
+
readonly ERROR_UNIT: 1;
|
|
6
|
+
readonly WARNING_UNIT: 0.4;
|
|
7
|
+
readonly INFO_UNIT: 0;
|
|
8
|
+
readonly MAX_FINDINGS_PER_RULE_PER_FILE: 3;
|
|
9
|
+
readonly SLOT_DIVISOR: 25;
|
|
10
|
+
readonly MIN_SLOTS: 4;
|
|
11
|
+
readonly K: 0.4;
|
|
12
|
+
};
|
|
13
|
+
declare const PENALTY_DIMENSIONS: DimensionName[];
|
|
14
|
+
export declare function gradeFromScore(score: number): Grade;
|
|
15
|
+
export declare function locatorsScore(native: number, raw: number): number;
|
|
16
|
+
/** Cap findings per (file, rule) for penalty math only */
|
|
17
|
+
export declare function applyPerRuleCap(findings: Finding[]): Finding[];
|
|
18
|
+
export declare function penaltyDimensionScore(findings: Finding[], dimension: DimensionName, sloc: number): number;
|
|
19
|
+
export declare function computeScore(input: {
|
|
20
|
+
profile: ProfileName;
|
|
21
|
+
threshold: number;
|
|
22
|
+
findings: Finding[];
|
|
23
|
+
sloc: number;
|
|
24
|
+
files: number;
|
|
25
|
+
tests: number;
|
|
26
|
+
nativeLocators: number;
|
|
27
|
+
rawLocators: number;
|
|
28
|
+
}): ScoreResult;
|
|
29
|
+
export { PENALTY_DIMENSIONS };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { PROFILE_WEIGHTS } from './profiles.js';
|
|
2
|
+
/** sqs-v1 frozen constants — change requires sqs-v2 */
|
|
3
|
+
export const SQS_V1 = {
|
|
4
|
+
scoreVersion: 'sqs-v1',
|
|
5
|
+
ERROR_UNIT: 1.0,
|
|
6
|
+
WARNING_UNIT: 0.4,
|
|
7
|
+
INFO_UNIT: 0.0,
|
|
8
|
+
MAX_FINDINGS_PER_RULE_PER_FILE: 3,
|
|
9
|
+
SLOT_DIVISOR: 25,
|
|
10
|
+
MIN_SLOTS: 4,
|
|
11
|
+
K: 0.4,
|
|
12
|
+
};
|
|
13
|
+
const PENALTY_DIMENSIONS = [
|
|
14
|
+
'playwrightHygiene',
|
|
15
|
+
'assertions',
|
|
16
|
+
'structure',
|
|
17
|
+
'guardianConventions',
|
|
18
|
+
];
|
|
19
|
+
export function gradeFromScore(score) {
|
|
20
|
+
if (score >= 90)
|
|
21
|
+
return 'A';
|
|
22
|
+
if (score >= 80)
|
|
23
|
+
return 'B';
|
|
24
|
+
if (score >= 70)
|
|
25
|
+
return 'C';
|
|
26
|
+
if (score >= 60)
|
|
27
|
+
return 'D';
|
|
28
|
+
return 'F';
|
|
29
|
+
}
|
|
30
|
+
export function locatorsScore(native, raw) {
|
|
31
|
+
const total = native + raw;
|
|
32
|
+
if (total === 0)
|
|
33
|
+
return 100;
|
|
34
|
+
return Math.round((100 * native) / total);
|
|
35
|
+
}
|
|
36
|
+
function severityUnit(severity) {
|
|
37
|
+
if (severity === 'error')
|
|
38
|
+
return SQS_V1.ERROR_UNIT;
|
|
39
|
+
if (severity === 'warning')
|
|
40
|
+
return SQS_V1.WARNING_UNIT;
|
|
41
|
+
return SQS_V1.INFO_UNIT;
|
|
42
|
+
}
|
|
43
|
+
/** Cap findings per (file, rule) for penalty math only */
|
|
44
|
+
export function applyPerRuleCap(findings) {
|
|
45
|
+
const counts = new Map();
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const f of findings) {
|
|
48
|
+
if (f.reportOnly)
|
|
49
|
+
continue;
|
|
50
|
+
const key = `${f.file}::${f.rule}`;
|
|
51
|
+
const n = counts.get(key) ?? 0;
|
|
52
|
+
if (n >= SQS_V1.MAX_FINDINGS_PER_RULE_PER_FILE)
|
|
53
|
+
continue;
|
|
54
|
+
counts.set(key, n + 1);
|
|
55
|
+
out.push(f);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
export function penaltyDimensionScore(findings, dimension, sloc) {
|
|
60
|
+
const dimFindings = findings.filter((f) => f.dimension === dimension);
|
|
61
|
+
const capped = applyPerRuleCap(dimFindings);
|
|
62
|
+
let rawUnits = 0;
|
|
63
|
+
for (const f of capped) {
|
|
64
|
+
rawUnits += severityUnit(f.severity);
|
|
65
|
+
}
|
|
66
|
+
const slots = Math.max(sloc / SQS_V1.SLOT_DIVISOR, SQS_V1.MIN_SLOTS);
|
|
67
|
+
const load = rawUnits / slots;
|
|
68
|
+
const score = Math.round(100 * Math.exp(-SQS_V1.K * load));
|
|
69
|
+
return Math.min(100, Math.max(0, score));
|
|
70
|
+
}
|
|
71
|
+
export function computeScore(input) {
|
|
72
|
+
const weights = PROFILE_WEIGHTS[input.profile];
|
|
73
|
+
const dims = {
|
|
74
|
+
playwrightHygiene: penaltyDimensionScore(input.findings, 'playwrightHygiene', input.sloc),
|
|
75
|
+
assertions: penaltyDimensionScore(input.findings, 'assertions', input.sloc),
|
|
76
|
+
locators: locatorsScore(input.nativeLocators, input.rawLocators),
|
|
77
|
+
structure: penaltyDimensionScore(input.findings, 'structure', input.sloc),
|
|
78
|
+
};
|
|
79
|
+
if (input.profile === 'guardian') {
|
|
80
|
+
dims.guardianConventions = penaltyDimensionScore(input.findings, 'guardianConventions', input.sloc);
|
|
81
|
+
}
|
|
82
|
+
let score = 0;
|
|
83
|
+
for (const [dim, weight] of Object.entries(weights)) {
|
|
84
|
+
const key = dim;
|
|
85
|
+
const value = dims[key];
|
|
86
|
+
if (value === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
score += (weight / 100) * value;
|
|
89
|
+
}
|
|
90
|
+
score = Math.round(score);
|
|
91
|
+
const errors = input.findings.filter((f) => f.severity === 'error').length;
|
|
92
|
+
const warnings = input.findings.filter((f) => f.severity === 'warning').length;
|
|
93
|
+
return {
|
|
94
|
+
scoreVersion: SQS_V1.scoreVersion,
|
|
95
|
+
profile: input.profile,
|
|
96
|
+
score,
|
|
97
|
+
grade: gradeFromScore(score),
|
|
98
|
+
pass: score >= input.threshold,
|
|
99
|
+
threshold: input.threshold,
|
|
100
|
+
summary: {
|
|
101
|
+
files: input.files,
|
|
102
|
+
tests: input.tests,
|
|
103
|
+
sloc: input.sloc,
|
|
104
|
+
findings: input.findings.length,
|
|
105
|
+
errors,
|
|
106
|
+
warnings,
|
|
107
|
+
nativeLocators: input.nativeLocators,
|
|
108
|
+
rawLocators: input.rawLocators,
|
|
109
|
+
},
|
|
110
|
+
dimensions: dims,
|
|
111
|
+
findings: input.findings,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export { PENALTY_DIMENSIONS };
|
package/dist/sloc.d.ts
ADDED
package/dist/sloc.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Count source lines of code: exclude blank and pure-comment lines.
|
|
3
|
+
*/
|
|
4
|
+
export function countSloc(source) {
|
|
5
|
+
const lines = source.split(/\r?\n/);
|
|
6
|
+
let sloc = 0;
|
|
7
|
+
let inBlockComment = false;
|
|
8
|
+
for (const line of lines) {
|
|
9
|
+
const trimmed = line.trim();
|
|
10
|
+
if (!trimmed)
|
|
11
|
+
continue;
|
|
12
|
+
if (inBlockComment) {
|
|
13
|
+
if (trimmed.includes('*/')) {
|
|
14
|
+
inBlockComment = false;
|
|
15
|
+
const after = trimmed.slice(trimmed.indexOf('*/') + 2).trim();
|
|
16
|
+
if (after && !after.startsWith('//'))
|
|
17
|
+
sloc++;
|
|
18
|
+
}
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (trimmed.startsWith('/*')) {
|
|
22
|
+
if (!trimmed.includes('*/')) {
|
|
23
|
+
inBlockComment = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// single-line block comment /* ... */
|
|
27
|
+
const after = trimmed.replace(/\/\*.*?\*\//g, '').trim();
|
|
28
|
+
if (!after || after.startsWith('//'))
|
|
29
|
+
continue;
|
|
30
|
+
sloc++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (trimmed.startsWith('//'))
|
|
34
|
+
continue;
|
|
35
|
+
sloc++;
|
|
36
|
+
}
|
|
37
|
+
return Math.max(sloc, 1);
|
|
38
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type ScoreVersion = 'sqs-v1';
|
|
2
|
+
export type ProfileName = 'standard' | 'guardian';
|
|
3
|
+
export type Grade = 'A' | 'B' | 'C' | 'D' | 'F';
|
|
4
|
+
export type Severity = 'error' | 'warning' | 'info';
|
|
5
|
+
export type DimensionName = 'playwrightHygiene' | 'assertions' | 'locators' | 'structure' | 'guardianConventions';
|
|
6
|
+
export interface Finding {
|
|
7
|
+
rule: string;
|
|
8
|
+
severity: Severity;
|
|
9
|
+
message: string;
|
|
10
|
+
file: string;
|
|
11
|
+
line?: number;
|
|
12
|
+
column?: number;
|
|
13
|
+
dimension: DimensionName;
|
|
14
|
+
/** When true, finding is listed but does not contribute penalty units */
|
|
15
|
+
reportOnly?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface ScoreDimensions {
|
|
18
|
+
playwrightHygiene: number;
|
|
19
|
+
assertions: number;
|
|
20
|
+
locators: number;
|
|
21
|
+
structure: number;
|
|
22
|
+
guardianConventions?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ScoreSummary {
|
|
25
|
+
files: number;
|
|
26
|
+
tests: number;
|
|
27
|
+
sloc: number;
|
|
28
|
+
findings: number;
|
|
29
|
+
errors: number;
|
|
30
|
+
warnings: number;
|
|
31
|
+
nativeLocators: number;
|
|
32
|
+
rawLocators: number;
|
|
33
|
+
}
|
|
34
|
+
export interface ScoreResult {
|
|
35
|
+
scoreVersion: ScoreVersion;
|
|
36
|
+
profile: ProfileName;
|
|
37
|
+
score: number;
|
|
38
|
+
grade: Grade;
|
|
39
|
+
pass: boolean;
|
|
40
|
+
threshold: number;
|
|
41
|
+
summary: ScoreSummary;
|
|
42
|
+
dimensions: ScoreDimensions;
|
|
43
|
+
findings: Finding[];
|
|
44
|
+
}
|
|
45
|
+
export interface ScoreOptions {
|
|
46
|
+
paths: string[];
|
|
47
|
+
profile?: ProfileName;
|
|
48
|
+
threshold?: number;
|
|
49
|
+
cwd?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface LocatorCounts {
|
|
52
|
+
native: number;
|
|
53
|
+
raw: number;
|
|
54
|
+
}
|
|
55
|
+
export interface FileMetrics {
|
|
56
|
+
file: string;
|
|
57
|
+
sloc: number;
|
|
58
|
+
tests: number;
|
|
59
|
+
locators: LocatorCounts;
|
|
60
|
+
findings: Finding[];
|
|
61
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qaguardian/playwright-score",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deterministic, AI-free Playwright spec quality score (sqs-v1). Lint + score with standard and guardian profiles.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"playwright-score": "bin/playwright-score.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"dist",
|
|
20
|
+
"METHODOLOGY.md",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc && node -e \"require('fs').chmodSync('dist/cli.js',0o755); require('fs').chmodSync('bin/playwright-score.js',0o755)\"",
|
|
26
|
+
"build:tests": "tsc -p tsconfig.tests.json",
|
|
27
|
+
"test": "npm run build && npm run build:tests && node --test dist-tests/tests/*.js",
|
|
28
|
+
"score": "npm run build && node bin/playwright-score.js",
|
|
29
|
+
"dogfood": "npm run build && bash scripts/dogfood.sh",
|
|
30
|
+
"prepublishOnly": "npm run build"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"playwright",
|
|
34
|
+
"eslint",
|
|
35
|
+
"testing",
|
|
36
|
+
"quality",
|
|
37
|
+
"score",
|
|
38
|
+
"lint",
|
|
39
|
+
"e2e"
|
|
40
|
+
],
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"author": "QA Guardian <qag@qaguardian.com> (https://qaguardian.com)",
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/qa-guardian/playwright-score.git"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/qa-guardian/playwright-score/issues"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://qaguardian.com/open-source/playwright-score",
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=18"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@typescript-eslint/parser": "^8.18.0",
|
|
59
|
+
"eslint": "^9.17.0",
|
|
60
|
+
"eslint-plugin-playwright": "^2.1.0",
|
|
61
|
+
"glob": "^11.0.0"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/node": "^22.10.0",
|
|
65
|
+
"typescript": "^5.7.2"
|
|
66
|
+
}
|
|
67
|
+
}
|