@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.
- package/README.md +22 -0
- package/dist/analyze.js +2 -2
- 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 +128 -0
- package/dist/cli/index.js +51 -1
- 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 +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/phases/act.js +3 -3
- package/dist/phases/observe.js +3 -3
- 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/config.schema.d.ts +229 -73
- package/dist/schemas/config.schema.d.ts.map +1 -1
- package/dist/schemas/config.schema.js +19 -18
- 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/tools/auth-detector.d.ts +18 -0
- package/dist/tools/auth-detector.d.ts.map +1 -1
- package/dist/tools/auth-detector.js +287 -28
- package/dist/tools/auth-surface-analyzer.d.ts.map +1 -1
- package/dist/tools/auth-surface-analyzer.js +26 -10
- package/dist/tools/automation-maturity.d.ts.map +1 -1
- package/dist/tools/automation-maturity.js +76 -20
- package/dist/tools/repo-scanner.d.ts.map +1 -1
- package/dist/tools/repo-scanner.js +7 -2
- 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;
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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=${
|
|
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 ===
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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,
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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,
|
|
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
|
|
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
|
}
|
|
@@ -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,
|
|
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
|
|
145
|
-
|
|
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],
|