@sun-asterisk/sungen 1.0.6 → 1.0.7
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 +173 -1
- package/dist/cli/index.js +76 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/generators/cli.js +1 -1
- package/dist/generators/gherkin-parser/index.d.ts +1 -0
- package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
- package/dist/generators/gherkin-parser/index.js +3 -0
- package/dist/generators/gherkin-parser/index.js.map +1 -1
- package/dist/generators/scaffold-generator/index.d.ts +25 -2
- package/dist/generators/scaffold-generator/index.d.ts.map +1 -1
- package/dist/generators/scaffold-generator/index.js +157 -14
- package/dist/generators/scaffold-generator/index.js.map +1 -1
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts +2 -0
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/scenario.hbs +4 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/radio-select-action.hbs +1 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-value-assertion.hbs +1 -0
- package/dist/generators/test-generator/auth-setup-generator.d.ts +18 -0
- package/dist/generators/test-generator/auth-setup-generator.d.ts.map +1 -0
- package/dist/generators/test-generator/auth-setup-generator.js +82 -0
- package/dist/generators/test-generator/auth-setup-generator.js.map +1 -0
- package/dist/generators/test-generator/code-generator.d.ts +2 -0
- package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
- package/dist/generators/test-generator/code-generator.js +130 -5
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/patterns/assertion-patterns.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/assertion-patterns.js +30 -0
- package/dist/generators/test-generator/patterns/assertion-patterns.js.map +1 -1
- package/dist/generators/test-generator/patterns/form-patterns.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/form-patterns.js +25 -5
- package/dist/generators/test-generator/patterns/form-patterns.js.map +1 -1
- package/dist/generators/test-generator/templates/auth-setup.ts.hbs +36 -0
- package/dist/input/cli-adapter.d.ts +4 -0
- package/dist/input/cli-adapter.d.ts.map +1 -1
- package/dist/input/cli-adapter.js +20 -1
- package/dist/input/cli-adapter.js.map +1 -1
- package/dist/orchestrator/pipeline.d.ts.map +1 -1
- package/dist/orchestrator/pipeline.js +31 -22
- package/dist/orchestrator/pipeline.js.map +1 -1
- package/dist/tools/auth-maker.d.ts +74 -0
- package/dist/tools/auth-maker.d.ts.map +1 -0
- package/dist/tools/auth-maker.js +420 -0
- package/dist/tools/auth-maker.js.map +1 -0
- package/package.json +2 -2
- package/src/cli/index.ts +81 -4
- package/src/generators/cli.ts +1 -1
- package/src/generators/gherkin-parser/index.ts +5 -0
- package/src/generators/scaffold-generator/index.ts +179 -19
- package/src/generators/test-generator/adapters/adapter-interface.ts +2 -0
- package/src/generators/test-generator/adapters/playwright/templates/scenario.hbs +4 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/radio-select-action.hbs +1 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-value-assertion.hbs +1 -0
- package/src/generators/test-generator/auth-setup-generator.ts +59 -0
- package/src/generators/test-generator/code-generator.ts +173 -6
- package/src/generators/test-generator/patterns/assertion-patterns.ts +34 -1
- package/src/generators/test-generator/patterns/form-patterns.ts +27 -8
- package/src/generators/test-generator/templates/auth-setup.ts.hbs +36 -0
- package/src/input/cli-adapter.ts +21 -1
- package/src/orchestrator/pipeline.ts +39 -24
- package/src/tools/auth-maker.ts +467 -0
|
@@ -75,10 +75,12 @@ export const formPatterns: StepPattern[] = [
|
|
|
75
75
|
{
|
|
76
76
|
name: 'select-dropdown',
|
|
77
77
|
matcher: (step: ParsedStep) =>
|
|
78
|
-
step.text.includes('selects')
|
|
78
|
+
(step.text.includes('selects') || step.text.match(/\bselect\b/)) &&
|
|
79
|
+
!!step.selectorRef &&
|
|
80
|
+
!!(step.dataRef || step.value),
|
|
79
81
|
generator: (step, context) => {
|
|
80
82
|
const resolved = context.selectorResolver.resolveSelector(step.selectorRef!);
|
|
81
|
-
|
|
83
|
+
|
|
82
84
|
// Resolve data reference to actual value
|
|
83
85
|
let value: string;
|
|
84
86
|
if (step.dataRef) {
|
|
@@ -90,12 +92,29 @@ export const formPatterns: StepPattern[] = [
|
|
|
90
92
|
} else {
|
|
91
93
|
value = step.value!;
|
|
92
94
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
|
|
96
|
+
// Determine action based on role type
|
|
97
|
+
// Radio buttons use check(), dropdowns use selectOption()
|
|
98
|
+
const isRadio = resolved.role === 'radio' || step.text.includes('radio');
|
|
99
|
+
const isCheckbox = resolved.role === 'checkbox' || step.text.includes('checkbox');
|
|
100
|
+
|
|
101
|
+
let code: string;
|
|
102
|
+
if (isRadio) {
|
|
103
|
+
// For radio buttons, we need to find the specific radio with the value
|
|
104
|
+
code = context.templateEngine.renderStep('radio-select-action', {
|
|
105
|
+
...resolved,
|
|
106
|
+
selectValue: value,
|
|
107
|
+
});
|
|
108
|
+
} else if (isCheckbox) {
|
|
109
|
+
code = context.templateEngine.renderStep('check-action', resolved);
|
|
110
|
+
} else {
|
|
111
|
+
// Default to dropdown/combobox selectOption
|
|
112
|
+
code = context.templateEngine.renderStep('select-action', {
|
|
113
|
+
...resolved,
|
|
114
|
+
selectValue: value,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
return {
|
|
100
119
|
code,
|
|
101
120
|
comment: `Select ${step.dataRef || step.value} in ${step.selectorRef}`,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { test as setup, expect } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Authentication Setup
|
|
5
|
+
*
|
|
6
|
+
* This file creates authenticated browser contexts for your tests.
|
|
7
|
+
* Update the TODOs with your application's actual login flow.
|
|
8
|
+
*
|
|
9
|
+
* Generated roles: {{#each roles}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const authFile = (role: string) => `specs/.auth/${role}.json`;
|
|
13
|
+
|
|
14
|
+
{{#each roles}}
|
|
15
|
+
setup('authenticate as {{this}}', async ({ page }) => {
|
|
16
|
+
// TODO: Navigate to your login page
|
|
17
|
+
await page.goto('/login');
|
|
18
|
+
|
|
19
|
+
// TODO: Fill in credentials for {{this}} role
|
|
20
|
+
// Update these selectors to match your application
|
|
21
|
+
await page.getByLabel('Email').fill('{{this}}@example.com');
|
|
22
|
+
await page.getByLabel('Password').fill('{{this}}123');
|
|
23
|
+
|
|
24
|
+
// TODO: Click login button
|
|
25
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
26
|
+
|
|
27
|
+
// TODO: Wait for authentication to complete
|
|
28
|
+
// Update this to match your post-login URL or element
|
|
29
|
+
await page.waitForURL('/dashboard');
|
|
30
|
+
|
|
31
|
+
// Save authentication state
|
|
32
|
+
await page.context().storageState({ path: authFile('{{this}}') });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
{{/each}}
|
|
36
|
+
// TODO: Add more roles as needed by adding @auth:{role} tags to your features
|
package/src/input/cli-adapter.ts
CHANGED
|
@@ -36,7 +36,7 @@ export class CLIAdapter implements InputAdapter {
|
|
|
36
36
|
program
|
|
37
37
|
.name('sungen')
|
|
38
38
|
.description('AI-Native E2E Test Generator - Generate Playwright tests from Gherkin features')
|
|
39
|
-
.version('1.0.
|
|
39
|
+
.version('1.0.7');
|
|
40
40
|
|
|
41
41
|
// Global options
|
|
42
42
|
program
|
|
@@ -81,6 +81,7 @@ export class CLIAdapter implements InputAdapter {
|
|
|
81
81
|
.command('map')
|
|
82
82
|
.description('Generate selector and test-data YAML from Gherkin feature files')
|
|
83
83
|
.option('-s, --screen <name>', 'Screen name (reads from qa/screens/<name>/features/)')
|
|
84
|
+
.option('--file <path>', 'Single feature file path (generates selectors next to features folder)')
|
|
84
85
|
.option('-o, --output <dir>', 'Output directory for generated files')
|
|
85
86
|
.option('-f, --force', 'Force overwrite existing YAML files')
|
|
86
87
|
.option('-v, --verbose', 'Detailed output');
|
|
@@ -169,6 +170,25 @@ export class CLIAdapter implements InputAdapter {
|
|
|
169
170
|
.option('-v, --verbose', 'Show all checks, not just failures');
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Add makeauth command - Generate auth state for E2E tests
|
|
175
|
+
*/
|
|
176
|
+
addMakeAuthCommand(program: Command): Command {
|
|
177
|
+
return program
|
|
178
|
+
.command('makeauth <name>')
|
|
179
|
+
.description('Generate auth state by opening browser for manual SSO login')
|
|
180
|
+
.option('-u, --url <url>', 'Base URL of the application')
|
|
181
|
+
.option('-p, --path <path>', 'Login page path (default: /login)')
|
|
182
|
+
.option('-o, --output <dir>', 'Output directory (default: specs/.auth)')
|
|
183
|
+
.option('-t, --timeout <ms>', 'Overall timeout in milliseconds (default: 180000)')
|
|
184
|
+
.option('--nav-timeout <ms>', 'Navigation timeout in milliseconds (default: 180000)')
|
|
185
|
+
.option('--stability-wait <ms>', 'URL stability wait in milliseconds (default: 5000)')
|
|
186
|
+
.option('--headless', 'Run browser in headless mode')
|
|
187
|
+
.option('--verify', 'Verify existing auth state instead of creating new')
|
|
188
|
+
.option('--list', 'List all existing auth states')
|
|
189
|
+
.option('--export', 'Export auth state as base64 for CI');
|
|
190
|
+
}
|
|
191
|
+
|
|
172
192
|
/**
|
|
173
193
|
* Extract options from commander Command
|
|
174
194
|
*/
|
|
@@ -367,11 +367,12 @@ export class Pipeline {
|
|
|
367
367
|
: this.findAllFeatureFiles();
|
|
368
368
|
|
|
369
369
|
if (screenName && featureFiles.length === 0) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
370
|
+
throw new Error(
|
|
371
|
+
`No feature files found for screen: ${screenName}\n` +
|
|
372
|
+
`Looked in:\n` +
|
|
373
|
+
` - qa/screens/${screenName}/features/\n` +
|
|
374
|
+
` - qa/features/${screenName}.feature`
|
|
375
|
+
);
|
|
375
376
|
}
|
|
376
377
|
|
|
377
378
|
// Parse features
|
|
@@ -385,34 +386,48 @@ export class Pipeline {
|
|
|
385
386
|
const missing = validation.filter(v => !v.selectorExists);
|
|
386
387
|
|
|
387
388
|
if (missing.length > 0) {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
process.exit(1);
|
|
389
|
+
const missingList = missing.map(v => ` ✗ ${v.featureName}.feature → selector file not found`).join('\n');
|
|
390
|
+
throw new Error(
|
|
391
|
+
`Missing selector files:\n${missingList}\n\n` +
|
|
392
|
+
`Run: sungen map --screen ${screenName}`
|
|
393
|
+
);
|
|
394
394
|
}
|
|
395
395
|
}
|
|
396
396
|
|
|
397
397
|
// Generate tests
|
|
398
398
|
const generator = TestGeneratorFactory.create(this.config.testGenerator.framework, this.config);
|
|
399
399
|
const results: GeneratedTest[] = [];
|
|
400
|
+
const errors: Array<{ feature: string; error: any }> = [];
|
|
400
401
|
|
|
401
402
|
for (const feature of features) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
403
|
+
try {
|
|
404
|
+
// Load selectors and test data
|
|
405
|
+
const selectors = {}; // TODO: Load from selector files
|
|
406
|
+
const testData = {}; // TODO: Load from test-data files
|
|
407
|
+
|
|
408
|
+
const result = await generator.generate(feature, selectors, testData, this.config);
|
|
409
|
+
results.push(result);
|
|
410
|
+
|
|
411
|
+
// Display per-file result
|
|
412
|
+
const fileName = path.basename(result.outputPath);
|
|
413
|
+
const relativePath = path.relative(process.cwd(), result.outputPath);
|
|
414
|
+
const stepInfo = result.stats?.totalSteps ? ` (${result.stats.totalSteps} steps)` : '';
|
|
415
|
+
|
|
416
|
+
console.log(`✓ Generated ${fileName}`);
|
|
417
|
+
console.log(` → ${relativePath}${stepInfo}\n`);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
const featureName = typeof feature === 'object' && feature.name ? feature.name : 'unknown';
|
|
420
|
+
console.error(`✗ Failed to generate test for feature: ${featureName}`);
|
|
421
|
+
errors.push({ feature: featureName, error });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
408
424
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
console.log(` → ${relativePath}${stepInfo}\n`);
|
|
425
|
+
// Throw if any errors occurred
|
|
426
|
+
if (errors.length > 0) {
|
|
427
|
+
const errorList = errors.map(e => ` - ${e.feature}`).join('\n');
|
|
428
|
+
throw new Error(
|
|
429
|
+
`Failed to generate ${errors.length} test(s):\n${errorList}`
|
|
430
|
+
);
|
|
416
431
|
}
|
|
417
432
|
|
|
418
433
|
// Generate report
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Maker - Generate authentication state for E2E tests
|
|
3
|
+
*
|
|
4
|
+
* Usage: sungen makeauth <name>
|
|
5
|
+
* Example: sungen makeauth admin -> generates specs/.auth/admin.json
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import * as readline from 'readline';
|
|
11
|
+
import { chromium, Browser, BrowserContext } from '@playwright/test';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract baseURL from playwright.config.ts
|
|
15
|
+
* Returns { url, source } or null
|
|
16
|
+
*/
|
|
17
|
+
function getPlaywrightBaseURL(configPath?: string): { url: string; source: string } | null {
|
|
18
|
+
const possiblePaths = configPath
|
|
19
|
+
? [configPath]
|
|
20
|
+
: [
|
|
21
|
+
path.join(process.cwd(), 'playwright.config.ts'),
|
|
22
|
+
path.join(process.cwd(), 'playwright.config.js'),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
for (const configFile of possiblePaths) {
|
|
26
|
+
if (fs.existsSync(configFile)) {
|
|
27
|
+
try {
|
|
28
|
+
const content = fs.readFileSync(configFile, 'utf-8');
|
|
29
|
+
|
|
30
|
+
// Match baseURL in use: { baseURL: '...' } or baseURL: '...'
|
|
31
|
+
const patterns = [
|
|
32
|
+
/baseURL:\s*['"]([^'"]+)['"]/,
|
|
33
|
+
/baseURL:\s*`([^`]+)`/,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
for (const pattern of patterns) {
|
|
37
|
+
const match = content.match(pattern);
|
|
38
|
+
if (match) {
|
|
39
|
+
return {
|
|
40
|
+
url: match[1],
|
|
41
|
+
source: path.basename(configFile),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// Ignore read errors
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AuthMakerOptions {
|
|
55
|
+
name: string;
|
|
56
|
+
baseURL?: string;
|
|
57
|
+
loginPath?: string;
|
|
58
|
+
outputDir?: string;
|
|
59
|
+
timeout?: number;
|
|
60
|
+
navigationTimeout?: number;
|
|
61
|
+
stabilityWait?: number;
|
|
62
|
+
headless?: boolean;
|
|
63
|
+
successSelector?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AuthMakerConfig {
|
|
67
|
+
baseURL: string;
|
|
68
|
+
loginPath: string;
|
|
69
|
+
outputDir: string;
|
|
70
|
+
timeout: number;
|
|
71
|
+
navigationTimeout: number;
|
|
72
|
+
stabilityWait: number;
|
|
73
|
+
successSelector?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class AuthMaker {
|
|
77
|
+
private config: AuthMakerConfig;
|
|
78
|
+
private activeReadline: readline.Interface | null = null;
|
|
79
|
+
|
|
80
|
+
constructor(config: Partial<AuthMakerConfig> = {}) {
|
|
81
|
+
// Priority: config > playwright.config.ts > env > default
|
|
82
|
+
const playwrightConfig = getPlaywrightBaseURL();
|
|
83
|
+
|
|
84
|
+
this.config = {
|
|
85
|
+
baseURL: config.baseURL || playwrightConfig?.url || process.env.APP_BASE_URL || 'http://localhost:3000',
|
|
86
|
+
loginPath: config.loginPath || '/login',
|
|
87
|
+
outputDir: config.outputDir || 'specs/.auth',
|
|
88
|
+
timeout: config.timeout || 180000, // 3 minutes for overall timeout
|
|
89
|
+
navigationTimeout: config.navigationTimeout || 180000, // 3 minutes for page navigation
|
|
90
|
+
stabilityWait: config.stabilityWait || 5000, // 5 seconds for URL stability check
|
|
91
|
+
successSelector: config.successSelector,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Store source for display
|
|
95
|
+
if (!config.baseURL && playwrightConfig) {
|
|
96
|
+
this.baseURLSource = playwrightConfig.source;
|
|
97
|
+
} else if (!config.baseURL && process.env.APP_BASE_URL) {
|
|
98
|
+
this.baseURLSource = 'APP_BASE_URL env';
|
|
99
|
+
} else if (config.baseURL) {
|
|
100
|
+
this.baseURLSource = 'config';
|
|
101
|
+
} else {
|
|
102
|
+
this.baseURLSource = 'default';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private baseURLSource: string = 'default';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Generate auth state by opening browser for manual login
|
|
110
|
+
*/
|
|
111
|
+
async makeAuth(options: AuthMakerOptions): Promise<string> {
|
|
112
|
+
const { name, headless = false } = options;
|
|
113
|
+
|
|
114
|
+
// Merge options with config
|
|
115
|
+
const baseURL = options.baseURL || this.config.baseURL;
|
|
116
|
+
const loginPath = options.loginPath || this.config.loginPath;
|
|
117
|
+
const outputDir = options.outputDir || this.config.outputDir;
|
|
118
|
+
const timeout = options.timeout || this.config.timeout;
|
|
119
|
+
const navigationTimeout = options.navigationTimeout || this.config.navigationTimeout;
|
|
120
|
+
const stabilityWait = options.stabilityWait || this.config.stabilityWait;
|
|
121
|
+
const successSelector = options.successSelector || this.config.successSelector;
|
|
122
|
+
|
|
123
|
+
const loginURL = `${baseURL}${loginPath}`;
|
|
124
|
+
const authFile = path.join(outputDir, `${name}.json`);
|
|
125
|
+
|
|
126
|
+
console.log('');
|
|
127
|
+
console.log('========================================');
|
|
128
|
+
console.log(` Auth Maker: ${name}`);
|
|
129
|
+
console.log('========================================');
|
|
130
|
+
console.log('');
|
|
131
|
+
console.log(` Base URL: ${baseURL} (from ${this.baseURLSource})`);
|
|
132
|
+
console.log(` Login URL: ${loginURL}`);
|
|
133
|
+
console.log(` Output: ${authFile}`);
|
|
134
|
+
console.log('');
|
|
135
|
+
|
|
136
|
+
// Create output directory
|
|
137
|
+
if (!fs.existsSync(outputDir)) {
|
|
138
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check if auth file already exists
|
|
142
|
+
if (fs.existsSync(authFile)) {
|
|
143
|
+
const overwrite = await this.askQuestion(
|
|
144
|
+
`⚠️ File ${authFile} already exists. Overwrite? (y/N): `
|
|
145
|
+
);
|
|
146
|
+
if (overwrite.toLowerCase() !== 'y') {
|
|
147
|
+
console.log('Cancelled.');
|
|
148
|
+
return authFile;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let browser: Browser | null = null;
|
|
153
|
+
let context: BrowserContext | null = null;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
console.log('🚀 Opening browser...');
|
|
157
|
+
|
|
158
|
+
browser = await chromium.launch({
|
|
159
|
+
headless,
|
|
160
|
+
slowMo: 50,
|
|
161
|
+
args: ['--start-maximized'],
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
context = await browser.newContext({
|
|
165
|
+
viewport: { width: 1280, height: 800 },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const page = await context.newPage();
|
|
169
|
+
|
|
170
|
+
// Navigate to login page
|
|
171
|
+
console.log(`📍 Navigating to ${loginURL}...`);
|
|
172
|
+
await page.goto(loginURL, { timeout: navigationTimeout });
|
|
173
|
+
await page.waitForLoadState('domcontentloaded');
|
|
174
|
+
|
|
175
|
+
// Check if already logged in (URL changed)
|
|
176
|
+
const currentURL = page.url();
|
|
177
|
+
if (!currentURL.includes(loginPath)) {
|
|
178
|
+
console.log('');
|
|
179
|
+
console.log('✅ Already logged in!');
|
|
180
|
+
await this.saveAuthState(context, authFile);
|
|
181
|
+
return authFile;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log('');
|
|
185
|
+
console.log('========================================');
|
|
186
|
+
console.log(' MANUAL LOGIN REQUIRED');
|
|
187
|
+
console.log('========================================');
|
|
188
|
+
console.log('');
|
|
189
|
+
console.log(' 1. Login in the browser window');
|
|
190
|
+
console.log(' 2. Complete any 2FA if required');
|
|
191
|
+
console.log(' 3. Browser will auto-close when redirected back');
|
|
192
|
+
console.log(' (Or press ENTER to save manually)');
|
|
193
|
+
console.log('');
|
|
194
|
+
console.log(` Timeout: ${Math.round(timeout / 60000)} minutes`);
|
|
195
|
+
console.log('========================================');
|
|
196
|
+
console.log('');
|
|
197
|
+
|
|
198
|
+
// Wait for callback to baseURL OR user press Enter
|
|
199
|
+
const callbackPromise = this.waitForCallback(page, baseURL, loginPath, timeout, stabilityWait);
|
|
200
|
+
const enterPromise = this.waitForEnter();
|
|
201
|
+
|
|
202
|
+
await Promise.race([callbackPromise, enterPromise]);
|
|
203
|
+
|
|
204
|
+
// Cancel readline if callback was detected (so process can exit)
|
|
205
|
+
this.cancelWaitForEnter();
|
|
206
|
+
|
|
207
|
+
// Verify login was successful
|
|
208
|
+
const finalURL = page.url();
|
|
209
|
+
if (finalURL.includes(loginPath)) {
|
|
210
|
+
console.log('');
|
|
211
|
+
console.log('⚠️ Still on login page. Saving state anyway...');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Save auth state
|
|
215
|
+
await this.saveAuthState(context, authFile);
|
|
216
|
+
|
|
217
|
+
return authFile;
|
|
218
|
+
} catch (error: any) {
|
|
219
|
+
console.error('');
|
|
220
|
+
console.error('❌ Error:', error.message);
|
|
221
|
+
throw error;
|
|
222
|
+
} finally {
|
|
223
|
+
if (context) await context.close();
|
|
224
|
+
if (browser) await browser.close();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Detect SSO provider from URL
|
|
230
|
+
*/
|
|
231
|
+
private detectSSOProvider(url: string): string {
|
|
232
|
+
const providers: { pattern: RegExp; name: string; icon: string }[] = [
|
|
233
|
+
{ pattern: /accounts\.google\.com|googleapis\.com/i, name: 'Google', icon: '🔵' },
|
|
234
|
+
{ pattern: /facebook\.com|fb\.com/i, name: 'Facebook', icon: '🔷' },
|
|
235
|
+
{ pattern: /github\.com/i, name: 'GitHub', icon: '⚫' },
|
|
236
|
+
{ pattern: /login\.microsoftonline\.com|login\.live\.com|microsoft\.com/i, name: 'Microsoft', icon: '🟦' },
|
|
237
|
+
{ pattern: /appleid\.apple\.com/i, name: 'Apple', icon: '🍎' },
|
|
238
|
+
{ pattern: /twitter\.com|x\.com/i, name: 'X (Twitter)', icon: '🐦' },
|
|
239
|
+
{ pattern: /linkedin\.com/i, name: 'LinkedIn', icon: '🔗' },
|
|
240
|
+
{ pattern: /\.okta\.com/i, name: 'Okta', icon: '🔐' },
|
|
241
|
+
{ pattern: /\.auth0\.com/i, name: 'Auth0', icon: '🔑' },
|
|
242
|
+
{ pattern: /gitlab\.com/i, name: 'GitLab', icon: '🦊' },
|
|
243
|
+
{ pattern: /bitbucket\.org|atlassian\.com/i, name: 'Atlassian', icon: '🔹' },
|
|
244
|
+
{ pattern: /slack\.com/i, name: 'Slack', icon: '💬' },
|
|
245
|
+
{ pattern: /discord\.com/i, name: 'Discord', icon: '🎮' },
|
|
246
|
+
{ pattern: /amazon\.com|amazoncognito\.com/i, name: 'Amazon', icon: '📦' },
|
|
247
|
+
{ pattern: /yahoo\.com/i, name: 'Yahoo', icon: '🟣' },
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
for (const provider of providers) {
|
|
251
|
+
if (provider.pattern.test(url)) {
|
|
252
|
+
return `${provider.icon} ${provider.name}`;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Try to extract domain name for unknown providers
|
|
257
|
+
try {
|
|
258
|
+
const domain = new URL(url).hostname;
|
|
259
|
+
return `🔐 ${domain}`;
|
|
260
|
+
} catch {
|
|
261
|
+
return '🔐 Unknown SSO';
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Wait for callback to app URL after SSO login
|
|
267
|
+
* Flow: Wait for user to leave baseURL (click SSO) -> Wait for return to baseURL (not login page)
|
|
268
|
+
*/
|
|
269
|
+
private async waitForCallback(
|
|
270
|
+
page: any,
|
|
271
|
+
baseURL: string,
|
|
272
|
+
loginPath: string,
|
|
273
|
+
timeout: number = 180000,
|
|
274
|
+
stabilityWait: number = 5000
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
const startTime = Date.now();
|
|
277
|
+
const checkInterval = 1000; // Check every 1 second
|
|
278
|
+
let hasLeftBaseURL = false;
|
|
279
|
+
let detectedProvider = '';
|
|
280
|
+
let callbackDetectedAt = 0;
|
|
281
|
+
let lastCallbackURL = '';
|
|
282
|
+
|
|
283
|
+
console.log('⏳ Waiting for SSO redirect...');
|
|
284
|
+
console.log(' Supported: Google, Facebook, GitHub, Microsoft, Apple, Twitter, LinkedIn, Okta, Auth0, GitLab, Slack, Discord, and more...');
|
|
285
|
+
|
|
286
|
+
while (Date.now() - startTime < timeout) {
|
|
287
|
+
const currentURL = page.url();
|
|
288
|
+
|
|
289
|
+
// Phase 1: Wait for user to click SSO and leave baseURL
|
|
290
|
+
if (!hasLeftBaseURL) {
|
|
291
|
+
if (!currentURL.startsWith(baseURL)) {
|
|
292
|
+
hasLeftBaseURL = true;
|
|
293
|
+
detectedProvider = this.detectSSOProvider(currentURL);
|
|
294
|
+
console.log(`🔄 ${detectedProvider} SSO detected, waiting for callback...`);
|
|
295
|
+
}
|
|
296
|
+
await page.waitForTimeout(checkInterval);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Phase 2: Wait for callback to baseURL (not login page)
|
|
301
|
+
const isCallback = currentURL.startsWith(baseURL) && !currentURL.includes(loginPath);
|
|
302
|
+
|
|
303
|
+
if (isCallback) {
|
|
304
|
+
// First time detecting callback - start stability timer
|
|
305
|
+
if (callbackDetectedAt === 0 || lastCallbackURL !== currentURL) {
|
|
306
|
+
callbackDetectedAt = Date.now();
|
|
307
|
+
lastCallbackURL = currentURL;
|
|
308
|
+
console.log('🔍 Callback detected, verifying login...');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Check if URL has been stable for stabilityWait duration
|
|
312
|
+
if (Date.now() - callbackDetectedAt >= stabilityWait) {
|
|
313
|
+
console.log('');
|
|
314
|
+
console.log(`✅ ${detectedProvider} login successful!`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
// Reset if URL changed back (e.g., redirects during auth)
|
|
319
|
+
if (callbackDetectedAt > 0) {
|
|
320
|
+
console.log('🔄 Redirect detected, continuing to wait...');
|
|
321
|
+
}
|
|
322
|
+
callbackDetectedAt = 0;
|
|
323
|
+
lastCallbackURL = '';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
await page.waitForTimeout(checkInterval);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
throw new Error('Timeout waiting for login callback');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Wait for user to press Enter (cancellable)
|
|
334
|
+
*/
|
|
335
|
+
private waitForEnter(): Promise<void> {
|
|
336
|
+
return new Promise((resolve) => {
|
|
337
|
+
this.activeReadline = readline.createInterface({
|
|
338
|
+
input: process.stdin,
|
|
339
|
+
output: process.stdout,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
this.activeReadline.question('', () => {
|
|
343
|
+
this.activeReadline?.close();
|
|
344
|
+
this.activeReadline = null;
|
|
345
|
+
resolve();
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Cancel waiting for Enter (close readline interface)
|
|
352
|
+
*/
|
|
353
|
+
private cancelWaitForEnter(): void {
|
|
354
|
+
if (this.activeReadline) {
|
|
355
|
+
this.activeReadline.close();
|
|
356
|
+
this.activeReadline = null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Save browser auth state to file
|
|
362
|
+
*/
|
|
363
|
+
private async saveAuthState(context: BrowserContext, authFile: string): Promise<void> {
|
|
364
|
+
console.log('💾 Saving auth state...');
|
|
365
|
+
|
|
366
|
+
await context.storageState({ path: authFile });
|
|
367
|
+
|
|
368
|
+
console.log('');
|
|
369
|
+
console.log('========================================');
|
|
370
|
+
console.log(' ✅ AUTH STATE SAVED!');
|
|
371
|
+
console.log('========================================');
|
|
372
|
+
console.log('');
|
|
373
|
+
console.log(` File: ${authFile}`);
|
|
374
|
+
console.log('');
|
|
375
|
+
console.log(' Usage in Playwright:');
|
|
376
|
+
console.log(` storageState: '${authFile}'`);
|
|
377
|
+
console.log('');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Ask user a question
|
|
382
|
+
*/
|
|
383
|
+
private askQuestion(question: string): Promise<string> {
|
|
384
|
+
return new Promise((resolve) => {
|
|
385
|
+
const rl = readline.createInterface({
|
|
386
|
+
input: process.stdin,
|
|
387
|
+
output: process.stdout,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
rl.question(question, (answer) => {
|
|
391
|
+
rl.close();
|
|
392
|
+
resolve(answer);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Verify auth state is valid
|
|
399
|
+
*/
|
|
400
|
+
async verifyAuth(name: string): Promise<boolean> {
|
|
401
|
+
const authFile = path.join(this.config.outputDir, `${name}.json`);
|
|
402
|
+
|
|
403
|
+
if (!fs.existsSync(authFile)) {
|
|
404
|
+
console.log(`❌ Auth file not found: ${authFile}`);
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
console.log(`🔍 Verifying auth state: ${name}...`);
|
|
409
|
+
|
|
410
|
+
const browser = await chromium.launch({ headless: true });
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const context = await browser.newContext({
|
|
414
|
+
storageState: authFile,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const page = await context.newPage();
|
|
418
|
+
await page.goto(this.config.baseURL);
|
|
419
|
+
await page.waitForLoadState('networkidle');
|
|
420
|
+
|
|
421
|
+
const currentURL = page.url();
|
|
422
|
+
const isLoggedIn = !currentURL.includes(this.config.loginPath);
|
|
423
|
+
|
|
424
|
+
if (isLoggedIn) {
|
|
425
|
+
console.log('✅ Auth state is valid');
|
|
426
|
+
} else {
|
|
427
|
+
console.log('❌ Auth state expired or invalid');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
await context.close();
|
|
431
|
+
return isLoggedIn;
|
|
432
|
+
} finally {
|
|
433
|
+
await browser.close();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* List all auth states
|
|
439
|
+
*/
|
|
440
|
+
listAuth(): string[] {
|
|
441
|
+
const outputDir = this.config.outputDir;
|
|
442
|
+
|
|
443
|
+
if (!fs.existsSync(outputDir)) {
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return fs
|
|
448
|
+
.readdirSync(outputDir)
|
|
449
|
+
.filter((f) => f.endsWith('.json'))
|
|
450
|
+
.map((f) => f.replace('.json', ''));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Export auth state as base64 (for CI)
|
|
455
|
+
*/
|
|
456
|
+
exportAuth(name: string): string | null {
|
|
457
|
+
const authFile = path.join(this.config.outputDir, `${name}.json`);
|
|
458
|
+
|
|
459
|
+
if (!fs.existsSync(authFile)) {
|
|
460
|
+
console.log(`❌ Auth file not found: ${authFile}`);
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const authJson = fs.readFileSync(authFile, 'utf-8');
|
|
465
|
+
return Buffer.from(authJson).toString('base64');
|
|
466
|
+
}
|
|
467
|
+
}
|