@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.
@@ -0,0 +1,32 @@
1
+ export function formatText(result) {
2
+ const lines = [];
3
+ lines.push(`Playwright Spec Score ${result.scoreVersion} · profile=${result.profile}`);
4
+ lines.push(`Score: ${result.score}/100 (${result.grade}) · ${result.pass ? 'PASS' : 'FAIL'} (threshold ${result.threshold})`);
5
+ lines.push('');
6
+ lines.push('Dimensions:');
7
+ for (const [k, v] of Object.entries(result.dimensions)) {
8
+ if (v === undefined)
9
+ continue;
10
+ lines.push(` ${k}: ${v}`);
11
+ }
12
+ lines.push('');
13
+ lines.push(`Summary: files=${result.summary.files} tests=${result.summary.tests} sloc=${result.summary.sloc} ` +
14
+ `findings=${result.summary.findings} (errors=${result.summary.errors} warnings=${result.summary.warnings}) ` +
15
+ `locators native/raw=${result.summary.nativeLocators}/${result.summary.rawLocators}`);
16
+ if (result.findings.length) {
17
+ lines.push('');
18
+ lines.push('Findings:');
19
+ for (const f of result.findings.slice(0, 50)) {
20
+ const loc = f.line ? `:${f.line}` : '';
21
+ lines.push(` [${f.severity}] ${f.rule} ${pathBasename(f.file)}${loc} — ${f.message}`);
22
+ }
23
+ if (result.findings.length > 50) {
24
+ lines.push(` … and ${result.findings.length - 50} more`);
25
+ }
26
+ }
27
+ return lines.join('\n');
28
+ }
29
+ function pathBasename(p) {
30
+ const parts = p.split(/[/\\]/);
31
+ return parts[parts.length - 1] ?? p;
32
+ }
@@ -0,0 +1,13 @@
1
+ import type { ScoreOptions, ScoreResult } from './types.js';
2
+ export type { ScoreResult, ScoreOptions, Finding, ProfileName } from './types.js';
3
+ export { computeScore, SQS_V1, locatorsScore, gradeFromScore } from './score-engine.js';
4
+ export { countSloc } from './sloc.js';
5
+ export { countLocators, analyzeSource } from './metrics.js';
6
+ export { formatJson } from './formatters/json.js';
7
+ export { formatText } from './formatters/text.js';
8
+ export { formatMarkdown } from './formatters/markdown.js';
9
+ export { formatSarif } from './formatters/sarif.js';
10
+ /**
11
+ * Score Playwright spec files. Deterministic and AI-free (sqs-v1).
12
+ */
13
+ export declare function scorePaths(options: ScoreOptions): Promise<ScoreResult>;
package/dist/index.js ADDED
@@ -0,0 +1,95 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { globSync } from 'glob';
4
+ import { runEslint } from './eslint-runner.js';
5
+ import { analyzeSource } from './metrics.js';
6
+ import { DEFAULT_THRESHOLDS } from './profiles.js';
7
+ import { computeScore } from './score-engine.js';
8
+ export { computeScore, SQS_V1, locatorsScore, gradeFromScore } from './score-engine.js';
9
+ export { countSloc } from './sloc.js';
10
+ export { countLocators, analyzeSource } from './metrics.js';
11
+ export { formatJson } from './formatters/json.js';
12
+ export { formatText } from './formatters/text.js';
13
+ export { formatMarkdown } from './formatters/markdown.js';
14
+ export { formatSarif } from './formatters/sarif.js';
15
+ const SPEC_GLOBS = ['**/*.{spec,test}.{ts,tsx,js,jsx}', '**/*.spec.ts', '**/*.test.ts'];
16
+ function expandPaths(inputs, cwd) {
17
+ const files = new Set();
18
+ for (const input of inputs) {
19
+ const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input);
20
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
21
+ files.add(abs);
22
+ continue;
23
+ }
24
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
25
+ for (const g of SPEC_GLOBS) {
26
+ for (const f of globSync(g, { cwd: abs, absolute: true, nodir: true })) {
27
+ files.add(path.resolve(f));
28
+ }
29
+ }
30
+ continue;
31
+ }
32
+ // treat as glob
33
+ for (const f of globSync(input, { cwd, absolute: true, nodir: true })) {
34
+ files.add(path.resolve(f));
35
+ }
36
+ }
37
+ return [...files].sort();
38
+ }
39
+ /**
40
+ * Score Playwright spec files. Deterministic and AI-free (sqs-v1).
41
+ */
42
+ export async function scorePaths(options) {
43
+ const cwd = options.cwd ?? process.cwd();
44
+ const profile = options.profile ?? 'standard';
45
+ const threshold = options.threshold ?? DEFAULT_THRESHOLDS[profile] ?? 80;
46
+ const files = expandPaths(options.paths, cwd);
47
+ if (files.length === 0) {
48
+ return computeScore({
49
+ profile,
50
+ threshold,
51
+ findings: [
52
+ {
53
+ rule: 'playwright-score/no-files',
54
+ severity: 'error',
55
+ message: 'No Playwright spec files matched the given paths',
56
+ file: cwd,
57
+ dimension: 'structure',
58
+ },
59
+ ],
60
+ sloc: 1,
61
+ files: 0,
62
+ tests: 0,
63
+ nativeLocators: 0,
64
+ rawLocators: 0,
65
+ });
66
+ }
67
+ const eslintFindings = await runEslint({ files, profile, cwd });
68
+ let totalSloc = 0;
69
+ let totalTests = 0;
70
+ let native = 0;
71
+ let raw = 0;
72
+ const metricFindings = [];
73
+ for (const file of files) {
74
+ const source = fs.readFileSync(file, 'utf8');
75
+ const m = analyzeSource(source, file);
76
+ totalSloc += m.sloc;
77
+ totalTests += m.tests;
78
+ native += m.locators.native;
79
+ raw += m.locators.raw;
80
+ // metrics/no-empty-test duplicates guardian/require-expect under guardian — keep both mild
81
+ metricFindings.push(...m.findings);
82
+ }
83
+ // Dedupe empty-test style: if guardian require-expect already fired, still ok
84
+ const findings = [...eslintFindings, ...metricFindings];
85
+ return computeScore({
86
+ profile,
87
+ threshold,
88
+ findings,
89
+ sloc: Math.max(totalSloc, 1),
90
+ files: files.length,
91
+ tests: totalTests,
92
+ nativeLocators: native,
93
+ rawLocators: raw,
94
+ });
95
+ }
@@ -0,0 +1,17 @@
1
+ import type { Finding, LocatorCounts } from './types.js';
2
+ /** Count native vs raw locator calls (simple statistical). */
3
+ export declare function countLocators(source: string): LocatorCounts;
4
+ /** Count test( / test.only( / test.skip( style declarations (approx). */
5
+ export declare function countTests(source: string): number;
6
+ /**
7
+ * Detect test callbacks that appear to have zero expect( calls.
8
+ * Heuristic: for each test( ... async (...) => { body }, check body for expect.
9
+ */
10
+ export declare function findEmptyExpectTests(source: string, file: string): Finding[];
11
+ export declare function findOversizedFile(source: string, file: string, maxSloc?: number): Finding[];
12
+ export declare function analyzeSource(source: string, file: string): {
13
+ sloc: number;
14
+ tests: number;
15
+ locators: LocatorCounts;
16
+ findings: Finding[];
17
+ };
@@ -0,0 +1,79 @@
1
+ import { countSloc } from './sloc.js';
2
+ const NATIVE_RE = /\.\s*getBy(?:Role|Label|TestId|Text|Placeholder)\s*\(/g;
3
+ const RAW_RE = /(?:page|frame)\s*\.\s*locator\s*\(/g;
4
+ /** Count native vs raw locator calls (simple statistical). */
5
+ export function countLocators(source) {
6
+ const native = [...source.matchAll(NATIVE_RE)].length;
7
+ const raw = [...source.matchAll(RAW_RE)].length;
8
+ return { native, raw };
9
+ }
10
+ /** Count test( / test.only( / test.skip( style declarations (approx). */
11
+ export function countTests(source) {
12
+ const re = /\btest(?:\.(?:only|skip|fix|slow))?\s*\(/g;
13
+ // Exclude test.describe, test.step, test.before*, test.after*, test.use
14
+ let count = 0;
15
+ let m;
16
+ const exclude = /\btest\.(?:describe|step|beforeEach|afterEach|beforeAll|afterAll|use|setTimeout|info|expect)\b/;
17
+ while ((m = re.exec(source)) !== null) {
18
+ const start = Math.max(0, m.index - 0);
19
+ const snippet = source.slice(start, start + 40);
20
+ if (exclude.test(snippet.split('(')[0] + '(')) {
21
+ // more precise: check the matched prefix
22
+ }
23
+ const before = source.slice(Math.max(0, m.index - 1), m.index + 20);
24
+ if (/\btest\.(describe|step|beforeEach|afterEach|beforeAll|afterAll|use|setTimeout|info)\b/.test(before)) {
25
+ continue;
26
+ }
27
+ // test.expect is rare as call form
28
+ if (/\btest\.expect\b/.test(before))
29
+ continue;
30
+ count++;
31
+ }
32
+ return count;
33
+ }
34
+ /**
35
+ * Detect test callbacks that appear to have zero expect( calls.
36
+ * Heuristic: for each test( ... async (...) => { body }, check body for expect.
37
+ */
38
+ export function findEmptyExpectTests(source, file) {
39
+ const findings = [];
40
+ // Rough scan: if file has test( but no expect( at all
41
+ const hasTest = /\btest(?:\.(?:only|skip|fix))?\s*\(/.test(source);
42
+ const hasExpect = /\bexpect\s*\(/.test(source);
43
+ if (hasTest && !hasExpect) {
44
+ findings.push({
45
+ rule: 'metrics/no-empty-test',
46
+ severity: 'error',
47
+ message: 'Test file has test() but no expect() assertions',
48
+ file,
49
+ dimension: 'assertions',
50
+ });
51
+ }
52
+ return findings;
53
+ }
54
+ export function findOversizedFile(source, file, maxSloc = 400) {
55
+ const sloc = countSloc(source);
56
+ if (sloc > maxSloc) {
57
+ return [
58
+ {
59
+ rule: 'metrics/oversized-file',
60
+ severity: 'warning',
61
+ message: `File has ${sloc} SLOC (limit ${maxSloc})`,
62
+ file,
63
+ dimension: 'structure',
64
+ },
65
+ ];
66
+ }
67
+ return [];
68
+ }
69
+ export function analyzeSource(source, file) {
70
+ return {
71
+ sloc: countSloc(source),
72
+ tests: countTests(source),
73
+ locators: countLocators(source),
74
+ findings: [
75
+ ...findEmptyExpectTests(source, file),
76
+ ...findOversizedFile(source, file),
77
+ ],
78
+ };
79
+ }
@@ -0,0 +1,15 @@
1
+ import type { DimensionName, ProfileName } from './types.js';
2
+ export declare const PROFILE_WEIGHTS: Record<ProfileName, Partial<Record<DimensionName, number>>>;
3
+ export declare const DEFAULT_THRESHOLDS: Record<ProfileName, number>;
4
+ /** Map eslint ruleId → dimension + whether report-only for scoring */
5
+ export interface RuleMapping {
6
+ dimension: DimensionName;
7
+ /** Override severity for scoring units; undefined keeps ESLint severity */
8
+ severityOverride?: 'error' | 'warning' | 'info';
9
+ reportOnly?: boolean;
10
+ }
11
+ /**
12
+ * ESLint rule → dimension map (plugin rule ids without prefix handled in runner).
13
+ */
14
+ export declare const ESLINT_RULE_MAP: Record<string, RuleMapping>;
15
+ export declare function mapRule(ruleId: string): RuleMapping;
@@ -0,0 +1,130 @@
1
+ export const PROFILE_WEIGHTS = {
2
+ standard: {
3
+ playwrightHygiene: 40,
4
+ assertions: 25,
5
+ locators: 20,
6
+ structure: 15,
7
+ },
8
+ guardian: {
9
+ playwrightHygiene: 30,
10
+ assertions: 15,
11
+ locators: 15,
12
+ structure: 15,
13
+ guardianConventions: 25,
14
+ },
15
+ };
16
+ export const DEFAULT_THRESHOLDS = {
17
+ standard: 80,
18
+ guardian: 75,
19
+ };
20
+ /**
21
+ * ESLint rule → dimension map (plugin rule ids without prefix handled in runner).
22
+ */
23
+ export const ESLINT_RULE_MAP = {
24
+ // Hygiene
25
+ 'playwright/no-wait-for-timeout': { dimension: 'playwrightHygiene' },
26
+ 'playwright/no-networkidle': { dimension: 'playwrightHygiene' },
27
+ 'playwright/no-force-option': { dimension: 'playwrightHygiene' },
28
+ 'playwright/no-wait-for-selector': { dimension: 'playwrightHygiene' },
29
+ 'playwright/no-wait-for-navigation': { dimension: 'playwrightHygiene' },
30
+ 'playwright/missing-playwright-await': { dimension: 'playwrightHygiene' },
31
+ 'playwright/no-element-handle': { dimension: 'playwrightHygiene' },
32
+ 'playwright/no-eval': { dimension: 'playwrightHygiene' },
33
+ 'playwright/no-conditional-in-test': {
34
+ dimension: 'playwrightHygiene',
35
+ severityOverride: 'warning',
36
+ },
37
+ 'playwright/no-conditional-expect': {
38
+ dimension: 'playwrightHygiene',
39
+ severityOverride: 'warning',
40
+ },
41
+ 'playwright/no-page-pause': { dimension: 'playwrightHygiene' },
42
+ 'playwright/no-useless-await': {
43
+ dimension: 'playwrightHygiene',
44
+ severityOverride: 'warning',
45
+ },
46
+ // Assertions
47
+ 'playwright/expect-expect': { dimension: 'assertions' },
48
+ 'playwright/prefer-web-first-assertions': {
49
+ dimension: 'assertions',
50
+ severityOverride: 'warning',
51
+ },
52
+ 'playwright/valid-expect': { dimension: 'assertions' },
53
+ 'playwright/no-standalone-expect': { dimension: 'assertions' },
54
+ // Locators — findings for repair, reportOnly so ratio owns locators score
55
+ 'playwright/no-raw-locators': {
56
+ dimension: 'locators',
57
+ reportOnly: true,
58
+ severityOverride: 'warning',
59
+ },
60
+ 'playwright/prefer-native-locators': {
61
+ dimension: 'locators',
62
+ reportOnly: true,
63
+ severityOverride: 'warning',
64
+ },
65
+ 'playwright/prefer-locator': {
66
+ dimension: 'locators',
67
+ reportOnly: true,
68
+ severityOverride: 'info',
69
+ },
70
+ // Structure
71
+ 'playwright/no-focused-test': { dimension: 'structure' },
72
+ 'playwright/no-skipped-test': {
73
+ dimension: 'structure',
74
+ severityOverride: 'warning',
75
+ },
76
+ 'playwright/max-nested-describe': {
77
+ dimension: 'structure',
78
+ severityOverride: 'warning',
79
+ },
80
+ 'playwright/require-top-level-describe': {
81
+ dimension: 'structure',
82
+ severityOverride: 'warning',
83
+ },
84
+ // Guardian custom rules
85
+ 'guardian/no-wait-for-load-state': { dimension: 'guardianConventions' },
86
+ 'guardian/no-date-now-id': { dimension: 'guardianConventions' },
87
+ 'guardian/require-expect': { dimension: 'guardianConventions' },
88
+ 'guardian/no-hardcoded-secrets': {
89
+ dimension: 'guardianConventions',
90
+ severityOverride: 'warning',
91
+ },
92
+ 'guardian/require-test-step': {
93
+ dimension: 'guardianConventions',
94
+ severityOverride: 'warning',
95
+ },
96
+ 'guardian/one-describe-one-test': {
97
+ dimension: 'guardianConventions',
98
+ severityOverride: 'warning',
99
+ },
100
+ 'guardian/no-generic-long-timeout': {
101
+ dimension: 'guardianConventions',
102
+ severityOverride: 'warning',
103
+ },
104
+ 'guardian/prefer-uuid': {
105
+ dimension: 'guardianConventions',
106
+ severityOverride: 'warning',
107
+ },
108
+ 'guardian/fixtures-import': {
109
+ dimension: 'guardianConventions',
110
+ severityOverride: 'info',
111
+ reportOnly: true,
112
+ },
113
+ // Metrics injected as synthetic findings
114
+ 'metrics/no-empty-test': { dimension: 'assertions' },
115
+ 'metrics/oversized-file': {
116
+ dimension: 'structure',
117
+ severityOverride: 'warning',
118
+ },
119
+ };
120
+ export function mapRule(ruleId) {
121
+ if (ESLINT_RULE_MAP[ruleId])
122
+ return ESLINT_RULE_MAP[ruleId];
123
+ if (ruleId.startsWith('guardian/')) {
124
+ return { dimension: 'guardianConventions', severityOverride: 'warning' };
125
+ }
126
+ if (ruleId.startsWith('playwright/')) {
127
+ return { dimension: 'playwrightHygiene', severityOverride: 'warning' };
128
+ }
129
+ return { dimension: 'playwrightHygiene', severityOverride: 'info', reportOnly: true };
130
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Guardian house-style ESLint rules for profile=guardian.
3
+ * Embedded in playwright-score — not claimed as official Playwright doctrine.
4
+ */
5
+ import type { Rule } from 'eslint';
6
+ export declare const guardianPlugin: {
7
+ meta: {
8
+ name: string;
9
+ version: string;
10
+ };
11
+ rules: {
12
+ 'no-wait-for-load-state': Rule.RuleModule;
13
+ 'no-date-now-id': Rule.RuleModule;
14
+ 'no-hardcoded-secrets': Rule.RuleModule;
15
+ 'no-generic-long-timeout': Rule.RuleModule;
16
+ 'one-describe-one-test': Rule.RuleModule;
17
+ 'require-test-step': Rule.RuleModule;
18
+ 'require-expect': Rule.RuleModule;
19
+ };
20
+ };
21
+ export declare const guardianRuleConfigs: {
22
+ readonly 'guardian/no-wait-for-load-state': "error";
23
+ readonly 'guardian/no-date-now-id': "error";
24
+ readonly 'guardian/no-hardcoded-secrets': "warn";
25
+ readonly 'guardian/no-generic-long-timeout': "warn";
26
+ readonly 'guardian/one-describe-one-test': "warn";
27
+ readonly 'guardian/require-test-step': "warn";
28
+ readonly 'guardian/require-expect': "error";
29
+ };