@qulib/core 0.4.0 → 0.4.2

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 (42) hide show
  1. package/README.md +22 -0
  2. package/dist/analyze.js +2 -2
  3. package/dist/cli/auth-login-resolve.d.ts +14 -0
  4. package/dist/cli/auth-login-resolve.d.ts.map +1 -0
  5. package/dist/cli/auth-login-resolve.js +68 -0
  6. package/dist/cli/auth-login-run.d.ts +13 -0
  7. package/dist/cli/auth-login-run.d.ts.map +1 -0
  8. package/dist/cli/auth-login-run.js +128 -0
  9. package/dist/cli/index.js +51 -1
  10. package/dist/harness/state-manager.d.ts +10 -0
  11. package/dist/harness/state-manager.d.ts.map +1 -1
  12. package/dist/harness/state-manager.js +15 -0
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -1
  16. package/dist/phases/act.js +3 -3
  17. package/dist/phases/observe.js +3 -3
  18. package/dist/schemas/automation-maturity.schema.d.ts +40 -0
  19. package/dist/schemas/automation-maturity.schema.d.ts.map +1 -1
  20. package/dist/schemas/automation-maturity.schema.js +27 -0
  21. package/dist/schemas/config.schema.d.ts +229 -73
  22. package/dist/schemas/config.schema.d.ts.map +1 -1
  23. package/dist/schemas/config.schema.js +19 -18
  24. package/dist/schemas/index.d.ts +1 -1
  25. package/dist/schemas/index.d.ts.map +1 -1
  26. package/dist/schemas/index.js +1 -1
  27. package/dist/schemas/repo-analysis.schema.d.ts +22 -0
  28. package/dist/schemas/repo-analysis.schema.d.ts.map +1 -1
  29. package/dist/schemas/repo-analysis.schema.js +1 -0
  30. package/dist/telemetry/emit.d.ts +22 -0
  31. package/dist/telemetry/emit.d.ts.map +1 -1
  32. package/dist/telemetry/emit.js +37 -0
  33. package/dist/tools/auth-detector.d.ts +18 -0
  34. package/dist/tools/auth-detector.d.ts.map +1 -1
  35. package/dist/tools/auth-detector.js +287 -28
  36. package/dist/tools/auth-surface-analyzer.d.ts.map +1 -1
  37. package/dist/tools/auth-surface-analyzer.js +26 -10
  38. package/dist/tools/automation-maturity.d.ts.map +1 -1
  39. package/dist/tools/automation-maturity.js +76 -20
  40. package/dist/tools/repo-scanner.d.ts.map +1 -1
  41. package/dist/tools/repo-scanner.js +7 -2
  42. package/package.json +12 -2
@@ -1,4 +1,22 @@
1
+ import type { Page } from '@playwright/test';
1
2
  import type { DetectedAuth } from '../schemas/config.schema.js';
2
3
  import type { AnalyzeProgressSink } from '../harness/progress-log.js';
4
+ export declare function evaluateStorageStateValidity(signals: {
5
+ expectedOrigin: string;
6
+ finalUrl: string;
7
+ visiblePasswordCount: number;
8
+ hadUnauthorizedHttp: boolean;
9
+ }): {
10
+ valid: boolean;
11
+ reason: string;
12
+ };
13
+ export declare function waitForReturnToOrigin(page: Page, baseUrl: string, timeoutMs?: number): Promise<{
14
+ returned: boolean;
15
+ finalUrl: string;
16
+ }>;
17
+ export declare function validateStorageState(url: string, storagePath: string, timeoutMs?: number): Promise<{
18
+ valid: boolean;
19
+ reason: string;
20
+ }>;
3
21
  export declare function detectAuth(url: string, timeoutMs?: number, progress?: AnalyzeProgressSink): Promise<DetectedAuth>;
4
22
  //# sourceMappingURL=auth-detector.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth-detector.d.ts","sourceRoot":"","sources":["../../src/tools/auth-detector.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAgEtE,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,SAAS,SAAQ,EACjB,QAAQ,CAAC,EAAE,mBAAmB,GAC7B,OAAO,CAAC,YAAY,CAAC,CA8HvB"}
