@qulib/core 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/analyze.d.ts +4 -2
- package/dist/analyze.d.ts.map +1 -1
- package/dist/analyze.js +40 -0
- package/dist/cli/index.js +119 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/schemas/config.schema.d.ts +61 -0
- package/dist/schemas/config.schema.d.ts.map +1 -1
- package/dist/schemas/config.schema.js +18 -0
- package/dist/schemas/gap-analysis.schema.d.ts +6 -6
- package/dist/schemas/gap-analysis.schema.d.ts.map +1 -1
- package/dist/schemas/gap-analysis.schema.js +4 -2
- 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/tools/auth-detector.d.ts +3 -0
- package/dist/tools/auth-detector.d.ts.map +1 -0
- package/dist/tools/auth-detector.js +134 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,55 @@
|
|
|
8
8
|
npm install @qulib/core
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
## Scanning authenticated apps
|
|
12
|
+
|
|
13
|
+
Qulib supports three auth modes: anonymous (default), form-login, and storage-state.
|
|
14
|
+
|
|
15
|
+
### Form login
|
|
16
|
+
|
|
17
|
+
If your app uses a simple username/password form:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
qulib analyze --url https://app.example.com \
|
|
21
|
+
--auth-form-login \
|
|
22
|
+
--login-url https://app.example.com/login \
|
|
23
|
+
--username you@example.com \
|
|
24
|
+
--password "..." \
|
|
25
|
+
--username-selector "input[name=email]" \
|
|
26
|
+
--password-selector "input[name=password]" \
|
|
27
|
+
--submit-selector "button[type=submit]"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### OAuth, magic link, SSO, or anything else
|
|
31
|
+
|
|
32
|
+
These can't be automated. Qulib has a helper for this:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
qulib auth init --base-url https://app.example.com
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This opens a real browser. Log in normally (OAuth, magic link, password manager, whatever). Press ENTER in the terminal when you reach a logged-in page. Qulib saves your session to `qulib-storage-state.json`.
|
|
39
|
+
|
|
40
|
+
Then scan with it:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
qulib analyze --url https://app.example.com --auth-storage-state ./qulib-storage-state.json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The storage state is just a JSON file of cookies and localStorage — keep it private, treat it like a credential.
|
|
47
|
+
|
|
48
|
+
### Auth detection
|
|
49
|
+
|
|
50
|
+
To check what auth pattern a site uses before configuring anything:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
qulib detect-auth --url https://app.example.com
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or via MCP:
|
|
57
|
+
|
|
58
|
+
> "Use qulib's detect_auth tool on https://app.example.com — what's the recommended auth setup?"
|
|
59
|
+
|
|
11
60
|
## CLI (from npm)
|
|
12
61
|
|
|
13
62
|
```bash
|
package/dist/analyze.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { HarnessConfig } from './schemas/config.schema.js';
|
|
2
|
-
import type
|
|
1
|
+
import type { HarnessConfig, DetectedAuth } from './schemas/config.schema.js';
|
|
2
|
+
import { type GapAnalysis } from './schemas/gap-analysis.schema.js';
|
|
3
3
|
import type { RouteInventory } from './schemas/route-inventory.schema.js';
|
|
4
4
|
import type { RepoAnalysis } from './schemas/repo-analysis.schema.js';
|
|
5
5
|
import type { DecisionLogEntry } from './schemas/decision-log.schema.js';
|
|
@@ -8,6 +8,7 @@ export interface AnalyzeOptions {
|
|
|
8
8
|
repoPath?: string;
|
|
9
9
|
config: HarnessConfig;
|
|
10
10
|
writeArtifacts?: boolean;
|
|
11
|
+
skipAuthDetection?: boolean;
|
|
11
12
|
}
|
|
12
13
|
export interface AnalyzeResult {
|
|
13
14
|
releaseConfidence: number;
|
|
@@ -15,6 +16,7 @@ export interface AnalyzeResult {
|
|
|
15
16
|
routeInventory: RouteInventory;
|
|
16
17
|
repoInventory: RepoAnalysis | null;
|
|
17
18
|
decisionLog: DecisionLogEntry[];
|
|
19
|
+
detectedAuth?: DetectedAuth;
|
|
18
20
|
}
|
|
19
21
|
export declare function analyzeApp(options: AnalyzeOptions): Promise<AnalyzeResult>;
|
|
20
22
|
//# sourceMappingURL=analyze.d.ts.map
|
package/dist/analyze.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAC9E,OAAO,EAAqB,KAAK,WAAW,EAAE,MAAM,kCAAkC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AAMzE,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,aAAa,CAAC;IACtB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,aAAa;IAC5B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,WAAW,CAAC;IACzB,cAAc,EAAE,cAAc,CAAC;IAC/B,aAAa,EAAE,YAAY,GAAG,IAAI,CAAC;IACnC,WAAW,EAAE,gBAAgB,EAAE,CAAC;IAChC,YAAY,CAAC,EAAE,YAAY,CAAC;CAC7B;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CA0DhF"}
|
package/dist/analyze.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { GapAnalysisSchema } from './schemas/gap-analysis.schema.js';
|
|
1
2
|
import { observe } from './phases/observe.js';
|
|
2
3
|
import { think } from './phases/think.js';
|
|
3
4
|
import { act } from './phases/act.js';
|
|
5
|
+
import { detectAuth } from './tools/auth-detector.js';
|
|
4
6
|
export async function analyzeApp(options) {
|
|
5
7
|
const writeArtifacts = options.writeArtifacts ?? false;
|
|
6
8
|
const decisionLog = [];
|
|
@@ -8,6 +10,44 @@ export async function analyzeApp(options) {
|
|
|
8
10
|
writeArtifacts,
|
|
9
11
|
decisionMemory: decisionLog,
|
|
10
12
|
};
|
|
13
|
+
if (!options.config.auth && !options.skipAuthDetection) {
|
|
14
|
+
const detection = await detectAuth(options.url, options.config.timeoutMs);
|
|
15
|
+
if (detection.hasAuth) {
|
|
16
|
+
const gapAnalysis = GapAnalysisSchema.parse({
|
|
17
|
+
analyzedAt: new Date().toISOString(),
|
|
18
|
+
mode: 'auth-required',
|
|
19
|
+
releaseConfidence: 0,
|
|
20
|
+
coveragePagesScanned: 0,
|
|
21
|
+
coverageBudgetExceeded: false,
|
|
22
|
+
coverageWarning: 'auth-required',
|
|
23
|
+
gaps: [],
|
|
24
|
+
scenarios: [],
|
|
25
|
+
generatedTests: [],
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
releaseConfidence: 0,
|
|
29
|
+
gapAnalysis,
|
|
30
|
+
routeInventory: {
|
|
31
|
+
scannedAt: new Date().toISOString(),
|
|
32
|
+
baseUrl: options.url,
|
|
33
|
+
routes: [],
|
|
34
|
+
pagesSkipped: 0,
|
|
35
|
+
budgetExceeded: false,
|
|
36
|
+
},
|
|
37
|
+
repoInventory: null,
|
|
38
|
+
decisionLog: [
|
|
39
|
+
{
|
|
40
|
+
timestamp: new Date().toISOString(),
|
|
41
|
+
phase: 'observe',
|
|
42
|
+
decision: 'auth-required',
|
|
43
|
+
reason: detection.recommendation,
|
|
44
|
+
metadata: { detection },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
detectedAuth: detection,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
11
51
|
const observed = await observe(options.url, options.repoPath, options.config, artifacts);
|
|
12
52
|
const analysis = await think(observed, options.config, artifacts);
|
|
13
53
|
await act(analysis, options.config, artifacts);
|
package/dist/cli/index.js
CHANGED
|
@@ -5,8 +5,17 @@ import { pathToFileURL } from 'node:url';
|
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { HarnessConfigSchema } from '../schemas/config.schema.js';
|
|
7
7
|
import { analyzeApp } from '../analyze.js';
|
|
8
|
+
import { detectAuth } from '../tools/auth-detector.js';
|
|
8
9
|
const program = new Command();
|
|
9
10
|
const AnalyzeUrlSchema = z.string().url();
|
|
11
|
+
const FormLoginCliSchema = z.object({
|
|
12
|
+
loginUrl: z.string().url(),
|
|
13
|
+
username: z.string().min(1),
|
|
14
|
+
password: z.string(),
|
|
15
|
+
usernameSelector: z.string().min(1),
|
|
16
|
+
passwordSelector: z.string().min(1),
|
|
17
|
+
submitSelector: z.string().min(1),
|
|
18
|
+
});
|
|
10
19
|
async function loadConfigFile(relativePath) {
|
|
11
20
|
const configPath = resolve(process.cwd(), relativePath);
|
|
12
21
|
const configModule = await import(pathToFileURL(configPath).href);
|
|
@@ -25,10 +34,47 @@ function redactConfigForLog(config) {
|
|
|
25
34
|
}
|
|
26
35
|
return base;
|
|
27
36
|
}
|
|
37
|
+
function mergeAuthFromCli(config, options) {
|
|
38
|
+
if (options.authStorageState && options.authFormLogin) {
|
|
39
|
+
throw new Error('Use either --auth-storage-state or --auth-form-login, not both.');
|
|
40
|
+
}
|
|
41
|
+
if (options.authStorageState) {
|
|
42
|
+
return {
|
|
43
|
+
...config,
|
|
44
|
+
auth: { type: 'storage-state', path: options.authStorageState },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (options.authFormLogin) {
|
|
48
|
+
const parsed = FormLoginCliSchema.parse({
|
|
49
|
+
loginUrl: options.loginUrl,
|
|
50
|
+
username: options.username,
|
|
51
|
+
password: options.password,
|
|
52
|
+
usernameSelector: options.usernameSelector,
|
|
53
|
+
passwordSelector: options.passwordSelector,
|
|
54
|
+
submitSelector: options.submitSelector,
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
...config,
|
|
58
|
+
auth: {
|
|
59
|
+
type: 'form-login',
|
|
60
|
+
loginUrl: parsed.loginUrl,
|
|
61
|
+
credentials: { username: parsed.username, password: parsed.password },
|
|
62
|
+
selectors: {
|
|
63
|
+
username: parsed.usernameSelector,
|
|
64
|
+
password: parsed.passwordSelector,
|
|
65
|
+
submit: parsed.submitSelector,
|
|
66
|
+
},
|
|
67
|
+
successIndicator: {},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return config;
|
|
72
|
+
}
|
|
28
73
|
async function runAnalyze(options) {
|
|
29
74
|
const validatedUrl = AnalyzeUrlSchema.parse(options.url);
|
|
30
75
|
const mode = options.repo ? 'url-repo' : 'url-only';
|
|
31
|
-
const
|
|
76
|
+
const baseConfig = await loadConfigFile(options.configFile ?? 'qulib.config.ts');
|
|
77
|
+
const config = mergeAuthFromCli(baseConfig, options);
|
|
32
78
|
const ephemeral = options.ephemeral ?? false;
|
|
33
79
|
const writeArtifacts = !ephemeral;
|
|
34
80
|
if (ephemeral) {
|
|
@@ -50,6 +96,7 @@ async function runAnalyze(options) {
|
|
|
50
96
|
discoveredRoutes: result.routeInventory,
|
|
51
97
|
repoInventory: result.repoInventory,
|
|
52
98
|
decisionLog: result.decisionLog,
|
|
99
|
+
...(result.detectedAuth !== undefined && { detectedAuth: result.detectedAuth }),
|
|
53
100
|
}, null, 2));
|
|
54
101
|
}
|
|
55
102
|
}
|
|
@@ -87,16 +134,86 @@ program
|
|
|
87
134
|
.option('--config <file>', 'Path to config file (relative to cwd)', 'qulib.config.ts')
|
|
88
135
|
.option('--adapter <type>', 'Override default test adapter (playwright, cypress-e2e, cypress-component, api)', 'playwright')
|
|
89
136
|
.option('--ephemeral', 'Do not write to disk — return full report as JSON on stdout (use for MCP/CI)', false)
|
|
137
|
+
.option('--auth-storage-state <path>', 'Path to a storage state JSON file (use after `qulib auth init`)')
|
|
138
|
+
.option('--auth-form-login', 'Use form-login; requires --login-url, credentials, and selectors', false)
|
|
139
|
+
.option('--login-url <url>', 'Form login page URL (required with --auth-form-login)')
|
|
140
|
+
.option('--username <user>', 'Form login username')
|
|
141
|
+
.option('--password <secret>', 'Form login password')
|
|
142
|
+
.option('--username-selector <sel>', 'Selector for username field')
|
|
143
|
+
.option('--password-selector <sel>', 'Selector for password field')
|
|
144
|
+
.option('--submit-selector <sel>', 'Selector for submit control')
|
|
90
145
|
.action(async (options) => {
|
|
146
|
+
const authFormLogin = Boolean(options.authFormLogin);
|
|
147
|
+
const loginUrl = options.loginUrl;
|
|
148
|
+
if (!authFormLogin && loginUrl !== undefined) {
|
|
149
|
+
throw new Error('--login-url is only valid with --auth-form-login');
|
|
150
|
+
}
|
|
151
|
+
if (authFormLogin && loginUrl === undefined) {
|
|
152
|
+
throw new Error('--auth-form-login requires --login-url');
|
|
153
|
+
}
|
|
91
154
|
await runAnalyze({
|
|
92
155
|
url: options.url,
|
|
93
156
|
repo: options.repo,
|
|
94
157
|
configFile: options.config,
|
|
95
158
|
ephemeral: options.ephemeral,
|
|
159
|
+
authStorageState: options.authStorageState,
|
|
160
|
+
authFormLogin,
|
|
161
|
+
loginUrl,
|
|
162
|
+
username: options.username,
|
|
163
|
+
password: options.password,
|
|
164
|
+
usernameSelector: options.usernameSelector,
|
|
165
|
+
passwordSelector: options.passwordSelector,
|
|
166
|
+
submitSelector: options.submitSelector,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
program
|
|
170
|
+
.command('detect-auth')
|
|
171
|
+
.description('Detect the authentication pattern used by a deployed web app')
|
|
172
|
+
.requiredOption('--url <url>', 'URL of the app or login page')
|
|
173
|
+
.option('--timeout <ms>', 'Page load timeout in ms', '15000')
|
|
174
|
+
.action(async (options) => {
|
|
175
|
+
const result = await detectAuth(options.url, parseInt(options.timeout, 10));
|
|
176
|
+
console.log(JSON.stringify(result, null, 2));
|
|
177
|
+
});
|
|
178
|
+
const authCmd = program.command('auth').description('Authentication helpers for scans');
|
|
179
|
+
authCmd
|
|
180
|
+
.command('init')
|
|
181
|
+
.description('Open a browser, let the user log in manually, save the storage state to a file for reuse')
|
|
182
|
+
.requiredOption('--base-url <url>', 'The base URL of the app to log into')
|
|
183
|
+
.option('--out <path>', 'Output file path for the storage state JSON', './qulib-storage-state.json')
|
|
184
|
+
.option('--timeout <ms>', 'Maximum time to wait for the user to finish logging in (default 5 min)', '300000')
|
|
185
|
+
.action(async (options) => {
|
|
186
|
+
const { chromium } = await import('@playwright/test');
|
|
187
|
+
const browser = await chromium.launch({ headless: false });
|
|
188
|
+
const context = await browser.newContext();
|
|
189
|
+
const page = await context.newPage();
|
|
190
|
+
const timeoutMs = parseInt(options.timeout, 10);
|
|
191
|
+
console.log(`\n[qulib] Opening ${options.baseUrl}`);
|
|
192
|
+
console.log('[qulib] Log in normally in the browser window that just opened.');
|
|
193
|
+
console.log('[qulib] After you reach a logged-in state, return to this terminal and press ENTER.');
|
|
194
|
+
console.log(`[qulib] You have ${timeoutMs / 1000}s before timeout.\n`);
|
|
195
|
+
await page.goto(options.baseUrl);
|
|
196
|
+
await new Promise((resolve, reject) => {
|
|
197
|
+
const timer = setTimeout(() => reject(new Error('Timed out waiting for user')), timeoutMs);
|
|
198
|
+
process.stdin.once('data', () => {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
resolve();
|
|
201
|
+
});
|
|
202
|
+
process.stdin.resume();
|
|
96
203
|
});
|
|
204
|
+
const fs = await import('node:fs/promises');
|
|
205
|
+
const pathMod = await import('node:path');
|
|
206
|
+
const outPath = pathMod.resolve(options.out);
|
|
207
|
+
await context.storageState({ path: outPath });
|
|
208
|
+
console.log(`\n[qulib] Saved storage state to ${outPath}`);
|
|
209
|
+
console.log('[qulib] To use it, pass to qulib like:');
|
|
210
|
+
console.log(` qulib analyze --url ${options.baseUrl} --auth-storage-state ${outPath}`);
|
|
211
|
+
console.log(`[qulib] Or in MCP, pass auth: { type: 'storage-state', path: '${outPath}' }`);
|
|
212
|
+
await browser.close();
|
|
213
|
+
process.exit(0);
|
|
97
214
|
});
|
|
98
215
|
program.parseAsync().catch((error) => {
|
|
99
216
|
const message = error instanceof Error ? error.message : String(error);
|
|
100
|
-
console.error('[qulib]
|
|
217
|
+
console.error('[qulib] Failed:', message);
|
|
101
218
|
process.exit(1);
|
|
102
219
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { analyzeApp } from './analyze.js';
|
|
2
|
+
export { detectAuth } from './tools/auth-detector.js';
|
|
2
3
|
export type { AnalyzeOptions, AnalyzeResult } from './analyze.js';
|
|
3
|
-
export type { HarnessConfig, AuthConfig, RouteInventory, GapAnalysis, RepoAnalysis, } from './schemas/index.js';
|
|
4
|
+
export type { HarnessConfig, AuthConfig, RouteInventory, GapAnalysis, RepoAnalysis, DetectedAuth, } from './schemas/index.js';
|
|
4
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClE,YAAY,EACV,aAAa,EACb,UAAU,EACV,cAAc,EACd,WAAW,EACX,YAAY,GACb,MAAM,oBAAoB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClE,YAAY,EACV,aAAa,EACb,UAAU,EACV,cAAc,EACd,WAAW,EACX,YAAY,EACZ,YAAY,GACb,MAAM,oBAAoB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -323,5 +323,66 @@ export declare const HarnessConfigSchema: z.ZodObject<{
|
|
|
323
323
|
} | undefined;
|
|
324
324
|
}>;
|
|
325
325
|
export type HarnessConfig = z.infer<typeof HarnessConfigSchema>;
|
|
326
|
+
export declare const DetectedAuthSchema: z.ZodObject<{
|
|
327
|
+
hasAuth: z.ZodBoolean;
|
|
328
|
+
type: z.ZodEnum<["none", "form-login", "oauth", "magic-link", "unknown"]>;
|
|
329
|
+
provider: z.ZodNullable<z.ZodString>;
|
|
330
|
+
loginUrl: z.ZodNullable<z.ZodString>;
|
|
331
|
+
observedSelectors: z.ZodNullable<z.ZodObject<{
|
|
332
|
+
usernameSelector: z.ZodNullable<z.ZodString>;
|
|
333
|
+
passwordSelector: z.ZodNullable<z.ZodString>;
|
|
334
|
+
submitSelector: z.ZodNullable<z.ZodString>;
|
|
335
|
+
}, "strip", z.ZodTypeAny, {
|
|
336
|
+
usernameSelector: string | null;
|
|
337
|
+
passwordSelector: string | null;
|
|
338
|
+
submitSelector: string | null;
|
|
339
|
+
}, {
|
|
340
|
+
usernameSelector: string | null;
|
|
341
|
+
passwordSelector: string | null;
|
|
342
|
+
submitSelector: string | null;
|
|
343
|
+
}>>;
|
|
344
|
+
oauthButtons: z.ZodArray<z.ZodObject<{
|
|
345
|
+
provider: z.ZodString;
|
|
346
|
+
text: z.ZodString;
|
|
347
|
+
}, "strip", z.ZodTypeAny, {
|
|
348
|
+
provider: string;
|
|
349
|
+
text: string;
|
|
350
|
+
}, {
|
|
351
|
+
provider: string;
|
|
352
|
+
text: string;
|
|
353
|
+
}>, "many">;
|
|
354
|
+
recommendation: z.ZodString;
|
|
355
|
+
}, "strip", z.ZodTypeAny, {
|
|
356
|
+
type: "unknown" | "form-login" | "none" | "oauth" | "magic-link";
|
|
357
|
+
loginUrl: string | null;
|
|
358
|
+
hasAuth: boolean;
|
|
359
|
+
provider: string | null;
|
|
360
|
+
observedSelectors: {
|
|
361
|
+
usernameSelector: string | null;
|
|
362
|
+
passwordSelector: string | null;
|
|
363
|
+
submitSelector: string | null;
|
|
364
|
+
} | null;
|
|
365
|
+
oauthButtons: {
|
|
366
|
+
provider: string;
|
|
367
|
+
text: string;
|
|
368
|
+
}[];
|
|
369
|
+
recommendation: string;
|
|
370
|
+
}, {
|
|
371
|
+
type: "unknown" | "form-login" | "none" | "oauth" | "magic-link";
|
|
372
|
+
loginUrl: string | null;
|
|
373
|
+
hasAuth: boolean;
|
|
374
|
+
provider: string | null;
|
|
375
|
+
observedSelectors: {
|
|
376
|
+
usernameSelector: string | null;
|
|
377
|
+
passwordSelector: string | null;
|
|
378
|
+
submitSelector: string | null;
|
|
379
|
+
} | null;
|
|
380
|
+
oauthButtons: {
|
|
381
|
+
provider: string;
|
|
382
|
+
text: string;
|
|
383
|
+
}[];
|
|
384
|
+
recommendation: string;
|
|
385
|
+
}>;
|
|
386
|
+
export type DetectedAuth = z.infer<typeof DetectedAuthSchema>;
|
|
326
387
|
export {};
|
|
327
388
|
//# sourceMappingURL=config.schema.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.schema.d.ts","sourceRoot":"","sources":["../../src/schemas/config.schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,MAAM,MAAM,YAAY,GAAG,YAAY,GAAG,SAAS,CAAC;AACpD,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,aAAa,GAAG,mBAAmB,GAAG,KAAK,GAAG,eAAe,CAAC;AAEvG,QAAA,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAgBvB,CAAC;AAEH,QAAA,MAAM,sBAAsB;;;;;;;;;EAG1B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAA8E,CAAC;AAE5G,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AACtE,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAC5E,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAe9B,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC"}
|
|
1
|
+
{"version":3,"file":"config.schema.d.ts","sourceRoot":"","sources":["../../src/schemas/config.schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,MAAM,MAAM,YAAY,GAAG,YAAY,GAAG,SAAS,CAAC;AACpD,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,aAAa,GAAG,mBAAmB,GAAG,KAAK,GAAG,eAAe,CAAC;AAEvG,QAAA,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAgBvB,CAAC;AAEH,QAAA,MAAM,sBAAsB;;;;;;;;;EAG1B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAA8E,CAAC;AAE5G,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AACtE,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAC5E,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAe9B,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAmB7B,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC"}
|
|
@@ -37,3 +37,21 @@ export const HarnessConfigSchema = z.object({
|
|
|
37
37
|
adapters: z.array(z.enum(['playwright', 'cypress-e2e', 'cypress-component', 'api', 'accessibility'])).default(['playwright']),
|
|
38
38
|
auth: AuthConfigSchema.optional(),
|
|
39
39
|
});
|
|
40
|
+
export const DetectedAuthSchema = z.object({
|
|
41
|
+
hasAuth: z.boolean(),
|
|
42
|
+
type: z.enum(['none', 'form-login', 'oauth', 'magic-link', 'unknown']),
|
|
43
|
+
provider: z.string().nullable(),
|
|
44
|
+
loginUrl: z.string().nullable(),
|
|
45
|
+
observedSelectors: z
|
|
46
|
+
.object({
|
|
47
|
+
usernameSelector: z.string().nullable(),
|
|
48
|
+
passwordSelector: z.string().nullable(),
|
|
49
|
+
submitSelector: z.string().nullable(),
|
|
50
|
+
})
|
|
51
|
+
.nullable(),
|
|
52
|
+
oauthButtons: z.array(z.object({
|
|
53
|
+
provider: z.string(),
|
|
54
|
+
text: z.string(),
|
|
55
|
+
})),
|
|
56
|
+
recommendation: z.string(),
|
|
57
|
+
});
|
|
@@ -147,11 +147,11 @@ export declare const GeneratedTestSchema: z.ZodObject<{
|
|
|
147
147
|
}>;
|
|
148
148
|
export declare const GapAnalysisSchema: z.ZodObject<{
|
|
149
149
|
analyzedAt: z.ZodString;
|
|
150
|
-
mode: z.ZodEnum<["url-only", "url-repo"]>;
|
|
150
|
+
mode: z.ZodEnum<["url-only", "url-repo", "auth-required"]>;
|
|
151
151
|
releaseConfidence: z.ZodNumber;
|
|
152
152
|
coveragePagesScanned: z.ZodNumber;
|
|
153
153
|
coverageBudgetExceeded: z.ZodBoolean;
|
|
154
|
-
coverageWarning: z.ZodOptional<z.ZodEnum<["budget-exceeded", "low-coverage", "navigation-failures"]>>;
|
|
154
|
+
coverageWarning: z.ZodOptional<z.ZodEnum<["budget-exceeded", "low-coverage", "navigation-failures", "auth-required"]>>;
|
|
155
155
|
gaps: z.ZodArray<z.ZodObject<{
|
|
156
156
|
id: z.ZodString;
|
|
157
157
|
path: z.ZodString;
|
|
@@ -271,7 +271,7 @@ export declare const GapAnalysisSchema: z.ZodObject<{
|
|
|
271
271
|
}>, "many">;
|
|
272
272
|
}, "strip", z.ZodTypeAny, {
|
|
273
273
|
analyzedAt: string;
|
|
274
|
-
mode: "url-only" | "url-repo";
|
|
274
|
+
mode: "url-only" | "url-repo" | "auth-required";
|
|
275
275
|
releaseConfidence: number;
|
|
276
276
|
coveragePagesScanned: number;
|
|
277
277
|
coverageBudgetExceeded: boolean;
|
|
@@ -310,10 +310,10 @@ export declare const GapAnalysisSchema: z.ZodObject<{
|
|
|
310
310
|
source: "llm" | "template";
|
|
311
311
|
outputPath: string;
|
|
312
312
|
}[];
|
|
313
|
-
coverageWarning?: "budget-exceeded" | "low-coverage" | "navigation-failures" | undefined;
|
|
313
|
+
coverageWarning?: "auth-required" | "budget-exceeded" | "low-coverage" | "navigation-failures" | undefined;
|
|
314
314
|
}, {
|
|
315
315
|
analyzedAt: string;
|
|
316
|
-
mode: "url-only" | "url-repo";
|
|
316
|
+
mode: "url-only" | "url-repo" | "auth-required";
|
|
317
317
|
releaseConfidence: number;
|
|
318
318
|
coveragePagesScanned: number;
|
|
319
319
|
coverageBudgetExceeded: boolean;
|
|
@@ -352,7 +352,7 @@ export declare const GapAnalysisSchema: z.ZodObject<{
|
|
|
352
352
|
source: "llm" | "template";
|
|
353
353
|
outputPath: string;
|
|
354
354
|
}[];
|
|
355
|
-
coverageWarning?: "budget-exceeded" | "low-coverage" | "navigation-failures" | undefined;
|
|
355
|
+
coverageWarning?: "auth-required" | "budget-exceeded" | "low-coverage" | "navigation-failures" | undefined;
|
|
356
356
|
}>;
|
|
357
357
|
export type GapAnalysis = z.infer<typeof GapAnalysisSchema>;
|
|
358
358
|
export type Gap = z.infer<typeof GapSchema>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gap-analysis.schema.d.ts","sourceRoot":"","sources":["../../src/schemas/gap-analysis.schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;EAMpB,CAAC;AAEH,eAAO,MAAM,6BAA6B;;;;;;;;;;;;EAIxC,CAAC;AAEH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;EAgBzB,CAAC;AAEH,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAUhC,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;EAO9B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"gap-analysis.schema.d.ts","sourceRoot":"","sources":["../../src/schemas/gap-analysis.schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;EAMpB,CAAC;AAEH,eAAO,MAAM,6BAA6B;;;;;;;;;;;;EAIxC,CAAC;AAEH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;EAgBzB,CAAC;AAEH,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAUhC,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;EAO9B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAY5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAC5D,MAAM,MAAM,GAAG,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,SAAS,CAAC,CAAC;AAC5C,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AACpE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAChE,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AACtD,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,6BAA6B,CAAC,CAAC"}
|
|
@@ -49,11 +49,13 @@ export const GeneratedTestSchema = z.object({
|
|
|
49
49
|
});
|
|
50
50
|
export const GapAnalysisSchema = z.object({
|
|
51
51
|
analyzedAt: z.string().datetime(),
|
|
52
|
-
mode: z.enum(['url-only', 'url-repo']),
|
|
52
|
+
mode: z.enum(['url-only', 'url-repo', 'auth-required']),
|
|
53
53
|
releaseConfidence: z.number().min(0).max(100),
|
|
54
54
|
coveragePagesScanned: z.number().int().min(0),
|
|
55
55
|
coverageBudgetExceeded: z.boolean(),
|
|
56
|
-
coverageWarning: z
|
|
56
|
+
coverageWarning: z
|
|
57
|
+
.enum(['budget-exceeded', 'low-coverage', 'navigation-failures', 'auth-required'])
|
|
58
|
+
.optional(),
|
|
57
59
|
gaps: z.array(GapSchema),
|
|
58
60
|
scenarios: z.array(NeutralScenarioSchema),
|
|
59
61
|
generatedTests: z.array(GeneratedTestSchema),
|
package/dist/schemas/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { HarnessConfigSchema, AuthConfigSchema, type ExplorerType, type AdapterType, type FormLoginAuthConfig, type StorageStateAuthConfig, type AuthConfig, type HarnessConfig, } from './config.schema.js';
|
|
1
|
+
export { HarnessConfigSchema, AuthConfigSchema, DetectedAuthSchema, type ExplorerType, type AdapterType, type FormLoginAuthConfig, type StorageStateAuthConfig, type AuthConfig, type HarnessConfig, type DetectedAuth, } from './config.schema.js';
|
|
2
2
|
export { DecisionLogEntrySchema, type DecisionLogEntry, } from './decision-log.schema.js';
|
|
3
3
|
export { RouteInventorySchema, RouteSchema, A11yViolationSchema, BrokenLinkSchema, type RouteInventory, type Route, } from './route-inventory.schema.js';
|
|
4
4
|
export { GapAnalysisSchema, GapSchema, NeutralScenarioSchema, GeneratedTestSchema, TestStepSchema, FrameworkRecommendationSchema, type GapAnalysis, type Gap, type NeutralScenario, type GeneratedTest, type TestStep, type FrameworkRecommendation, } from './gap-analysis.schema.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/schemas/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,gBAAgB,EAChB,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,UAAU,EACf,KAAK,aAAa,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/schemas/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,gBAAgB,EAChB,kBAAkB,EAClB,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,sBAAsB,EACtB,KAAK,gBAAgB,GACtB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,oBAAoB,EACpB,WAAW,EACX,mBAAmB,EACnB,gBAAgB,EAChB,KAAK,cAAc,EACnB,KAAK,KAAK,GACX,MAAM,6BAA6B,CAAC;AACrC,OAAO,EACL,iBAAiB,EACjB,SAAS,EACT,qBAAqB,EACrB,mBAAmB,EACnB,cAAc,EACd,6BAA6B,EAC7B,KAAK,WAAW,EAChB,KAAK,GAAG,EACR,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,KAAK,uBAAuB,GAC7B,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,kBAAkB,EAClB,KAAK,YAAY,GAClB,MAAM,2BAA2B,CAAC"}
|
package/dist/schemas/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { HarnessConfigSchema, AuthConfigSchema, } from './config.schema.js';
|
|
1
|
+
export { HarnessConfigSchema, AuthConfigSchema, DetectedAuthSchema, } from './config.schema.js';
|
|
2
2
|
export { DecisionLogEntrySchema, } from './decision-log.schema.js';
|
|
3
3
|
export { RouteInventorySchema, RouteSchema, A11yViolationSchema, BrokenLinkSchema, } from './route-inventory.schema.js';
|
|
4
4
|
export { GapAnalysisSchema, GapSchema, NeutralScenarioSchema, GeneratedTestSchema, TestStepSchema, FrameworkRecommendationSchema, } from './gap-analysis.schema.js';
|
|
@@ -0,0 +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;AAmDhE,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,SAAQ,GAAG,OAAO,CAAC,YAAY,CAAC,CAgGtF"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { chromium } from '@playwright/test';
|
|
2
|
+
const OAUTH_PROVIDERS = [
|
|
3
|
+
{ provider: 'github', patterns: [/github/i, /sign in with github/i] },
|
|
4
|
+
{
|
|
5
|
+
provider: 'google',
|
|
6
|
+
patterns: [/google/i, /sign in with google/i, /accounts\.google\.com/i],
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
provider: 'microsoft',
|
|
10
|
+
patterns: [/microsoft/i, /sign in with microsoft/i, /login\.microsoftonline\.com/i],
|
|
11
|
+
},
|
|
12
|
+
{ provider: 'apple', patterns: [/apple/i, /sign in with apple/i] },
|
|
13
|
+
{ provider: 'auth0', patterns: [/auth0/i] },
|
|
14
|
+
{ provider: 'okta', patterns: [/okta/i] },
|
|
15
|
+
];
|
|
16
|
+
function textLooksLikeOAuthIdpButton(text) {
|
|
17
|
+
const t = text.trim();
|
|
18
|
+
if (t.length === 0 || t.length > 120) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return (/\b(sign in with|log in with|continue with|sign up with)\b/i.test(t) ||
|
|
22
|
+
/^(github|google|microsoft|apple)$/i.test(t));
|
|
23
|
+
}
|
|
24
|
+
const MAGIC_LINK_PATTERNS = [
|
|
25
|
+
/email me a (sign[- ]?in )?link/i,
|
|
26
|
+
/sign in with email/i,
|
|
27
|
+
/passwordless/i,
|
|
28
|
+
/we'll send you a link/i,
|
|
29
|
+
];
|
|
30
|
+
async function firstTextInputNameForLogin(page) {
|
|
31
|
+
const emailName = await page.locator('input[type="email"]').first().getAttribute('name').catch(() => null);
|
|
32
|
+
if (emailName) {
|
|
33
|
+
return emailName;
|
|
34
|
+
}
|
|
35
|
+
const textInputs = page.locator('input[type="text"]');
|
|
36
|
+
const count = await textInputs.count();
|
|
37
|
+
for (let i = 0; i < count; i++) {
|
|
38
|
+
const name = await textInputs.nth(i).getAttribute('name');
|
|
39
|
+
if (name && /user|email|login/i.test(name)) {
|
|
40
|
+
return name;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
export async function detectAuth(url, timeoutMs = 15000) {
|
|
46
|
+
const browser = await chromium.launch({ headless: true });
|
|
47
|
+
try {
|
|
48
|
+
const context = await browser.newContext();
|
|
49
|
+
const page = await context.newPage();
|
|
50
|
+
await page.goto(url, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
|
|
51
|
+
let loginUrl = url;
|
|
52
|
+
const looksLikeLoginPage = /login|sign[- ]?in|auth/i.test(page.url()) ||
|
|
53
|
+
(await page.locator('input[type="password"]').count()) > 0;
|
|
54
|
+
if (!looksLikeLoginPage) {
|
|
55
|
+
const loginLink = page.locator('a').filter({ hasText: /^(log ?in|sign ?in|sign in)$/i }).first();
|
|
56
|
+
if ((await loginLink.count()) > 0) {
|
|
57
|
+
const href = await loginLink.getAttribute('href');
|
|
58
|
+
if (href) {
|
|
59
|
+
loginUrl = new URL(href, url).toString();
|
|
60
|
+
await page.goto(loginUrl, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const passwordInputs = page.locator('input[type="password"]');
|
|
65
|
+
const passwordCount = await passwordInputs.count();
|
|
66
|
+
const hasFormLogin = passwordCount > 0;
|
|
67
|
+
const oauthButtons = [];
|
|
68
|
+
const buttonTexts = await page.locator('button, a').allInnerTexts();
|
|
69
|
+
for (const text of buttonTexts) {
|
|
70
|
+
const trimmed = text.trim();
|
|
71
|
+
if (!textLooksLikeOAuthIdpButton(trimmed)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
for (const { provider, patterns } of OAUTH_PROVIDERS) {
|
|
75
|
+
if (patterns.some((p) => p.test(trimmed))) {
|
|
76
|
+
if (!oauthButtons.find((b) => b.provider === provider)) {
|
|
77
|
+
oauthButtons.push({ provider, text: trimmed.slice(0, 100) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const pageText = await page.locator('body').innerText().catch(() => '');
|
|
83
|
+
const hasMagicLink = MAGIC_LINK_PATTERNS.some((p) => p.test(pageText));
|
|
84
|
+
let type = 'none';
|
|
85
|
+
let provider = null;
|
|
86
|
+
let observedSelectors = null;
|
|
87
|
+
let recommendation = '';
|
|
88
|
+
if (oauthButtons.length > 0) {
|
|
89
|
+
type = 'oauth';
|
|
90
|
+
provider = oauthButtons[0].provider;
|
|
91
|
+
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.`;
|
|
92
|
+
}
|
|
93
|
+
else if (hasFormLogin) {
|
|
94
|
+
type = 'form-login';
|
|
95
|
+
const usernameName = await firstTextInputNameForLogin(page);
|
|
96
|
+
const passwordName = await passwordInputs.first().getAttribute('name').catch(() => null);
|
|
97
|
+
const submitName = await page
|
|
98
|
+
.locator('button[type="submit"], input[type="submit"]')
|
|
99
|
+
.first()
|
|
100
|
+
.getAttribute('name')
|
|
101
|
+
.catch(() => null);
|
|
102
|
+
observedSelectors = {
|
|
103
|
+
usernameSelector: usernameName ? `input[name="${usernameName}"]` : null,
|
|
104
|
+
passwordSelector: passwordName ? `input[name="${passwordName}"]` : null,
|
|
105
|
+
submitSelector: submitName ? `button[name="${submitName}"]` : 'button[type="submit"]',
|
|
106
|
+
};
|
|
107
|
+
recommendation = `Form login detected. Configure auth with type="form-login", credentials, and the selectors above. Test selectors in your browser dev tools to confirm.`;
|
|
108
|
+
}
|
|
109
|
+
else if (hasMagicLink) {
|
|
110
|
+
type = 'magic-link';
|
|
111
|
+
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.`;
|
|
112
|
+
}
|
|
113
|
+
else if (looksLikeLoginPage) {
|
|
114
|
+
type = 'unknown';
|
|
115
|
+
recommendation = `Authentication required but the pattern is unrecognized. Use "qulib auth init --base-url ${url}" to capture a storage state by logging in manually.`;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
type = 'none';
|
|
119
|
+
recommendation = `No authentication required for the entry URL. Qulib can scan anonymously.`;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
hasAuth: type !== 'none',
|
|
123
|
+
type,
|
|
124
|
+
provider,
|
|
125
|
+
loginUrl: type === 'none' ? null : loginUrl,
|
|
126
|
+
observedSelectors,
|
|
127
|
+
oauthButtons,
|
|
128
|
+
recommendation,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await browser.close();
|
|
133
|
+
}
|
|
134
|
+
}
|