@qulib/core 0.4.1 → 0.4.3
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 +56 -8
- package/dist/analyze.d.ts.map +1 -1
- package/dist/analyze.js +86 -7
- package/dist/cli/auth-login-resolve.d.ts +14 -0
- package/dist/cli/auth-login-resolve.d.ts.map +1 -0
- package/dist/cli/auth-login-resolve.js +68 -0
- package/dist/cli/auth-login-run.d.ts +13 -0
- package/dist/cli/auth-login-run.d.ts.map +1 -0
- package/dist/cli/auth-login-run.js +152 -0
- package/dist/cli/index.js +60 -7
- package/dist/harness/state-manager.d.ts +10 -0
- package/dist/harness/state-manager.d.ts.map +1 -1
- package/dist/harness/state-manager.js +15 -0
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -6
- package/dist/phases/act.js +3 -3
- package/dist/phases/observe.js +5 -5
- package/dist/phases/think.js +1 -1
- package/dist/schemas/automation-maturity.schema.d.ts +40 -0
- package/dist/schemas/automation-maturity.schema.d.ts.map +1 -1
- package/dist/schemas/automation-maturity.schema.js +27 -0
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/schemas/repo-analysis.schema.d.ts +22 -0
- package/dist/schemas/repo-analysis.schema.d.ts.map +1 -1
- package/dist/schemas/repo-analysis.schema.js +1 -0
- package/dist/telemetry/emit.d.ts +22 -0
- package/dist/telemetry/emit.d.ts.map +1 -1
- package/dist/telemetry/emit.js +37 -0
- package/dist/telemetry/telemetry.interface.d.ts +1 -1
- package/dist/telemetry/telemetry.interface.d.ts.map +1 -1
- package/dist/tools/apply-auth.d.ts +4 -0
- package/dist/tools/apply-auth.d.ts.map +1 -0
- package/dist/tools/apply-auth.js +35 -0
- package/dist/tools/auth/apply.d.ts +4 -0
- package/dist/tools/auth/apply.d.ts.map +1 -0
- package/dist/tools/auth/apply.js +35 -0
- package/dist/tools/auth/block-gap.d.ts +9 -0
- package/dist/tools/auth/block-gap.d.ts.map +1 -0
- package/dist/tools/auth/block-gap.js +52 -0
- package/dist/tools/auth/custom-providers.d.ts +15 -0
- package/dist/tools/auth/custom-providers.d.ts.map +1 -0
- package/dist/tools/auth/custom-providers.js +62 -0
- package/dist/tools/auth/detect.d.ts +23 -0
- package/dist/tools/auth/detect.d.ts.map +1 -0
- package/dist/tools/auth/detect.js +526 -0
- package/dist/tools/auth/detector.d.ts +23 -0
- package/dist/tools/auth/detector.d.ts.map +1 -0
- package/dist/tools/auth/detector.js +526 -0
- package/dist/tools/auth/explore.d.ts +4 -0
- package/dist/tools/auth/explore.d.ts.map +1 -0
- package/dist/tools/auth/explore.js +346 -0
- package/dist/tools/auth/explorer.d.ts +4 -0
- package/dist/tools/auth/explorer.d.ts.map +1 -0
- package/dist/tools/auth/explorer.js +346 -0
- package/dist/tools/auth/gaps.d.ts +9 -0
- package/dist/tools/auth/gaps.d.ts.map +1 -0
- package/dist/tools/auth/gaps.js +52 -0
- package/dist/tools/auth/oauth-providers.d.ts +7 -0
- package/dist/tools/auth/oauth-providers.d.ts.map +1 -0
- package/dist/tools/auth/oauth-providers.js +21 -0
- package/dist/tools/auth/providers.d.ts +7 -0
- package/dist/tools/auth/providers.d.ts.map +1 -0
- package/dist/tools/auth/providers.js +21 -0
- package/dist/tools/auth/surface-analyzer.d.ts +4 -0
- package/dist/tools/auth/surface-analyzer.d.ts.map +1 -0
- package/dist/tools/auth/surface-analyzer.js +170 -0
- package/dist/tools/auth/surface.d.ts +4 -0
- package/dist/tools/auth/surface.d.ts.map +1 -0
- package/dist/tools/auth/surface.js +170 -0
- package/dist/tools/auth/user-providers.d.ts +15 -0
- package/dist/tools/auth/user-providers.d.ts.map +1 -0
- package/dist/tools/auth/user-providers.js +62 -0
- package/dist/tools/auth-block-gap.d.ts +6 -0
- package/dist/tools/auth-block-gap.d.ts.map +1 -1
- package/dist/tools/auth-block-gap.js +42 -9
- package/dist/tools/auth-detector.d.ts +19 -0
- package/dist/tools/auth-detector.d.ts.map +1 -1
- package/dist/tools/auth-detector.js +186 -8
- package/dist/tools/automation-maturity.d.ts.map +1 -1
- package/dist/tools/automation-maturity.js +76 -20
- package/dist/tools/explorers/browser.d.ts +3 -0
- package/dist/tools/explorers/browser.d.ts.map +1 -0
- package/dist/tools/explorers/browser.js +13 -0
- package/dist/tools/explorers/cypress-explorer.d.ts +8 -0
- package/dist/tools/explorers/cypress-explorer.d.ts.map +1 -0
- package/dist/tools/explorers/cypress-explorer.js +5 -0
- package/dist/tools/explorers/cypress.d.ts +8 -0
- package/dist/tools/explorers/cypress.d.ts.map +1 -0
- package/dist/tools/explorers/cypress.js +5 -0
- package/dist/tools/explorers/explorer.interface.d.ts +7 -0
- package/dist/tools/explorers/explorer.interface.d.ts.map +1 -0
- package/dist/tools/explorers/explorer.interface.js +1 -0
- package/dist/tools/explorers/factory.d.ts +4 -0
- package/dist/tools/explorers/factory.d.ts.map +1 -0
- package/dist/tools/explorers/factory.js +12 -0
- package/dist/tools/explorers/playwright-explorer.d.ts +8 -0
- package/dist/tools/explorers/playwright-explorer.d.ts.map +1 -0
- package/dist/tools/explorers/playwright-explorer.js +172 -0
- package/dist/tools/explorers/playwright.d.ts +8 -0
- package/dist/tools/explorers/playwright.d.ts.map +1 -0
- package/dist/tools/explorers/playwright.js +172 -0
- package/dist/tools/explorers/types.d.ts +7 -0
- package/dist/tools/explorers/types.d.ts.map +1 -0
- package/dist/tools/explorers/types.js +1 -0
- package/dist/tools/playwright-explorer.js +1 -1
- package/dist/tools/repo/detect-framework.d.ts +15 -0
- package/dist/tools/repo/detect-framework.d.ts.map +1 -0
- package/dist/tools/repo/detect-framework.js +153 -0
- package/dist/tools/repo/framework-detector.d.ts +15 -0
- package/dist/tools/repo/framework-detector.d.ts.map +1 -0
- package/dist/tools/repo/framework-detector.js +153 -0
- package/dist/tools/repo/scan.d.ts +19 -0
- package/dist/tools/repo/scan.d.ts.map +1 -0
- package/dist/tools/repo/scan.js +181 -0
- package/dist/tools/repo/scanner.d.ts +19 -0
- package/dist/tools/repo/scanner.d.ts.map +1 -0
- package/dist/tools/repo/scanner.js +181 -0
- package/dist/tools/repo-scanner.d.ts.map +1 -1
- package/dist/tools/repo-scanner.js +7 -2
- package/dist/tools/scoring/automation-maturity.d.ts +4 -0
- package/dist/tools/scoring/automation-maturity.d.ts.map +1 -0
- package/dist/tools/scoring/automation-maturity.js +219 -0
- package/dist/tools/scoring/gap-engine.d.ts +8 -0
- package/dist/tools/scoring/gap-engine.d.ts.map +1 -0
- package/dist/tools/scoring/gap-engine.js +138 -0
- package/dist/tools/scoring/gaps.d.ts +8 -0
- package/dist/tools/scoring/gaps.d.ts.map +1 -0
- package/dist/tools/scoring/gaps.js +138 -0
- package/dist/tools/scoring/public-surface.d.ts +5 -0
- package/dist/tools/scoring/public-surface.d.ts.map +1 -0
- package/dist/tools/scoring/public-surface.js +13 -0
- package/package.json +3 -3
|
@@ -97,16 +97,32 @@ export function computeAutomationMaturity(repo) {
|
|
|
97
97
|
evidence: fwEvidence,
|
|
98
98
|
recommendations: frameworkScore >= 80 ? [] : ['Standardize on Playwright or Cypress for E2E against deployed URLs.'],
|
|
99
99
|
};
|
|
100
|
-
const
|
|
101
|
-
const
|
|
100
|
+
const missingIds = repo.missingTestIds.length;
|
|
101
|
+
const interactiveTsxScanned = repo.interactiveTsxFilesScanned ?? missingIds;
|
|
102
|
+
let hygieneScore = 0;
|
|
103
|
+
let hygieneApplicability = 'applicable';
|
|
104
|
+
let hygieneReason;
|
|
105
|
+
const hygieneEvidence = [];
|
|
106
|
+
if (interactiveTsxScanned === 0) {
|
|
107
|
+
hygieneApplicability = 'unknown';
|
|
108
|
+
hygieneReason = 'No interactive TSX files scanned — cannot compute a missing-id ratio honestly.';
|
|
109
|
+
hygieneEvidence.push(hygieneReason);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const missingRatio = missingIds / interactiveTsxScanned;
|
|
113
|
+
hygieneScore = Math.round(Math.max(0, 100 * (1 - missingRatio)));
|
|
114
|
+
hygieneEvidence.push(`${missingIds}/${interactiveTsxScanned} interactive TSX file(s) lacked data-testid (heuristic scan).`);
|
|
115
|
+
}
|
|
102
116
|
const hygieneDim = {
|
|
103
117
|
dimension: 'test-id-hygiene',
|
|
104
118
|
score: hygieneScore,
|
|
105
119
|
weight: W_TEST_ID,
|
|
106
|
-
evidence:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
evidence: hygieneEvidence,
|
|
121
|
+
recommendations: hygieneApplicability === 'applicable' && hygieneScore < 85
|
|
122
|
+
? ['Add stable data-testid (or role-based selectors) on interactive components used in tests.']
|
|
123
|
+
: [],
|
|
124
|
+
applicability: hygieneApplicability,
|
|
125
|
+
...(hygieneReason && { reason: hygieneReason }),
|
|
110
126
|
};
|
|
111
127
|
const ci = hasCiAtRoot(repo.repoPath);
|
|
112
128
|
const ciDim = {
|
|
@@ -117,36 +133,75 @@ export function computeAutomationMaturity(repo) {
|
|
|
117
133
|
recommendations: ci.ok ? [] : ['Add a CI workflow that runs unit/E2E tests on every PR.'],
|
|
118
134
|
};
|
|
119
135
|
const authRe = /\/(login|auth|signin)(\/|$)/i;
|
|
136
|
+
const authRouteFileRe = /(login|auth|signin)/i;
|
|
120
137
|
const authCovered = repo.testFiles.some((tf) => tf.coveredPaths.some((c) => authRe.test(c)));
|
|
121
|
-
const
|
|
138
|
+
const repoHasAuthRoute = repo.routes.some((r) => authRe.test(r.path));
|
|
139
|
+
const repoHasAuthTestFile = repo.testFiles.some((tf) => authRouteFileRe.test(tf.file));
|
|
140
|
+
const repoHasAnyAuthSignal = repoHasAuthRoute || repoHasAuthTestFile || authCovered;
|
|
141
|
+
let authScore = 0;
|
|
142
|
+
let authApplicability = 'applicable';
|
|
143
|
+
let authReason;
|
|
144
|
+
const authEvidence = [];
|
|
145
|
+
if (!repoHasAnyAuthSignal) {
|
|
146
|
+
authApplicability = 'not_applicable';
|
|
147
|
+
authReason = 'No auth routes, auth-named test files, or auth path coverage detected — repo appears auth-free.';
|
|
148
|
+
authEvidence.push(authReason);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
authScore = authCovered ? 90 : 25;
|
|
152
|
+
authEvidence.push(authCovered
|
|
153
|
+
? 'At least one test references /login, /auth, or /signin in coveredPaths.'
|
|
154
|
+
: 'Repo has auth-shaped routes or test files but no auth-route coverage in extracted test path strings.');
|
|
155
|
+
}
|
|
122
156
|
const authDim = {
|
|
123
157
|
dimension: 'auth-test-coverage',
|
|
124
158
|
score: authScore,
|
|
125
159
|
weight: W_AUTH_TESTS,
|
|
126
|
-
evidence:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
160
|
+
evidence: authEvidence,
|
|
161
|
+
recommendations: authApplicability === 'applicable' && !authCovered
|
|
162
|
+
? ['Add focused tests for sign-in and post-auth landing behavior.']
|
|
163
|
+
: [],
|
|
164
|
+
applicability: authApplicability,
|
|
165
|
+
...(authReason && { reason: authReason }),
|
|
130
166
|
};
|
|
131
167
|
const cypressE2e = repo.testFiles.filter((t) => t.type === 'cypress-e2e').length;
|
|
132
168
|
const cypressComp = repo.testFiles.filter((t) => t.type === 'cypress-component').length;
|
|
133
169
|
const cypressTotal = cypressE2e + cypressComp;
|
|
134
|
-
|
|
170
|
+
let compRatioScore = 0;
|
|
171
|
+
let compApplicability = 'applicable';
|
|
172
|
+
let compReason;
|
|
173
|
+
const compEvidence = [];
|
|
174
|
+
if (cypressTotal === 0) {
|
|
175
|
+
compApplicability = 'not_applicable';
|
|
176
|
+
compReason = 'No Cypress (e2e or component) tests detected — component-test-ratio does not apply.';
|
|
177
|
+
compEvidence.push(compReason);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
compRatioScore = Math.round((100 * cypressComp) / cypressTotal);
|
|
181
|
+
compEvidence.push(`Cypress e2e files (matched): ${cypressE2e}, component: ${cypressComp}.`);
|
|
182
|
+
}
|
|
135
183
|
const compDim = {
|
|
136
184
|
dimension: 'component-test-ratio',
|
|
137
185
|
score: compRatioScore,
|
|
138
186
|
weight: W_COMPONENT_RATIO,
|
|
139
|
-
evidence:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
187
|
+
evidence: compEvidence,
|
|
188
|
+
recommendations: compApplicability === 'applicable' && cypressComp > 0
|
|
189
|
+
? ['Balance component vs E2E Cypress tests so critical flows stay fast in CI.']
|
|
190
|
+
: [],
|
|
191
|
+
applicability: compApplicability,
|
|
192
|
+
...(compReason && { reason: compReason }),
|
|
145
193
|
};
|
|
146
194
|
const dimensions = [breadthDim, frameworkDim, hygieneDim, ciDim, authDim, compDim];
|
|
147
|
-
|
|
195
|
+
// Overall score normalizes over applicable dimensions only.
|
|
196
|
+
// overallScore = round( Σ score_i * weight_i / Σ weight_i ) for i ∈ applicable.
|
|
197
|
+
// If no dimension is applicable (degenerate repo), overall = 0 and level = L1.
|
|
198
|
+
const applicableDims = dimensions.filter((d) => (d.applicability ?? 'applicable') === 'applicable');
|
|
199
|
+
const weightSum = applicableDims.reduce((s, d) => s + d.weight, 0);
|
|
200
|
+
const overallScore = weightSum > 0
|
|
201
|
+
? Math.round(applicableDims.reduce((s, d) => s + d.score * d.weight, 0) / weightSum)
|
|
202
|
+
: 0;
|
|
148
203
|
const { level, label } = scoreLevel(overallScore);
|
|
149
|
-
const topRecommendations = [...
|
|
204
|
+
const topRecommendations = [...applicableDims]
|
|
150
205
|
.sort((a, b) => a.score - b.score)
|
|
151
206
|
.flatMap((d) => d.recommendations)
|
|
152
207
|
.filter(Boolean)
|
|
@@ -159,5 +214,6 @@ export function computeAutomationMaturity(repo) {
|
|
|
159
214
|
label,
|
|
160
215
|
dimensions,
|
|
161
216
|
topRecommendations,
|
|
217
|
+
scoreFormula: 'overallScore = round( Σ (score * weight) / Σ weight ) for applicable dimensions only. not_applicable and unknown dimensions are excluded from the denominator.',
|
|
162
218
|
});
|
|
163
219
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../../../src/tools/explorers/browser.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAE1D,wBAAsB,aAAa,IAAI,OAAO,CAAC,OAAO,CAAC,CAYtD"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { chromium } from '@playwright/test';
|
|
2
|
+
export async function launchBrowser() {
|
|
3
|
+
try {
|
|
4
|
+
return await chromium.launch({ headless: true });
|
|
5
|
+
}
|
|
6
|
+
catch (err) {
|
|
7
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
8
|
+
if (message.includes("Executable doesn't exist") || message.includes('chromium')) {
|
|
9
|
+
throw new Error(`Playwright Chromium browser is not installed. Run:\n\n npx playwright install chromium\n\nThen retry your qulib command. This is a one-time setup step.`);
|
|
10
|
+
}
|
|
11
|
+
throw err;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AppExplorer } from './explorer.interface.js';
|
|
2
|
+
import type { HarnessConfig } from '../../schemas/config.schema.js';
|
|
3
|
+
import type { RouteInventory } from '../../schemas/route-inventory.schema.js';
|
|
4
|
+
import type { RunArtifactsOptions } from '../../harness/run-options.js';
|
|
5
|
+
export declare class CypressExplorer implements AppExplorer {
|
|
6
|
+
explore(_baseUrl: string, _config: HarnessConfig, _artifacts?: RunArtifactsOptions): Promise<RouteInventory>;
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=cypress-explorer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cypress-explorer.d.ts","sourceRoot":"","sources":["../../../src/tools/explorers/cypress-explorer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AACpE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yCAAyC,CAAC;AAC9E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAExE,qBAAa,eAAgB,YAAW,WAAW;IAC3C,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,EAAE,UAAU,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC;CAGnH"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AppExplorer } from './types.js';
|
|
2
|
+
import type { HarnessConfig } from '../../schemas/config.schema.js';
|
|
3
|
+
import type { RouteInventory } from '../../schemas/route-inventory.schema.js';
|
|
4
|
+
import type { RunArtifactsOptions } from '../../harness/run-options.js';
|
|
5
|
+
export declare class CypressExplorer implements AppExplorer {
|
|
6
|
+
explore(_baseUrl: string, _config: HarnessConfig, _artifacts?: RunArtifactsOptions): Promise<RouteInventory>;
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=cypress.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cypress.d.ts","sourceRoot":"","sources":["../../../src/tools/explorers/cypress.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AACpE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yCAAyC,CAAC;AAC9E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAExE,qBAAa,eAAgB,YAAW,WAAW;IAC3C,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,EAAE,UAAU,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC;CAGnH"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { HarnessConfig } from '../../schemas/config.schema.js';
|
|
2
|
+
import type { RouteInventory } from '../../schemas/route-inventory.schema.js';
|
|
3
|
+
import type { RunArtifactsOptions } from '../../harness/run-options.js';
|
|
4
|
+
export interface AppExplorer {
|
|
5
|
+
explore(baseUrl: string, config: HarnessConfig, artifacts?: RunArtifactsOptions): Promise<RouteInventory>;
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=explorer.interface.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"explorer.interface.d.ts","sourceRoot":"","sources":["../../../src/tools/explorers/explorer.interface.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AACpE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yCAAyC,CAAC;AAC9E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAExE,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,SAAS,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CAC3G"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../../src/tools/explorers/factory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAI9C,wBAAgB,cAAc,CAAC,IAAI,EAAE,YAAY,GAAG,WAAW,CAS9D"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { PlaywrightExplorer } from './playwright.js';
|
|
2
|
+
import { CypressExplorer } from './cypress.js';
|
|
3
|
+
export function createExplorer(type) {
|
|
4
|
+
switch (type) {
|
|
5
|
+
case 'playwright':
|
|
6
|
+
return new PlaywrightExplorer();
|
|
7
|
+
case 'cypress':
|
|
8
|
+
return new CypressExplorer();
|
|
9
|
+
default:
|
|
10
|
+
throw new Error(`Unknown explorer type: ${type}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AppExplorer } from './explorer.interface.js';
|
|
2
|
+
import { type RouteInventory } from '../../schemas/route-inventory.schema.js';
|
|
3
|
+
import type { HarnessConfig } from '../../schemas/config.schema.js';
|
|
4
|
+
import type { RunArtifactsOptions } from '../../harness/run-options.js';
|
|
5
|
+
export declare class PlaywrightExplorer implements AppExplorer {
|
|
6
|
+
explore(baseUrl: string, config: HarnessConfig, artifacts?: RunArtifactsOptions): Promise<RouteInventory>;
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=playwright-explorer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"playwright-explorer.d.ts","sourceRoot":"","sources":["../../../src/tools/explorers/playwright-explorer.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE3D,OAAO,EAAwB,KAAK,cAAc,EAAc,MAAM,yCAAyC,CAAC;AAChH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AACpE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAoBxE,qBAAa,kBAAmB,YAAW,WAAW;IAC9C,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,SAAS,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC;CAsKhH"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { launchBrowser } from './browser.js';
|
|
2
|
+
import { AxeBuilder } from '@axe-core/playwright';
|
|
3
|
+
import { createAuthenticatedContext } from '../auth/apply.js';
|
|
4
|
+
import { RouteInventorySchema } from '../../schemas/route-inventory.schema.js';
|
|
5
|
+
function crawlHostKey(hostname) {
|
|
6
|
+
return hostname.replace(/^www\./i, '').toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
function isInternalHref(href, baseUrlStr) {
|
|
9
|
+
try {
|
|
10
|
+
const u = new URL(href);
|
|
11
|
+
const base = new URL(baseUrlStr);
|
|
12
|
+
return u.protocol === base.protocol && crawlHostKey(u.hostname) === crawlHostKey(base.hostname);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function debugMode() {
|
|
19
|
+
return process.env.QULIB_DEBUG === '1';
|
|
20
|
+
}
|
|
21
|
+
export class PlaywrightExplorer {
|
|
22
|
+
async explore(baseUrl, config, artifacts) {
|
|
23
|
+
const progress = artifacts?.progressLog;
|
|
24
|
+
const browser = await launchBrowser();
|
|
25
|
+
let context;
|
|
26
|
+
try {
|
|
27
|
+
context = await createAuthenticatedContext(browser, config.auth, config.timeoutMs);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
await browser.close();
|
|
31
|
+
throw new Error(`Authentication failed: ${String(err)}. Check your auth config and credentials.`);
|
|
32
|
+
}
|
|
33
|
+
if (config.auth) {
|
|
34
|
+
const label = config.auth.type === 'form-login' ? config.auth.credentials.username : 'storage-state';
|
|
35
|
+
progress?.info(`Authenticated context: ${label}`);
|
|
36
|
+
if (!progress) {
|
|
37
|
+
process.stderr.write(`[qulib] authenticated as ${label}\n`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const visited = new Set();
|
|
41
|
+
const queue = [baseUrl];
|
|
42
|
+
const routes = [];
|
|
43
|
+
let budgetExceeded = false;
|
|
44
|
+
try {
|
|
45
|
+
while (queue.length > 0) {
|
|
46
|
+
if (visited.size >= config.maxPagesToScan) {
|
|
47
|
+
budgetExceeded = queue.length > 0;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
const url = queue.shift();
|
|
51
|
+
if (!url) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const normalized = url.split('?')[0].split('#')[0];
|
|
55
|
+
if (visited.has(normalized))
|
|
56
|
+
continue;
|
|
57
|
+
visited.add(normalized);
|
|
58
|
+
const page = await context.newPage();
|
|
59
|
+
const consoleErrors = [];
|
|
60
|
+
page.on('console', (msg) => {
|
|
61
|
+
if (msg.type() === 'error') {
|
|
62
|
+
consoleErrors.push(msg.text());
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
const navResponse = await page.goto(url, {
|
|
67
|
+
timeout: config.timeoutMs,
|
|
68
|
+
waitUntil: 'domcontentloaded',
|
|
69
|
+
});
|
|
70
|
+
const httpStatus = navResponse?.status() ?? 0;
|
|
71
|
+
if (debugMode()) {
|
|
72
|
+
const html = await page.content();
|
|
73
|
+
progress?.debug(`page HTML byteLength=${Buffer.byteLength(html, 'utf8')} url=${normalized}`);
|
|
74
|
+
}
|
|
75
|
+
const pageTitle = await page.title();
|
|
76
|
+
const formCount = await page.locator('form').count();
|
|
77
|
+
const buttonLabels = await page.locator('button').allInnerTexts();
|
|
78
|
+
const hrefs = await page.evaluate(() => Array.from(document.querySelectorAll('a[href]'))
|
|
79
|
+
.map((a) => a.href)
|
|
80
|
+
.filter(Boolean));
|
|
81
|
+
const internalLinks = hrefs
|
|
82
|
+
.filter((href) => isInternalHref(href, baseUrl))
|
|
83
|
+
.map((href) => href.split('?')[0].split('#')[0]);
|
|
84
|
+
const uniqueInternal = [...new Set(internalLinks)];
|
|
85
|
+
for (const link of uniqueInternal) {
|
|
86
|
+
if (!visited.has(link) && !queue.includes(link)) {
|
|
87
|
+
queue.push(link);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const brokenLinks = [];
|
|
91
|
+
for (const link of uniqueInternal.slice(0, 20)) {
|
|
92
|
+
try {
|
|
93
|
+
const response = await page.request.head(link, { timeout: 5000 });
|
|
94
|
+
if (response.status() >= 400) {
|
|
95
|
+
brokenLinks.push({ url: link, status: response.status() });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
brokenLinks.push({ url: link, status: null, reason: String(err) });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
let a11yViolations = [];
|
|
103
|
+
try {
|
|
104
|
+
const axeResults = await new AxeBuilder({ page })
|
|
105
|
+
.withTags(['wcag2a', 'wcag2aa'])
|
|
106
|
+
.analyze();
|
|
107
|
+
if (debugMode()) {
|
|
108
|
+
progress?.debug(`raw axe violations (pre-map) count=${axeResults.violations.length} json=${JSON.stringify(axeResults.violations)}`);
|
|
109
|
+
}
|
|
110
|
+
a11yViolations = axeResults.violations.map((v) => ({
|
|
111
|
+
id: v.id,
|
|
112
|
+
impact: v.impact ?? 'unknown',
|
|
113
|
+
helpUrl: v.helpUrl,
|
|
114
|
+
nodeCount: v.nodes.length,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
consoleErrors.push(`axe-core failure: ${String(err)}`);
|
|
119
|
+
}
|
|
120
|
+
const path = new URL(url).pathname || '/';
|
|
121
|
+
progress?.info(`Crawled ${normalized} status=${httpStatus} a11yViolations=${a11yViolations.length}`);
|
|
122
|
+
routes.push({
|
|
123
|
+
path,
|
|
124
|
+
pageTitle,
|
|
125
|
+
links: uniqueInternal,
|
|
126
|
+
formCount,
|
|
127
|
+
buttonLabels: buttonLabels.map((b) => b.trim()).filter(Boolean),
|
|
128
|
+
consoleErrors,
|
|
129
|
+
brokenLinks,
|
|
130
|
+
a11yViolations,
|
|
131
|
+
statusCode: httpStatus,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const path = (() => {
|
|
136
|
+
try {
|
|
137
|
+
return new URL(url).pathname || '/';
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return url;
|
|
141
|
+
}
|
|
142
|
+
})();
|
|
143
|
+
progress?.info(`Crawled ${normalized} status=error a11yViolations=0 err=${String(err).slice(0, 120)}`);
|
|
144
|
+
routes.push({
|
|
145
|
+
path,
|
|
146
|
+
pageTitle: '',
|
|
147
|
+
links: [],
|
|
148
|
+
formCount: 0,
|
|
149
|
+
buttonLabels: [],
|
|
150
|
+
consoleErrors: [`Navigation error: ${String(err)}`],
|
|
151
|
+
brokenLinks: [],
|
|
152
|
+
a11yViolations: [],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
await page.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
await context.close();
|
|
162
|
+
await browser.close();
|
|
163
|
+
}
|
|
164
|
+
return RouteInventorySchema.parse({
|
|
165
|
+
scannedAt: new Date().toISOString(),
|
|
166
|
+
baseUrl,
|
|
167
|
+
routes,
|
|
168
|
+
pagesSkipped: budgetExceeded ? queue.length : 0,
|
|
169
|
+
budgetExceeded,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AppExplorer } from './types.js';
|
|
2
|
+
import { type RouteInventory } from '../../schemas/route-inventory.schema.js';
|
|
3
|
+
import type { HarnessConfig } from '../../schemas/config.schema.js';
|
|
4
|
+
import type { RunArtifactsOptions } from '../../harness/run-options.js';
|
|
5
|
+
export declare class PlaywrightExplorer implements AppExplorer {
|
|
6
|
+
explore(baseUrl: string, config: HarnessConfig, artifacts?: RunArtifactsOptions): Promise<RouteInventory>;
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=playwright.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"playwright.d.ts","sourceRoot":"","sources":["../../../src/tools/explorers/playwright.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,OAAO,EAAwB,KAAK,cAAc,EAAc,MAAM,yCAAyC,CAAC;AAChH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AACpE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAoBxE,qBAAa,kBAAmB,YAAW,WAAW;IAC9C,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,SAAS,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC;CAsKhH"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { launchBrowser } from './browser.js';
|
|
2
|
+
import { AxeBuilder } from '@axe-core/playwright';
|
|
3
|
+
import { createAuthenticatedContext } from '../auth/apply.js';
|
|
4
|
+
import { RouteInventorySchema } from '../../schemas/route-inventory.schema.js';
|
|
5
|
+
function crawlHostKey(hostname) {
|
|
6
|
+
return hostname.replace(/^www\./i, '').toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
function isInternalHref(href, baseUrlStr) {
|
|
9
|
+
try {
|
|
10
|
+
const u = new URL(href);
|
|
11
|
+
const base = new URL(baseUrlStr);
|
|
12
|
+
return u.protocol === base.protocol && crawlHostKey(u.hostname) === crawlHostKey(base.hostname);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function debugMode() {
|
|
19
|
+
return process.env.QULIB_DEBUG === '1';
|
|
20
|
+
}
|
|
21
|
+
export class PlaywrightExplorer {
|
|
22
|
+
async explore(baseUrl, config, artifacts) {
|
|
23
|
+
const progress = artifacts?.progressLog;
|
|
24
|
+
const browser = await launchBrowser();
|
|
25
|
+
let context;
|
|
26
|
+
try {
|
|
27
|
+
context = await createAuthenticatedContext(browser, config.auth, config.timeoutMs);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
await browser.close();
|
|
31
|
+
throw new Error(`Authentication failed: ${String(err)}. Check your auth config and credentials.`);
|
|
32
|
+
}
|
|
33
|
+
if (config.auth) {
|
|
34
|
+
const label = config.auth.type === 'form-login' ? config.auth.credentials.username : 'storage-state';
|
|
35
|
+
progress?.info(`Authenticated context: ${label}`);
|
|
36
|
+
if (!progress) {
|
|
37
|
+
process.stderr.write(`[qulib] authenticated as ${label}\n`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const visited = new Set();
|
|
41
|
+
const queue = [baseUrl];
|
|
42
|
+
const routes = [];
|
|
43
|
+
let budgetExceeded = false;
|
|
44
|
+
try {
|
|
45
|
+
while (queue.length > 0) {
|
|
46
|
+
if (visited.size >= config.maxPagesToScan) {
|
|
47
|
+
budgetExceeded = queue.length > 0;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
const url = queue.shift();
|
|
51
|
+
if (!url) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const normalized = url.split('?')[0].split('#')[0];
|
|
55
|
+
if (visited.has(normalized))
|
|
56
|
+
continue;
|
|
57
|
+
visited.add(normalized);
|
|
58
|
+
const page = await context.newPage();
|
|
59
|
+
const consoleErrors = [];
|
|
60
|
+
page.on('console', (msg) => {
|
|
61
|
+
if (msg.type() === 'error') {
|
|
62
|
+
consoleErrors.push(msg.text());
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
const navResponse = await page.goto(url, {
|
|
67
|
+
timeout: config.timeoutMs,
|
|
68
|
+
waitUntil: 'domcontentloaded',
|
|
69
|
+
});
|
|
70
|
+
const httpStatus = navResponse?.status() ?? 0;
|
|
71
|
+
if (debugMode()) {
|
|
72
|
+
const html = await page.content();
|
|
73
|
+
progress?.debug(`page HTML byteLength=${Buffer.byteLength(html, 'utf8')} url=${normalized}`);
|
|
74
|
+
}
|
|
75
|
+
const pageTitle = await page.title();
|
|
76
|
+
const formCount = await page.locator('form').count();
|
|
77
|
+
const buttonLabels = await page.locator('button').allInnerTexts();
|
|
78
|
+
const hrefs = await page.evaluate(() => Array.from(document.querySelectorAll('a[href]'))
|
|
79
|
+
.map((a) => a.href)
|
|
80
|
+
.filter(Boolean));
|
|
81
|
+
const internalLinks = hrefs
|
|
82
|
+
.filter((href) => isInternalHref(href, baseUrl))
|
|
83
|
+
.map((href) => href.split('?')[0].split('#')[0]);
|
|
84
|
+
const uniqueInternal = [...new Set(internalLinks)];
|
|
85
|
+
for (const link of uniqueInternal) {
|
|
86
|
+
if (!visited.has(link) && !queue.includes(link)) {
|
|
87
|
+
queue.push(link);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const brokenLinks = [];
|
|
91
|
+
for (const link of uniqueInternal.slice(0, 20)) {
|
|
92
|
+
try {
|
|
93
|
+
const response = await page.request.head(link, { timeout: 5000 });
|
|
94
|
+
if (response.status() >= 400) {
|
|
95
|
+
brokenLinks.push({ url: link, status: response.status() });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
brokenLinks.push({ url: link, status: null, reason: String(err) });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
let a11yViolations = [];
|
|
103
|
+
try {
|
|
104
|
+
const axeResults = await new AxeBuilder({ page })
|
|
105
|
+
.withTags(['wcag2a', 'wcag2aa'])
|
|
106
|
+
.analyze();
|
|
107
|
+
if (debugMode()) {
|
|
108
|
+
progress?.debug(`raw axe violations (pre-map) count=${axeResults.violations.length} json=${JSON.stringify(axeResults.violations)}`);
|
|
109
|
+
}
|
|
110
|
+
a11yViolations = axeResults.violations.map((v) => ({
|
|
111
|
+
id: v.id,
|
|
112
|
+
impact: v.impact ?? 'unknown',
|
|
113
|
+
helpUrl: v.helpUrl,
|
|
114
|
+
nodeCount: v.nodes.length,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
consoleErrors.push(`axe-core failure: ${String(err)}`);
|
|
119
|
+
}
|
|
120
|
+
const path = new URL(url).pathname || '/';
|
|
121
|
+
progress?.info(`Crawled ${normalized} status=${httpStatus} a11yViolations=${a11yViolations.length}`);
|
|
122
|
+
routes.push({
|
|
123
|
+
path,
|
|
124
|
+
pageTitle,
|
|
125
|
+
links: uniqueInternal,
|
|
126
|
+
formCount,
|
|
127
|
+
buttonLabels: buttonLabels.map((b) => b.trim()).filter(Boolean),
|
|
128
|
+
consoleErrors,
|
|
129
|
+
brokenLinks,
|
|
130
|
+
a11yViolations,
|
|
131
|
+
statusCode: httpStatus,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const path = (() => {
|
|
136
|
+
try {
|
|
137
|
+
return new URL(url).pathname || '/';
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return url;
|
|
141
|
+
}
|
|
142
|
+
})();
|
|
143
|
+
progress?.info(`Crawled ${normalized} status=error a11yViolations=0 err=${String(err).slice(0, 120)}`);
|
|
144
|
+
routes.push({
|
|
145
|
+
path,
|
|
146
|
+
pageTitle: '',
|
|
147
|
+
links: [],
|
|
148
|
+
formCount: 0,
|
|
149
|
+
buttonLabels: [],
|
|
150
|
+
consoleErrors: [`Navigation error: ${String(err)}`],
|
|
151
|
+
brokenLinks: [],
|
|
152
|
+
a11yViolations: [],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
await page.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
await context.close();
|
|
162
|
+
await browser.close();
|
|
163
|
+
}
|
|
164
|
+
return RouteInventorySchema.parse({
|
|
165
|
+
scannedAt: new Date().toISOString(),
|
|
166
|
+
baseUrl,
|
|
167
|
+
routes,
|
|
168
|
+
pagesSkipped: budgetExceeded ? queue.length : 0,
|
|
169
|
+
budgetExceeded,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { HarnessConfig } from '../../schemas/config.schema.js';
|
|
2
|
+
import type { RouteInventory } from '../../schemas/route-inventory.schema.js';
|
|
3
|
+
import type { RunArtifactsOptions } from '../../harness/run-options.js';
|
|
4
|
+
export interface AppExplorer {
|
|
5
|
+
explore(baseUrl: string, config: HarnessConfig, artifacts?: RunArtifactsOptions): Promise<RouteInventory>;
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/tools/explorers/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AACpE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yCAAyC,CAAC;AAC9E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAExE,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,SAAS,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CAC3G"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { launchBrowser } from './browser.js';
|
|
2
2
|
import { AxeBuilder } from '@axe-core/playwright';
|
|
3
|
-
import { createAuthenticatedContext } from './auth.js';
|
|
3
|
+
import { createAuthenticatedContext } from './apply-auth.js';
|
|
4
4
|
import { RouteInventorySchema } from '../schemas/route-inventory.schema.js';
|
|
5
5
|
function crawlHostKey(hostname) {
|
|
6
6
|
return hostname.replace(/^www\./i, '').toLowerCase();
|