1
+ {"version":3,"file":"auth-detector.d.ts","sourceRoot":"","sources":["../../src/tools/auth-detector.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,EAAY,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC1E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAwQtE,wBAAgB,4BAA4B,CAAC,OAAO,EAAE;IACpD,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,mBAAmB,EAAE,OAAO,CAAC;CAC9B,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAWrC;AAED,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,MAAM,EACf,SAAS,SAAQ,GAChB,OAAO,CAAC;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAelD;AAED,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,EACnB,SAAS,SAAQ,GAChB,OAAO,CAAC;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA+B7C;AAED,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,SAAS,SAAQ,EACjB,QAAQ,CAAC,EAAE,mBAAmB,GAC7B,OAAO,CAAC,YAAY,CAAC,CAqJvB"}
@@ -1,4 +1,6 @@
1
+ import { resolve } from 'node:path';
1
2
  import { launchBrowser } from './browser.js';
3
+ import { BUILT_IN_OAUTH_PROVIDERS } from './oauth-providers.js';
2
4
  async function waitNetworkIdleBestEffort(page) {
3
5
  try {
4
6
  await page.waitForLoadState('networkidle', { timeout: 5000 });
@@ -7,27 +9,20 @@ async function waitNetworkIdleBestEffort(page) {
7
9
  // best-effort — analytics or polling can prevent networkidle
8
10
  }
9
11
  }
10
- const OAUTH_PROVIDERS = [
11
- { provider: 'github', patterns: [/github/i, /sign in with github/i] },
12
- {
13
- provider: 'google',
14
- patterns: [/google/i, /sign in with google/i, /accounts\.google\.com/i],
15
- },
16
- {
17
- provider: 'microsoft',
18
- patterns: [/microsoft/i, /sign in with microsoft/i, /login\.microsoftonline\.com/i],
19
- },
20
- { provider: 'apple', patterns: [/apple/i, /sign in with apple/i] },
21
- { provider: 'auth0', patterns: [/auth0/i] },
22
- { provider: 'okta', patterns: [/okta/i] },
23
- ];
12
+ const PROVIDER_LABELS = new Set(BUILT_IN_OAUTH_PROVIDERS.map((p) => p.label.toLowerCase()));
24
13
  function textLooksLikeOAuthIdpButton(text) {
25
14
  const t = text.trim();
26
15
  if (t.length === 0 || t.length > 120) {
27
16
  return false;
28
17
  }
29
- return (/\b(sign in with|log in with|continue with|sign up with)\b/i.test(t) ||
30
- /^(github|google|microsoft|apple)$/i.test(t));
18
+ if (/\b(sign in with|log in with|continue with|sign up with)\b/i.test(t)) {
19
+ return true;
20
+ }
21
+ // Accept single-word / short labels that exactly match a known provider name
22
+ if (PROVIDER_LABELS.has(t.toLowerCase())) {
23
+ return true;
24
+ }
25
+ return false;
31
26
  }
32
27
  const MAGIC_LINK_PATTERNS = [
33
28
  /email me a (sign[- ]?in )?link/i,
@@ -53,6 +48,250 @@ async function firstTextInputNameForLogin(page) {
53
48
  function debugAuth() {
54
49
  return process.env.QULIB_DEBUG === '1';
55
50
  }
51
+ function slugify(label) {
52
+ const s = label
53
+ .toLowerCase()
54
+ .replace(/\s+/g, '-')
55
+ .replace(/[^a-z0-9-]+/g, '')
56
+ .replace(/^-+|-+$/g, '');
57
+ return s.length > 0 ? s : 'custom';
58
+ }
59
+ function escapeRegExp(s) {
60
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
61
+ }
62
+ async function resolveVisibleFieldLabel(page, el) {
63
+ const id = await el.getAttribute('id');
64
+ if (id) {
65
+ const lt = await page.locator(`label[for="${id.replace(/"/g, '\\"')}"]`).first().textContent().catch(() => null);
66
+ const fromLabel = (lt ?? '').trim();
67
+ if (fromLabel)
68
+ return fromLabel;
69
+ }
70
+ const placeholder = (await el.getAttribute('placeholder'))?.trim();
71
+ if (placeholder)
72
+ return placeholder;
73
+ const aria = (await el.getAttribute('aria-label'))?.trim();
74
+ if (aria)
75
+ return aria;
76
+ const name = (await el.getAttribute('name'))?.trim();
77
+ if (name)
78
+ return name;
79
+ const typ = (await el.getAttribute('type'))?.trim();
80
+ return typ && typ !== 'select' ? typ : 'text';
81
+ }
82
+ async function deriveCredentialFieldName(el) {
83
+ const name = (await el.getAttribute('name'))?.trim();
84
+ if (name)
85
+ return name;
86
+ const placeholder = (await el.getAttribute('placeholder'))?.trim();
87
+ if (placeholder)
88
+ return slugify(placeholder);
89
+ const aria = (await el.getAttribute('aria-label'))?.trim();
90
+ if (aria)
91
+ return slugify(aria);
92
+ const id = (await el.getAttribute('id'))?.trim();
93
+ if (id)
94
+ return slugify(id);
95
+ return 'field';
96
+ }
97
+ async function buildCredentialFieldsFromVisibleForm(page) {
98
+ const fields = [];
99
+ const seen = new Set();
100
+ const loc = page.locator('input[type="text"]:visible, input[type="email"]:visible, input[type="password"]:visible, select:visible');
101
+ const count = await loc.count();
102
+ for (let i = 0; i < count; i++) {
103
+ const el = loc.nth(i);
104
+ const tag = await el.evaluate((node) => node.tagName.toLowerCase()).catch(() => '');
105
+ if (tag === 'select') {
106
+ const name = await deriveCredentialFieldName(el);
107
+ const label = await resolveVisibleFieldLabel(page, el);
108
+ const opts = await el.locator('option').allInnerTexts();
109
+ const observedOptions = opts.map((o) => o.trim()).filter((x) => x.length > 0).slice(0, 20);
110
+ const dedupeKey = `select|${name}|${label}`;
111
+ if (seen.has(dedupeKey))
112
+ continue;
113
+ seen.add(dedupeKey);
114
+ fields.push({ name, label, type: 'select', observedOptions });
115
+ continue;
116
+ }
117
+ const rawType = ((await el.getAttribute('type')) ?? 'text').toLowerCase();
118
+ if (rawType === 'hidden')
119
+ continue;
120
+ const fieldType = rawType === 'email' ? 'email' : rawType === 'password' ? 'password' : 'text';
121
+ const name = await deriveCredentialFieldName(el);
122
+ const label = await resolveVisibleFieldLabel(page, el);
123
+ const placeholder = (await el.getAttribute('placeholder'))?.trim() ?? '';
124
+ const dedupeKey = `${name}|${placeholder}`;
125
+ if (seen.has(dedupeKey))
126
+ continue;
127
+ seen.add(dedupeKey);
128
+ fields.push({ name, label, type: fieldType, observedOptions: [] });
129
+ }
130
+ return fields;
131
+ }
132
+ function authPathsFromOauthButtons(oauthButtons, loginUrl) {
133
+ return oauthButtons.map((b) => {
134
+ const isUnknown = b.provider === 'unknown';
135
+ const id = isUnknown ? slugify(b.text) : b.provider;
136
+ return {
137
+ id,
138
+ label: b.text,
139
+ type: isUnknown ? 'oauth-unknown' : 'oauth',
140
+ provider: isUnknown ? slugify(b.text) : b.provider,
141
+ source: isUnknown ? 'heuristic' : 'built-in',
142
+ automatable: false,
143
+ confidence: isUnknown ? 'low' : 'high',
144
+ requirements: {
145
+ method: 'storage-state',
146
+ instruction: `Run qulib auth init --base-url ${loginUrl}`,
147
+ },
148
+ };
149
+ });
150
+ }
151
+ async function probeClickToRevealForms(page, loginUrl, alreadyMatchedTexts, timeoutMs, progress) {
152
+ const out = [];
153
+ const buttons = page.locator('button');
154
+ const n = await buttons.count();
155
+ const seenLabels = new Set();
156
+ const SUBMIT_RE = /^(sign in|log in|submit|continue|next|cancel|close)$/i;
157
+ let candidateAttempts = 0;
158
+ for (let i = 0; i < n && candidateAttempts < 4; i++) {
159
+ const label = ((await buttons.nth(i).textContent()) ?? '').trim();
160
+ if (!label || label.length > 80)
161
+ continue;
162
+ if (alreadyMatchedTexts.has(label))
163
+ continue;
164
+ if (SUBMIT_RE.test(label))
165
+ continue;
166
+ if (seenLabels.has(label))
167
+ continue;
168
+ seenLabels.add(label);
169
+ candidateAttempts += 1;
170
+ const originBefore = new URL(page.url()).origin;
171
+ if (debugAuth()) {
172
+ progress?.debug(`detect_auth click-reveal try label="${label.slice(0, 80)}"`);
173
+ }
174
+ let clicked = false;
175
+ try {
176
+ await page.getByRole('button', { name: label, exact: true }).first().click({ timeout: 2000 });
177
+ clicked = true;
178
+ }
179
+ catch {
180
+ try {
181
+ await page
182
+ .locator('button')
183
+ .filter({ hasText: new RegExp(`^\\s*${escapeRegExp(label)}\\s*$`, 'i') })
184
+ .first()
185
+ .click({ timeout: 2000 });
186
+ clicked = true;
187
+ }
188
+ catch {
189
+ /* skip */
190
+ }
191
+ }
192
+ if (!clicked) {
193
+ continue;
194
+ }
195
+ try {
196
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 });
197
+ }
198
+ catch {
199
+ /* best-effort after navigation */
200
+ }
201
+ if (new URL(page.url()).origin !== originBefore) {
202
+ if (debugAuth()) {
203
+ progress?.debug(`detect_auth click-reveal aborted (cross-origin after click): was ${originBefore} now ${new URL(page.url()).origin}`);
204
+ }
205
+ await page.goto(loginUrl, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
206
+ await waitNetworkIdleBestEffort(page);
207
+ continue;
208
+ }
209
+ try {
210
+ await page.locator('input[type="password"]:visible').first().waitFor({ state: 'visible', timeout: 2000 });
211
+ }
212
+ catch {
213
+ await page.goto(loginUrl, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
214
+ await waitNetworkIdleBestEffort(page);
215
+ continue;
216
+ }
217
+ const fields = await buildCredentialFieldsFromVisibleForm(page);
218
+ const slug = slugify(label);
219
+ out.push({
220
+ id: slug,
221
+ label,
222
+ type: 'form-login',
223
+ provider: slug,
224
+ source: 'heuristic',
225
+ automatable: true,
226
+ confidence: 'medium',
227
+ requirements: {
228
+ method: 'credentials',
229
+ fields,
230
+ },
231
+ });
232
+ await page.goto(loginUrl, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
233
+ await waitNetworkIdleBestEffort(page);
234
+ }
235
+ return out;
236
+ }
237
+ export function evaluateStorageStateValidity(signals) {
238
+ if (new URL(signals.finalUrl).origin !== signals.expectedOrigin) {
239
+ return { valid: false, reason: 'session redirected to external IdP' };
240
+ }
241
+ if (signals.visiblePasswordCount > 0) {
242
+ return { valid: false, reason: 'login form still visible after loading storage state' };
243
+ }
244
+ if (signals.hadUnauthorizedHttp) {
245
+ return { valid: false, reason: 'HTTP 401/403 on authenticated request' };
246
+ }
247
+ return { valid: true, reason: 'session appears active' };
248
+ }
249
+ export async function waitForReturnToOrigin(page, baseUrl, timeoutMs = 15000) {
250
+ const targetOrigin = new URL(baseUrl).origin;
251
+ const deadline = Date.now() + timeoutMs;
252
+ while (Date.now() < deadline) {
253
+ const finalUrl = page.url();
254
+ try {
255
+ if (new URL(finalUrl).origin === targetOrigin) {
256
+ return { returned: true, finalUrl };
257
+ }
258
+ }
259
+ catch {
260
+ /* ignore transient invalid URLs */
261
+ }
262
+ await new Promise((r) => setTimeout(r, 500));
263
+ }
264
+ return { returned: false, finalUrl: page.url() };
265
+ }
266
+ export async function validateStorageState(url, storagePath, timeoutMs = 10000) {
267
+ const storageAbs = resolve(process.cwd(), storagePath);
268
+ const expectedOrigin = new URL(url).origin;
269
+ let hadUnauthorizedHttp = false;
270
+ const browser = await launchBrowser();
271
+ try {
272
+ const context = await browser.newContext({ storageState: storageAbs });
273
+ const page = await context.newPage();
274
+ page.on('response', (res) => {
275
+ const s = res.status();
276
+ if (s === 401 || s === 403) {
277
+ hadUnauthorizedHttp = true;
278
+ }
279
+ });
280
+ await page.goto(url, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
281
+ await waitNetworkIdleBestEffort(page);
282
+ const finalUrl = page.url();
283
+ const visiblePasswordCount = await page.locator('input[type="password"]:visible').count();
284
+ return evaluateStorageStateValidity({
285
+ expectedOrigin,
286
+ finalUrl,
287
+ visiblePasswordCount,
288
+ hadUnauthorizedHttp,
289
+ });
290
+ }
291
+ finally {
292
+ await browser.close();
293
+ }
294
+ }
56
295
  export async function detectAuth(url, timeoutMs = 15000, progress) {
57
296
  const browser = await launchBrowser();
58
297
  try {
@@ -67,7 +306,7 @@ export async function detectAuth(url, timeoutMs = 15000, progress) {
67
306
  }
68
307
  let loginUrl = url;
69
308
  const looksLikeLoginPage = /login|sign[- ]?in|auth/i.test(page.url()) ||
70
- (await page.locator('input[type="password"]').count()) > 0;
309
+ (await page.locator('input[type="password"]:visible').count()) > 0;
71
310
  if (!looksLikeLoginPage) {
72
311
  const loginLink = page.locator('a').filter({ hasText: /^(log ?in|sign ?in|sign in)$/i }).first();
73
312
  const loginLinkCount = await loginLink.count();
@@ -82,7 +321,7 @@ export async function detectAuth(url, timeoutMs = 15000, progress) {
82
321
  }
83
322
  }
84
323
  }
85
- const passwordInputs = page.locator('input[type="password"]');
324
+ const passwordInputs = page.locator('input[type="password"]:visible');
86
325
  const passwordCount = await passwordInputs.count();
87
326
  progress?.debug(`detect_auth selector input[type=password] count=${passwordCount}`);
88
327
  const hasFormLogin = passwordCount > 0;
@@ -96,18 +335,33 @@ export async function detectAuth(url, timeoutMs = 15000, progress) {
96
335
  }
97
336
  continue;
98
337
  }
99
- for (const { provider, patterns } of OAUTH_PROVIDERS) {
338
+ let matchedAny = false;
339
+ for (const { id, patterns } of BUILT_IN_OAUTH_PROVIDERS) {
100
340
  const matched = patterns.some((p) => p.test(trimmed));
101
341
  if (debugAuth()) {
102
- progress?.debug(`detect_auth oauth pattern try provider=${provider} matched=${matched}`);
342
+ progress?.debug(`detect_auth oauth pattern try provider=${id} matched=${matched}`);
103
343
  }
104
344
  if (matched) {
105
- if (!oauthButtons.find((b) => b.provider === provider)) {
106
- oauthButtons.push({ provider, text: trimmed.slice(0, 100) });
345
+ if (!oauthButtons.find((b) => b.provider === id)) {
346
+ oauthButtons.push({ provider: id, text: trimmed.slice(0, 100) });
107
347
  }
348
+ matchedAny = true;
349
+ }
350
+ }
351
+ if (!matchedAny) {
352
+ const builtIn = BUILT_IN_OAUTH_PROVIDERS.find((p) => p.label.toLowerCase() === trimmed.toLowerCase());
353
+ if (builtIn) {
354
+ if (!oauthButtons.find((b) => b.provider === builtIn.id)) {
355
+ oauthButtons.push({ provider: builtIn.id, text: trimmed.slice(0, 100) });
356
+ }
357
+ }
358
+ else if (!oauthButtons.find((b) => b.text === trimmed.slice(0, 100))) {
359
+ oauthButtons.push({ provider: 'unknown', text: trimmed.slice(0, 100) });
108
360
  }
109
361
  }
110
362
  }
363
+ const skipProbeLabels = new Set(oauthButtons.map((b) => b.text.trim()));
364
+ const clickRevealForms = await probeClickToRevealForms(page, loginUrl, skipProbeLabels, timeoutMs, progress);
111
365
  const pageText = await page.locator('body').innerText().catch(() => '');
112
366
  const hasMagicLink = MAGIC_LINK_PATTERNS.some((p) => p.test(pageText));
113
367
  let type = 'none';
@@ -117,7 +371,7 @@ export async function detectAuth(url, timeoutMs = 15000, progress) {
117
371
  if (oauthButtons.length > 0) {
118
372
  type = 'oauth';
119
373
  provider = oauthButtons[0].provider;
120
- recommendation = `OAuth detected (${oauthButtons.map((b) => b.provider).join(', ')}). OAuth cannot be automated. Run "qulib auth init --base-url ${url}" to log in manually once and save a reusable storage state file.`;
374
+ recommendation = `OAuth detected (${oauthButtons.map((b) => b.provider).join(', ')}). OAuth cannot be automated. Run "qulib auth init --base-url ${loginUrl}" to log in manually once and save a reusable storage state file.`;
121
375
  }
122
376
  else if (hasFormLogin) {
123
377
  type = 'form-login';
@@ -140,26 +394,31 @@ export async function detectAuth(url, timeoutMs = 15000, progress) {
140
394
  }
141
395
  else if (hasMagicLink) {
142
396
  type = 'magic-link';
143
- recommendation = `Magic link / passwordless auth detected. Qulib cannot complete email-link flows. Run "qulib auth init --base-url ${url}" to log in manually once and save a storage state file.`;
397
+ recommendation = `Magic link / passwordless auth detected. Qulib cannot complete email-link flows. Run "qulib auth init --base-url ${loginUrl}" to log in manually once and save a storage state file.`;
144
398
  }
145
399
  else if (looksLikeLoginPage) {
146
400
  type = 'unknown';
147
- recommendation = `Authentication required but the pattern is unrecognized. Use "qulib auth init --base-url ${url}" to capture a storage state by logging in manually.`;
401
+ recommendation = `Authentication required but the pattern is unrecognized. Use "qulib auth init --base-url ${loginUrl}" to capture a storage state by logging in manually.`;
148
402
  }
149
403
  else {
150
404
  type = 'none';
151
405
  recommendation = `No authentication required for the entry URL. Qulib can scan anonymously.`;
152
406
  }
407
+ if (clickRevealForms.length > 0) {
408
+ recommendation += `\nAutomatable form login detected via: ${clickRevealForms.map((f) => f.label).join(', ')}. Use type="form-login" with the observed selectors in authOptions.`;
409
+ }
153
410
  const providerList = oauthButtons.length > 0 ? oauthButtons.map((b) => b.provider).join(', ') : provider ?? 'none';
154
- const automatable = type === 'form-login';
411
+ const automatable = type === 'form-login' || clickRevealForms.length > 0;
155
412
  progress?.info(`Auth detected: ${type} (${providerList}) automatable=${automatable}`);
413
+ const authOptions = [...authPathsFromOauthButtons(oauthButtons, loginUrl), ...clickRevealForms];
156
414
  return {
157
- hasAuth: type !== 'none',
415
+ hasAuth: type !== 'none' || oauthButtons.length > 0 || clickRevealForms.length > 0,
158
416
  type,
159
417
  provider,
160
- loginUrl: type === 'none' ? null : loginUrl,
418
+ loginUrl: type === 'none' && oauthButtons.length === 0 && clickRevealForms.length === 0 ? null : loginUrl,
161
419
  observedSelectors,
162
420
  oauthButtons,
421
+ ...(authOptions.length > 0 ? { authOptions } : {}),
163
422
  recommendation,
164
423
  };
165
424
  }
@@ -1 +1 @@
1
- {"version":3,"file":"auth-surface-analyzer.d.ts","sourceRoot":"","sources":["../../src/tools/auth-surface-analyzer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,mCAAmC,CAAC;AAW7D,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,YAAY,EACvB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,GAAG,EAAE,CAAC,CAuJhB"}
1
+ {"version":3,"file":"auth-surface-analyzer.d.ts","sourceRoot":"","sources":["../../src/tools/auth-surface-analyzer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,mCAAmC,CAAC;AAW7D,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,YAAY,EACvB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,GAAG,EAAE,CAAC,CAwKhB"}
@@ -110,16 +110,32 @@ export async function analyzeAuthSurfaceGaps(url, detection, timeoutMs) {
110
110
  const hasEmailLink = await page.getByText(/magic link|email.*link|passwordless/i).count();
111
111
  const hasOAuthUi = detection.oauthButtons.length > 0 ||
112
112
  (await page.getByText(/sign in with|continue with google|microsoft|github/i).count()) > 0;
113
- if (hasOAuthUi && !hasPassword && !hasEmailLink) {
114
- gaps.push({
115
- id: randomUUID(),
116
- path: '/',
117
- severity: 'medium',
118
- category: 'auth-surface',
119
- reason: 'OAuth-only entry with no visible password or magic-link fallback.',
120
- description: 'Users who cannot use a social IdP need another path (email/password, help, or support).',
121
- recommendation: 'Add a documented fallback (email/password, help desk link, or alternate IdP).',
122
- });
113
+ const formLoginFallbacks = (detection.authOptions ?? []).filter((o) => o.type === 'form-login');
114
+ const hasFormLoginFallback = formLoginFallbacks.length > 0;
115
+ if (detection.type === 'oauth' && hasOAuthUi && !hasPassword && !hasEmailLink) {
116
+ if (hasFormLoginFallback) {
117
+ const labels = formLoginFallbacks.map((o) => o.label).join(', ');
118
+ gaps.push({
119
+ id: randomUUID(),
120
+ path: '/',
121
+ severity: 'low',
122
+ category: 'auth-surface',
123
+ reason: `OAuth-primary login with form-login fallback detected via: ${labels}`,
124
+ description: 'A form-based login path exists alongside OAuth. Automate via type="form-login" using the selectors in authOptions.',
125
+ recommendation: `Automatable form option(s): ${labels}. Configure type="form-login" with credentials and selectors from detectedAuth.authOptions.`,
126
+ });
127
+ }
128
+ else {
129
+ gaps.push({
130
+ id: randomUUID(),
131
+ path: '/',
132
+ severity: 'medium',
133
+ category: 'auth-surface',
134
+ reason: 'OAuth-only entry with no visible password or magic-link fallback.',
135
+ description: 'Users who cannot use a social IdP need another path (email/password, help, or support).',
136
+ recommendation: 'Add a documented fallback (email/password, help desk link, or alternate IdP).',
137
+ });
138
+ }
123
139
  }
124
140
  const errorSelectors = '[role="alert"], [data-testid*="error" i], .error, .alert-danger, [class*="error" i]';
125
141
  const errCount = await page.locator(errorSelectors).count();
@@ -1 +1 @@
1
- {"version":3,"file":"automation-maturity.d.ts","sourceRoot":"","sources":["../../src/tools/automation-maturity.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oCAAoC,CAAC;AACvE,OAAO,KAAK,EAAE,kBAAkB,EAA+B,MAAM,0CAA0C,CAAC;AAiDhH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,YAAY,GAAG,kBAAkB,CA6HhF"}
1
+ {"version":3,"file":"automation-maturity.d.ts","sourceRoot":"","sources":["../../src/tools/automation-maturity.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oCAAoC,CAAC;AACvE,OAAO,KAAK,EACV,kBAAkB,EAGnB,MAAM,0CAA0C,CAAC;AAiDlD,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,YAAY,GAAG,kBAAkB,CAuLhF"}
@@ -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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"repo-scanner.d.ts","sourceRoot":"","sources":["../../src/tools/repo-scanner.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,EAAsB,KAAK,YAAY,EAAE,MAAM,oCAAoC,CAAC;AAmC3F,wBAAsB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAyItE"}
1
+ {"version":3,"file":"repo-scanner.d.ts","sourceRoot":"","sources":["../../src/tools/repo-scanner.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,EAAsB,KAAK,YAAY,EAAE,MAAM,oCAAoC,CAAC;AAmC3F,wBAAsB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CA8ItE"}
@@ -137,12 +137,16 @@ export async function scanRepo(repoPath) {
137
137
  ignore: [...IGNORE_PATTERNS, '**/*.spec.tsx'],
138
138
  });
139
139
  const missingTestIds = [];
140
+ let interactiveTsxFilesScanned = 0;
140
141
  for (const file of tsxFiles) {
141
142
  const rel = toPosix(relative(repoPath, file));
142
143
  const content = await readFile(file, 'utf8');
143
144
  const hasInteractive = content.includes('<button') || content.includes('<input') || content.includes('<a ');
144
- if (hasInteractive && !content.includes('data-testid')) {
145
- missingTestIds.push(rel);
145
+ if (hasInteractive) {
146
+ interactiveTsxFilesScanned += 1;
147
+ if (!content.includes('data-testid')) {
148
+ missingTestIds.push(rel);
149
+ }
146
150
  }
147
151
  }
148
152
  const base = {
@@ -151,6 +155,7 @@ export async function scanRepo(repoPath) {
151
155
  routes,
152
156
  testFiles,
153
157
  missingTestIds: [...new Set(missingTestIds)],
158
+ interactiveTsxFilesScanned,
154
159
  cypressStructure: {
155
160
  detected: cypressRoot.length > 0,
156
161
  e2eFolder: e2eFolder[0],