@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.
Files changed (135) hide show
  1. package/README.md +56 -8
  2. package/dist/analyze.d.ts.map +1 -1
  3. package/dist/analyze.js +86 -7
  4. package/dist/cli/auth-login-resolve.d.ts +14 -0
  5. package/dist/cli/auth-login-resolve.d.ts.map +1 -0
  6. package/dist/cli/auth-login-resolve.js +68 -0
  7. package/dist/cli/auth-login-run.d.ts +13 -0
  8. package/dist/cli/auth-login-run.d.ts.map +1 -0
  9. package/dist/cli/auth-login-run.js +152 -0
  10. package/dist/cli/index.js +60 -7
  11. package/dist/harness/state-manager.d.ts +10 -0
  12. package/dist/harness/state-manager.d.ts.map +1 -1
  13. package/dist/harness/state-manager.js +15 -0
  14. package/dist/index.d.ts +8 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7 -6
  17. package/dist/phases/act.js +3 -3
  18. package/dist/phases/observe.js +5 -5
  19. package/dist/phases/think.js +1 -1
  20. package/dist/schemas/automation-maturity.schema.d.ts +40 -0
  21. package/dist/schemas/automation-maturity.schema.d.ts.map +1 -1
  22. package/dist/schemas/automation-maturity.schema.js +27 -0
  23. package/dist/schemas/index.d.ts +1 -1
  24. package/dist/schemas/index.d.ts.map +1 -1
  25. package/dist/schemas/index.js +1 -1
  26. package/dist/schemas/repo-analysis.schema.d.ts +22 -0
  27. package/dist/schemas/repo-analysis.schema.d.ts.map +1 -1
  28. package/dist/schemas/repo-analysis.schema.js +1 -0
  29. package/dist/telemetry/emit.d.ts +22 -0
  30. package/dist/telemetry/emit.d.ts.map +1 -1
  31. package/dist/telemetry/emit.js +37 -0
  32. package/dist/telemetry/telemetry.interface.d.ts +1 -1
  33. package/dist/telemetry/telemetry.interface.d.ts.map +1 -1
  34. package/dist/tools/apply-auth.d.ts +4 -0
  35. package/dist/tools/apply-auth.d.ts.map +1 -0
  36. package/dist/tools/apply-auth.js +35 -0
  37. package/dist/tools/auth/apply.d.ts +4 -0
  38. package/dist/tools/auth/apply.d.ts.map +1 -0
  39. package/dist/tools/auth/apply.js +35 -0
  40. package/dist/tools/auth/block-gap.d.ts +9 -0
  41. package/dist/tools/auth/block-gap.d.ts.map +1 -0
  42. package/dist/tools/auth/block-gap.js +52 -0
  43. package/dist/tools/auth/custom-providers.d.ts +15 -0
  44. package/dist/tools/auth/custom-providers.d.ts.map +1 -0
  45. package/dist/tools/auth/custom-providers.js +62 -0
  46. package/dist/tools/auth/detect.d.ts +23 -0
  47. package/dist/tools/auth/detect.d.ts.map +1 -0
  48. package/dist/tools/auth/detect.js +526 -0
  49. package/dist/tools/auth/detector.d.ts +23 -0
  50. package/dist/tools/auth/detector.d.ts.map +1 -0
  51. package/dist/tools/auth/detector.js +526 -0
  52. package/dist/tools/auth/explore.d.ts +4 -0
  53. package/dist/tools/auth/explore.d.ts.map +1 -0
  54. package/dist/tools/auth/explore.js +346 -0
  55. package/dist/tools/auth/explorer.d.ts +4 -0
  56. package/dist/tools/auth/explorer.d.ts.map +1 -0
  57. package/dist/tools/auth/explorer.js +346 -0
  58. package/dist/tools/auth/gaps.d.ts +9 -0
  59. package/dist/tools/auth/gaps.d.ts.map +1 -0
  60. package/dist/tools/auth/gaps.js +52 -0
  61. package/dist/tools/auth/oauth-providers.d.ts +7 -0
  62. package/dist/tools/auth/oauth-providers.d.ts.map +1 -0
  63. package/dist/tools/auth/oauth-providers.js +21 -0
  64. package/dist/tools/auth/providers.d.ts +7 -0
  65. package/dist/tools/auth/providers.d.ts.map +1 -0
  66. package/dist/tools/auth/providers.js +21 -0
  67. package/dist/tools/auth/surface-analyzer.d.ts +4 -0
  68. package/dist/tools/auth/surface-analyzer.d.ts.map +1 -0
  69. package/dist/tools/auth/surface-analyzer.js +170 -0
  70. package/dist/tools/auth/surface.d.ts +4 -0
  71. package/dist/tools/auth/surface.d.ts.map +1 -0
  72. package/dist/tools/auth/surface.js +170 -0
  73. package/dist/tools/auth/user-providers.d.ts +15 -0
  74. package/dist/tools/auth/user-providers.d.ts.map +1 -0
  75. package/dist/tools/auth/user-providers.js +62 -0
  76. package/dist/tools/auth-block-gap.d.ts +6 -0
  77. package/dist/tools/auth-block-gap.d.ts.map +1 -1
  78. package/dist/tools/auth-block-gap.js +42 -9
  79. package/dist/tools/auth-detector.d.ts +19 -0
  80. package/dist/tools/auth-detector.d.ts.map +1 -1
  81. package/dist/tools/auth-detector.js +186 -8
  82. package/dist/tools/automation-maturity.d.ts.map +1 -1
  83. package/dist/tools/automation-maturity.js +76 -20
  84. package/dist/tools/explorers/browser.d.ts +3 -0
  85. package/dist/tools/explorers/browser.d.ts.map +1 -0
  86. package/dist/tools/explorers/browser.js +13 -0
  87. package/dist/tools/explorers/cypress-explorer.d.ts +8 -0
  88. package/dist/tools/explorers/cypress-explorer.d.ts.map +1 -0
  89. package/dist/tools/explorers/cypress-explorer.js +5 -0
  90. package/dist/tools/explorers/cypress.d.ts +8 -0
  91. package/dist/tools/explorers/cypress.d.ts.map +1 -0
  92. package/dist/tools/explorers/cypress.js +5 -0
  93. package/dist/tools/explorers/explorer.interface.d.ts +7 -0
  94. package/dist/tools/explorers/explorer.interface.d.ts.map +1 -0
  95. package/dist/tools/explorers/explorer.interface.js +1 -0
  96. package/dist/tools/explorers/factory.d.ts +4 -0
  97. package/dist/tools/explorers/factory.d.ts.map +1 -0
  98. package/dist/tools/explorers/factory.js +12 -0
  99. package/dist/tools/explorers/playwright-explorer.d.ts +8 -0
  100. package/dist/tools/explorers/playwright-explorer.d.ts.map +1 -0
  101. package/dist/tools/explorers/playwright-explorer.js +172 -0
  102. package/dist/tools/explorers/playwright.d.ts +8 -0
  103. package/dist/tools/explorers/playwright.d.ts.map +1 -0
  104. package/dist/tools/explorers/playwright.js +172 -0
  105. package/dist/tools/explorers/types.d.ts +7 -0
  106. package/dist/tools/explorers/types.d.ts.map +1 -0
  107. package/dist/tools/explorers/types.js +1 -0
  108. package/dist/tools/playwright-explorer.js +1 -1
  109. package/dist/tools/repo/detect-framework.d.ts +15 -0
  110. package/dist/tools/repo/detect-framework.d.ts.map +1 -0
  111. package/dist/tools/repo/detect-framework.js +153 -0
  112. package/dist/tools/repo/framework-detector.d.ts +15 -0
  113. package/dist/tools/repo/framework-detector.d.ts.map +1 -0
  114. package/dist/tools/repo/framework-detector.js +153 -0
  115. package/dist/tools/repo/scan.d.ts +19 -0
  116. package/dist/tools/repo/scan.d.ts.map +1 -0
  117. package/dist/tools/repo/scan.js +181 -0
  118. package/dist/tools/repo/scanner.d.ts +19 -0
  119. package/dist/tools/repo/scanner.d.ts.map +1 -0
  120. package/dist/tools/repo/scanner.js +181 -0
  121. package/dist/tools/repo-scanner.d.ts.map +1 -1
  122. package/dist/tools/repo-scanner.js +7 -2
  123. package/dist/tools/scoring/automation-maturity.d.ts +4 -0
  124. package/dist/tools/scoring/automation-maturity.d.ts.map +1 -0
  125. package/dist/tools/scoring/automation-maturity.js +219 -0
  126. package/dist/tools/scoring/gap-engine.d.ts +8 -0
  127. package/dist/tools/scoring/gap-engine.d.ts.map +1 -0
  128. package/dist/tools/scoring/gap-engine.js +138 -0
  129. package/dist/tools/scoring/gaps.d.ts +8 -0
  130. package/dist/tools/scoring/gaps.d.ts.map +1 -0
  131. package/dist/tools/scoring/gaps.js +138 -0
  132. package/dist/tools/scoring/public-surface.d.ts +5 -0
  133. package/dist/tools/scoring/public-surface.d.ts.map +1 -0
  134. package/dist/tools/scoring/public-surface.js +13 -0
  135. 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 hygienePenalty = Math.min(100, repo.missingTestIds.length * 6);
