@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.
Files changed (60) hide show
  1. package/README.md +173 -1
  2. package/dist/cli/index.js +76 -4
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/generators/cli.js +1 -1
  5. package/dist/generators/gherkin-parser/index.d.ts +1 -0
  6. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  7. package/dist/generators/gherkin-parser/index.js +3 -0
  8. package/dist/generators/gherkin-parser/index.js.map +1 -1
  9. package/dist/generators/scaffold-generator/index.d.ts +25 -2
  10. package/dist/generators/scaffold-generator/index.d.ts.map +1 -1
  11. package/dist/generators/scaffold-generator/index.js +157 -14
  12. package/dist/generators/scaffold-generator/index.js.map +1 -1
  13. package/dist/generators/test-generator/adapters/adapter-interface.d.ts +2 -0
  14. package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
  15. package/dist/generators/test-generator/adapters/playwright/templates/scenario.hbs +4 -0
  16. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/radio-select-action.hbs +1 -0
  17. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-value-assertion.hbs +1 -0
  18. package/dist/generators/test-generator/auth-setup-generator.d.ts +18 -0
  19. package/dist/generators/test-generator/auth-setup-generator.d.ts.map +1 -0
  20. package/dist/generators/test-generator/auth-setup-generator.js +82 -0
  21. package/dist/generators/test-generator/auth-setup-generator.js.map +1 -0
  22. package/dist/generators/test-generator/code-generator.d.ts +2 -0
  23. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  24. package/dist/generators/test-generator/code-generator.js +130 -5
  25. package/dist/generators/test-generator/code-generator.js.map +1 -1
  26. package/dist/generators/test-generator/patterns/assertion-patterns.d.ts.map +1 -1
  27. package/dist/generators/test-generator/patterns/assertion-patterns.js +30 -0
  28. package/dist/generators/test-generator/patterns/assertion-patterns.js.map +1 -1
  29. package/dist/generators/test-generator/patterns/form-patterns.d.ts.map +1 -1
  30. package/dist/generators/test-generator/patterns/form-patterns.js +25 -5
  31. package/dist/generators/test-generator/patterns/form-patterns.js.map +1 -1
  32. package/dist/generators/test-generator/templates/auth-setup.ts.hbs +36 -0
  33. package/dist/input/cli-adapter.d.ts +4 -0
  34. package/dist/input/cli-adapter.d.ts.map +1 -1
  35. package/dist/input/cli-adapter.js +20 -1
  36. package/dist/input/cli-adapter.js.map +1 -1
  37. package/dist/orchestrator/pipeline.d.ts.map +1 -1
  38. package/dist/orchestrator/pipeline.js +31 -22
  39. package/dist/orchestrator/pipeline.js.map +1 -1
  40. package/dist/tools/auth-maker.d.ts +74 -0
  41. package/dist/tools/auth-maker.d.ts.map +1 -0
  42. package/dist/tools/auth-maker.js +420 -0
  43. package/dist/tools/auth-maker.js.map +1 -0
  44. package/package.json +2 -2
  45. package/src/cli/index.ts +81 -4
  46. package/src/generators/cli.ts +1 -1
  47. package/src/generators/gherkin-parser/index.ts +5 -0
  48. package/src/generators/scaffold-generator/index.ts +179 -19
  49. package/src/generators/test-generator/adapters/adapter-interface.ts +2 -0
  50. package/src/generators/test-generator/adapters/playwright/templates/scenario.hbs +4 -0
  51. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/radio-select-action.hbs +1 -0
  52. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-value-assertion.hbs +1 -0
  53. package/src/generators/test-generator/auth-setup-generator.ts +59 -0
  54. package/src/generators/test-generator/code-generator.ts +173 -6
  55. package/src/generators/test-generator/patterns/assertion-patterns.ts +34 -1
  56. package/src/generators/test-generator/patterns/form-patterns.ts +27 -8
  57. package/src/generators/test-generator/templates/auth-setup.ts.hbs +36 -0
  58. package/src/input/cli-adapter.ts +21 -1
  59. package/src/orchestrator/pipeline.ts +39 -24
  60. 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') && !!step.selectorRef && !!(step.dataRef || step.value),
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
- const code = context.templateEngine.renderStep('select-action', {
95
- ...resolved,
96
- selectValue: value,
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
@@ -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.6');
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
- console.error(`No feature files found for screen: ${screenName}`);
371
- console.error(`Looked in:`);
372
- console.error(` - qa/screens/${screenName}/features/`);
373
- console.error(` - qa/features/${screenName}.feature`);
374
- process.exit(1);
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
- console.log(`\nMissing selector files:\n`);
389
- missing.forEach(v => {
390
- console.log(` ${v.featureName}.feature → selector file not found`);
391
- });
392
- console.log(`\nRun: sungen map --screen ${screenName}\n`);
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
- // Load selectors and test data
403
- const selectors = {}; // TODO: Load from selector files
404
- const testData = {}; // TODO: Load from test-data files
405
-
406
- const result = await generator.generate(feature, selectors, testData, this.config);
407
- results.push(result);
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
- // Display per-file result
410
- const fileName = path.basename(result.outputPath);
411
- const relativePath = path.relative(process.cwd(), result.outputPath);
412
- const stepInfo = result.stats?.totalSteps ? ` (${result.stats.totalSteps} steps)` : '';
413
-
414
- console.log(`✓ Generated ${fileName}`);
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
+ }