101
- const hygieneScore = Math.max(0, 100 - hygienePenalty);
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
- `${repo.missingTestIds.length} TSX file(s) with interactive markup but no data-testid (heuristic scan).`,
108
- ],
109
- recommendations: hygieneScore >= 85 ? [] : ['Add stable data-testid (or role-based selectors) on interactive components used in tests.'],
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 authScore = authCovered ? 90 : 25;
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: authCovered
127
- ? ['At least one test references /login, /auth, or /signin in coveredPaths.']
128
- : ['No obvious auth-route coverage in extracted test path strings.'],
129
- recommendations: authCovered ? [] : ['Add focused tests for sign-in and post-auth landing behavior.'],
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
- const compRatioScore = cypressTotal === 0 ? 50 : Math.round((100 * cypressComp) / cypressTotal);
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
- `Cypress e2e files (matched): ${cypressE2e}, component: ${cypressComp}.`,
141
- ],
142
- recommendations: cypressComp === 0 || cypressTotal === 0
143
- ? []
144
- : ['Balance component vs E2E Cypress tests so critical flows stay fast in CI.'],
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
- const overallScore = Math.round(dimensions.reduce((s, d) => s + d.score * d.weight, 0));
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 = [...dimensions]
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,3 @@
1
+ import { type Browser } from '@playwright/test';
2
+ export declare function launchBrowser(): Promise<Browser>;
3
+ //# sourceMappingURL=browser.d.ts.map
@@ -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,5 @@
1
+ export class CypressExplorer {
2
+ async explore(_baseUrl, _config, _artifacts) {
3
+ throw new Error('Not implemented');
4
+ }
5
+ }
@@ -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,5 @@
1
+ export class CypressExplorer {
2
+ async explore(_baseUrl, _config, _artifacts) {
3
+ throw new Error('Not implemented');
4
+ }
5
+ }
@@ -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,4 @@
1
+ import type { ExplorerType } from '../../schemas/config.schema.js';
2
+ import type { AppExplorer } from './types.js';
3
+ export declare function createExplorer(type: ExplorerType): AppExplorer;
4
+ //# sourceMappingURL=factory.d.ts.map
@@ -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();