@detiq/crawler 1.1.8 → 1.1.9

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 (82) hide show
  1. package/dist/accessibility-auditor.js +74 -54
  2. package/dist/ai-login.js +249 -227
  3. package/dist/auth-token-rotator.js +67 -57
  4. package/dist/auth.js +336 -299
  5. package/dist/browser-extension.js +142 -104
  6. package/dist/captcha-solver.js +125 -98
  7. package/dist/color-contrast.js +89 -77
  8. package/dist/content-chunker.js +126 -126
  9. package/dist/content-dedup.js +71 -54
  10. package/dist/cookie-consent.js +83 -68
  11. package/dist/cookie-jar.js +60 -53
  12. package/dist/crawler.js +5345 -4486
  13. package/dist/custom-js-injector.js +61 -45
  14. package/dist/dark-mode-capture.js +81 -67
  15. package/dist/dead-link-detector.js +55 -47
  16. package/dist/element-hash.js +12 -8
  17. package/dist/email-otp.js +195 -154
  18. package/dist/engine-router.js +98 -87
  19. package/dist/export/bulk-export.js +55 -70
  20. package/dist/export/vector-export.js +105 -77
  21. package/dist/extractors/advanced.js +266 -249
  22. package/dist/extractors/artifact-envelope.js +58 -59
  23. package/dist/failure-analysis-agent.js +33 -37
  24. package/dist/feature-flag-detector.js +66 -51
  25. package/dist/form-detector.js +75 -55
  26. package/dist/frontier.js +101 -76
  27. package/dist/gap-pass.js +89 -68
  28. package/dist/graphql-detector.js +72 -62
  29. package/dist/http-engine.js +330 -282
  30. package/dist/incremental-diff.js +87 -54
  31. package/dist/index.js +28 -67
  32. package/dist/input-fuzzer.js +97 -93
  33. package/dist/interactive-probe.js +827 -715
  34. package/dist/job-queue.js +2169 -2151
  35. package/dist/js-coverage.js +56 -52
  36. package/dist/js-router-detector.js +117 -86
  37. package/dist/language-detector.js +63 -51
  38. package/dist/link-classifier.js +50 -32
  39. package/dist/live-view.js +108 -91
  40. package/dist/locator-extractor.js +187 -151
  41. package/dist/logger.js +7 -9
  42. package/dist/markdown-extract.js +61 -48
  43. package/dist/native-crawler.js +946 -780
  44. package/dist/network-observer.js +156 -135
  45. package/dist/network-throttle.js +40 -29
  46. package/dist/openapi-generator.js +92 -77
  47. package/dist/pdf-extractor.js +90 -72
  48. package/dist/performance-metrics.js +102 -80
  49. package/dist/phash.js +144 -125
  50. package/dist/pii-scrubber.js +68 -65
  51. package/dist/playwright-recorder.js +58 -70
  52. package/dist/policies/crawl-safety.js +34 -31
  53. package/dist/profiles/domain-profile.js +125 -130
  54. package/dist/proxy.js +172 -146
  55. package/dist/rate-limiter.js +65 -58
  56. package/dist/redirect-tracker.js +64 -60
  57. package/dist/redis-frontier.js +136 -116
  58. package/dist/redis-queue.js +144 -157
  59. package/dist/response-cache.js +140 -127
  60. package/dist/responsive-detector.js +46 -42
  61. package/dist/retry-policy.js +50 -46
  62. package/dist/robots-txt.js +83 -69
  63. package/dist/screen-reader.js +70 -45
  64. package/dist/screenshot-masker.js +77 -59
  65. package/dist/seo-checker.js +107 -88
  66. package/dist/session-capture.js +212 -192
  67. package/dist/session-export.js +133 -107
  68. package/dist/sitemap-writer.js +41 -38
  69. package/dist/sitemap.js +202 -141
  70. package/dist/skyvern.js +148 -123
  71. package/dist/sqs-queue.js +42 -42
  72. package/dist/sso-token.js +90 -74
  73. package/dist/stealth/human-behavior.js +173 -156
  74. package/dist/telemetry.js +82 -68
  75. package/dist/test-executor.js +335 -309
  76. package/dist/tls-fingerprint.js +88 -77
  77. package/dist/url-pattern-completer.js +90 -71
  78. package/dist/visual-diff-verdict.js +38 -23
  79. package/dist/webhook-notifier.js +69 -71
  80. package/dist/websocket-detector.js +68 -56
  81. package/dist/worker.js +38 -29
  82. package/package.json +1 -1
package/dist/auth.js CHANGED
@@ -1,329 +1,366 @@
1
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
1
2
  async function firstVisible(page, selector) {
2
- const loc = page.locator(selector);
3
- const count = await loc.count().catch(() => 0);
4
- for (let i = 0; i < count; i += 1) {
5
- const item = loc.nth(i);
6
- if (await item.isVisible().catch(() => false)) return item;
7
- }
8
- for (const frame of page.frames()) {
9
- if (frame === page.mainFrame()) continue;
10
- const floc = frame.locator(selector);
11
- const fcount = await floc.count().catch(() => 0);
12
- for (let i = 0; i < fcount; i += 1) {
13
- const item = floc.nth(i);
14
- if (await item.isVisible().catch(() => false)) return item;
3
+ const loc = page.locator(selector);
4
+ const count = await loc.count().catch(() => 0);
5
+ for (let i = 0; i < count; i += 1) {
6
+ const item = loc.nth(i);
7
+ if (await item.isVisible().catch(() => false))
8
+ return item;
15
9
  }
16
- }
17
- return null;
10
+ // iframe fallback — Salesforce, Zendesk, and embedded login widgets hide forms in iframes
11
+ for (const frame of page.frames()) {
12
+ if (frame === page.mainFrame())
13
+ continue;
14
+ const floc = frame.locator(selector);
15
+ const fcount = await floc.count().catch(() => 0);
16
+ for (let i = 0; i < fcount; i += 1) {
17
+ const item = floc.nth(i);
18
+ if (await item.isVisible().catch(() => false))
19
+ return item;
20
+ }
21
+ }
22
+ return null;
18
23
  }
24
+ /** Type into framework-controlled inputs — handles React, Angular, Vue, and plain HTML forms. */
19
25
  async function reactFill(field, value) {
20
- if (!field) return;
21
- await field.click();
22
- await field.selectText().catch(() => {
23
- });
24
- await field.press("Control+a").catch(() => {
25
- });
26
- await field.press("Backspace").catch(() => {
27
- });
28
- await field.pressSequentially(value, { delay: 30 });
29
- await field.evaluate((el, v) => {
30
- const input = el;
31
- const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
32
- if (nativeInputValueSetter) nativeInputValueSetter.call(input, v);
33
- input.dispatchEvent(new Event("input", { bubbles: true }));
34
- input.dispatchEvent(new Event("change", { bubbles: true }));
35
- input.dispatchEvent(new Event("blur", { bubbles: true }));
36
- }, value).catch(() => {
37
- });
26
+ if (!field)
27
+ return;
28
+ await field.click();
29
+ await field.selectText().catch(() => { });
30
+ await field.press('Control+a').catch(() => { });
31
+ await field.press('Backspace').catch(() => { });
32
+ await field.pressSequentially(value, { delay: 30 });
33
+ // Fire native input + change events for Angular/Vue/custom frameworks that ignore DOM keypresses.
34
+ // React's synthetic listener catches pressSequentially above; this covers the rest.
35
+ await field.evaluate((el, v) => {
36
+ const input = el;
37
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
38
+ if (nativeInputValueSetter)
39
+ nativeInputValueSetter.call(input, v);
40
+ input.dispatchEvent(new Event('input', { bubbles: true }));
41
+ input.dispatchEvent(new Event('change', { bubbles: true }));
42
+ input.dispatchEvent(new Event('blur', { bubbles: true }));
43
+ }, value).catch(() => { });
38
44
  }
45
+ /**
46
+ * Find and click the most likely submit button.
47
+ * Waits up to 2 s for React's disabled={!email||!password} to clear before clicking.
48
+ */
39
49
  async function clickLikelySubmit(page) {
40
- let button = await firstVisible(page, [
41
- 'button[type="submit"]',
42
- 'input[type="submit"]',
43
- 'button:has-text("Next")',
44
- 'button:has-text("Sign in")',
45
- 'button:has-text("Log in")',
46
- 'button:has-text("Login")',
47
- 'button:has-text("Sign up")'
48
- ].join(", "));
49
- if (!button) {
50
- const candidates = page.locator('button:has-text("Continue")');
51
- const count = await candidates.count().catch(() => 0);
52
- for (let i = 0; i < count; i++) {
53
- const c = candidates.nth(i);
54
- if (!await c.isVisible().catch(() => false)) continue;
55
- const text = await c.textContent().catch(() => "") ?? "";
56
- if (/continue\s+with\s+/i.test(text)) continue;
57
- button = c;
58
- break;
50
+ // Prefer explicit submit selectors first; "Continue" is last resort to avoid
51
+ // accidentally clicking "Continue with Google/GitHub/Apple" SSO buttons.
52
+ let button = await firstVisible(page, [
53
+ 'button[type="submit"]',
54
+ 'input[type="submit"]',
55
+ 'button:has-text("Next")',
56
+ 'button:has-text("Sign in")',
57
+ 'button:has-text("Log in")',
58
+ 'button:has-text("Login")',
59
+ 'button:has-text("Sign up")',
60
+ ].join(', '));
61
+ if (!button) {
62
+ // Find "Continue" buttons but exclude OAuth provider ones ("Continue with Google" etc.)
63
+ const candidates = page.locator('button:has-text("Continue")');
64
+ const count = await candidates.count().catch(() => 0);
65
+ for (let i = 0; i < count; i++) {
66
+ const c = candidates.nth(i);
67
+ if (!(await c.isVisible().catch(() => false)))
68
+ continue;
69
+ const text = (await c.textContent().catch(() => '')) ?? '';
70
+ if (/continue\s+with\s+/i.test(text))
71
+ continue; // skip "Continue with Google/GitHub/Apple"
72
+ button = c;
73
+ break;
74
+ }
59
75
  }
60
- }
61
- if (button) {
62
- const handle = await button.elementHandle().catch(() => null);
63
- if (handle) {
64
- const deadline = Date.now() + 2e3;
65
- while (Date.now() < deadline) {
66
- const disabled = await handle.evaluate((el) => el.disabled).catch(() => false);
67
- if (!disabled) break;
68
- await new Promise((r) => setTimeout(r, 100));
69
- }
76
+ if (button) {
77
+ // Poll for enabled state — waitForFunction not available on Stagehand V3 proxy
78
+ const handle = await button.elementHandle().catch(() => null);
79
+ if (handle) {
80
+ const deadline = Date.now() + 2_000;
81
+ while (Date.now() < deadline) {
82
+ const disabled = await handle.evaluate((el) => el.disabled).catch(() => false);
83
+ if (!disabled)
84
+ break;
85
+ await new Promise(r => setTimeout(r, 100));
86
+ }
87
+ }
88
+ await Promise.all([
89
+ page.waitForLoadState('networkidle').catch(() => { }),
90
+ button.click({ timeout: 5_000 }).catch(() => { }), // page may navigate before click completes — ignore
91
+ ]);
92
+ return true;
70
93
  }
71
- await Promise.all([
72
- page.waitForLoadState("networkidle").catch(() => {
73
- }),
74
- button.click({ timeout: 5e3 }).catch(() => {
75
- })
76
- // page may navigate before click completes — ignore
77
- ]);
78
- return true;
79
- }
80
- await page.keyboard.press("Enter");
81
- await page.waitForLoadState("networkidle").catch(() => {
82
- });
83
- return false;
94
+ await page.keyboard.press('Enter');
95
+ await page.waitForLoadState('networkidle').catch(() => { });
96
+ return false;
84
97
  }
98
+ /** Dismiss cookie/GDPR consent banners so they don't block form fields. */
85
99
  async function dismissCookieBanners(page) {
86
- const acceptSel = [
87
- 'button:has-text("Accept")',
88
- 'button:has-text("Accept all")',
89
- 'button:has-text("Allow all")',
90
- 'button:has-text("I agree")',
91
- 'button:has-text("Agree")',
92
- 'button:has-text("OK")',
93
- 'button:has-text("Got it")',
94
- 'button:has-text("Close")',
95
- '[aria-label*="Accept" i]',
96
- '[aria-label*="Dismiss" i]',
97
- '[class*="cookie"] button',
98
- '[id*="cookie"] button',
99
- '[class*="consent"] button',
100
- '[class*="banner"] button[class*="close" i]'
101
- ].join(", ");
102
- const btn = await firstVisible(page, acceptSel);
103
- if (btn) await btn.click().catch(() => {
104
- });
105
- await page.waitForTimeout(300).catch(() => {
106
- });
100
+ const acceptSel = [
101
+ 'button:has-text("Accept")',
102
+ 'button:has-text("Accept all")',
103
+ 'button:has-text("Allow all")',
104
+ 'button:has-text("I agree")',
105
+ 'button:has-text("Agree")',
106
+ 'button:has-text("OK")',
107
+ 'button:has-text("Got it")',
108
+ 'button:has-text("Close")',
109
+ '[aria-label*="Accept" i]',
110
+ '[aria-label*="Dismiss" i]',
111
+ '[class*="cookie"] button',
112
+ '[id*="cookie"] button',
113
+ '[class*="consent"] button',
114
+ '[class*="banner"] button[class*="close" i]',
115
+ ].join(', ');
116
+ const btn = await firstVisible(page, acceptSel);
117
+ if (btn)
118
+ await btn.click().catch(() => { });
119
+ await page.waitForTimeout(300).catch(() => { });
107
120
  }
121
+ /**
122
+ * Look for a Sign In / Log In nav link on a marketing homepage and navigate to the actual login page.
123
+ * Returns true if navigation happened.
124
+ */
108
125
  async function navigateToLoginPage(page, appUrl) {
109
- const origin = (() => {
110
- try {
111
- return new URL(appUrl).origin;
112
- } catch {
113
- return "";
126
+ // First try common login URL suffixes
127
+ const origin = (() => { try {
128
+ return new URL(appUrl).origin;
114
129
  }
115
- })();
116
- const loginPaths = ["/login", "/sign-in", "/signin", "/auth/login", "/auth", "/account/login", "/user/login", "/app/login"];
117
- const navLoginSel = [
118
- 'a:has-text("Sign in")',
119
- 'a:has-text("Sign In")',
120
- 'a:has-text("Log in")',
121
- 'a:has-text("Log In")',
122
- 'a:has-text("Login")',
123
- 'a:has-text("Get started")',
124
- 'a[href*="login" i]',
125
- 'a[href*="sign-in" i]',
126
- 'a[href*="signin" i]',
127
- 'a[href*="auth" i]',
128
- 'button:has-text("Sign in")',
129
- 'button:has-text("Log in")',
130
- 'button:has-text("Login")'
131
- ].join(", ");
132
- const navLink = await firstVisible(page, navLoginSel);
133
- if (navLink) {
134
- const href = await navLink.getAttribute("href").catch(() => null);
135
- if (href && href.startsWith("http")) {
136
- await page.goto(href, { waitUntil: "networkidle", timeout: 15e3 }).catch(() => {
137
- });
138
- return true;
139
- }
140
- await navLink.click();
141
- const emailAppeared = await page.waitForSelector(EMAIL_SELECTOR, { timeout: 3e3 }).then(() => true).catch(() => false);
142
- if (!emailAppeared) {
143
- await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
144
- });
130
+ catch {
131
+ return '';
132
+ } })();
133
+ const loginPaths = ['/login', '/sign-in', '/signin', '/auth/login', '/auth', '/account/login', '/user/login', '/app/login'];
134
+ // Look for a "Sign In" / "Login" / "Get started" link in the nav
135
+ const navLoginSel = [
136
+ 'a:has-text("Sign in")',
137
+ 'a:has-text("Sign In")',
138
+ 'a:has-text("Log in")',
139
+ 'a:has-text("Log In")',
140
+ 'a:has-text("Login")',
141
+ 'a:has-text("Get started")',
142
+ 'a[href*="login" i]',
143
+ 'a[href*="sign-in" i]',
144
+ 'a[href*="signin" i]',
145
+ 'a[href*="auth" i]',
146
+ 'button:has-text("Sign in")',
147
+ 'button:has-text("Log in")',
148
+ 'button:has-text("Login")',
149
+ ].join(', ');
150
+ const navLink = await firstVisible(page, navLoginSel);
151
+ if (navLink) {
152
+ const href = await navLink.getAttribute('href').catch(() => null);
153
+ // If the link has an href, navigate directly — avoids modal-only flows
154
+ if (href && href.startsWith('http')) {
155
+ await page.goto(href, { waitUntil: 'networkidle', timeout: 15_000 }).catch(() => { });
156
+ return true;
157
+ }
158
+ // Click and immediately check for email field (modal opened) OR wait for navigation
159
+ await navLink.click();
160
+ const emailAppeared = await page.waitForSelector(EMAIL_SELECTOR, { timeout: 3_000 })
161
+ .then(() => true).catch(() => false);
162
+ if (!emailAppeared) {
163
+ // No modal — likely navigating to a new page
164
+ await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
165
+ }
166
+ return true;
145
167
  }
146
- return true;
147
- }
148
- if (origin) {
149
- for (const p of loginPaths) {
150
- const loginUrl = `${origin}${p}`;
151
- const res = await page.goto(loginUrl, { waitUntil: "networkidle", timeout: 1e4 }).catch(() => null);
152
- if (res && res.status() < 400) {
153
- const hasForm = await page.locator(
154
- 'input[type="email"], input[type="password"], input[name*="email" i], input[name*="user" i]'
155
- ).count().catch(() => 0) > 0;
156
- if (hasForm) return true;
157
- }
168
+ // Fallback: try common login paths on the same origin
169
+ if (origin) {
170
+ for (const p of loginPaths) {
171
+ const loginUrl = `${origin}${p}`;
172
+ const res = await page.goto(loginUrl, { waitUntil: 'networkidle', timeout: 10_000 }).catch(() => null);
173
+ if (res && res.status() < 400) {
174
+ // Check if this page actually has a login form
175
+ const hasForm = (await page.locator('input[type="email"], input[type="password"], input[name*="email" i], input[name*="user" i]').count().catch(() => 0)) > 0;
176
+ if (hasForm)
177
+ return true;
178
+ }
179
+ }
180
+ // Restore original page
181
+ await page.goto(appUrl, { waitUntil: 'networkidle', timeout: 15_000 }).catch(() => { });
158
182
  }
159
- await page.goto(appUrl, { waitUntil: "networkidle", timeout: 15e3 }).catch(() => {
160
- });
161
- }
162
- return false;
183
+ return false;
163
184
  }
185
+ /** Detect if current page redirected to a 3rd-party OAuth provider (SSO flow we can't automate). */
164
186
  function isOAuthProviderUrl(url) {
165
- const u = url.toLowerCase();
166
- if (u.includes("accounts.google.com")) return "Google";
167
- if (u.includes("github.com/login")) return "GitHub";
168
- if (u.includes("login.microsoftonline.com")) return "Microsoft";
169
- if (u.includes("login.live.com")) return "Microsoft";
170
- if (u.includes("appleid.apple.com")) return "Apple";
171
- if (u.includes("okta.com")) return "Okta";
172
- if (u.includes("auth0.com")) return "Auth0";
173
- if (u.includes("onelogin.com")) return "OneLogin";
174
- if (u.includes("pingidentity.com")) return "Ping Identity";
175
- return null;
187
+ const u = url.toLowerCase();
188
+ if (u.includes('accounts.google.com'))
189
+ return 'Google';
190
+ if (u.includes('github.com/login'))
191
+ return 'GitHub';
192
+ if (u.includes('login.microsoftonline.com'))
193
+ return 'Microsoft';
194
+ if (u.includes('login.live.com'))
195
+ return 'Microsoft';
196
+ if (u.includes('appleid.apple.com'))
197
+ return 'Apple';
198
+ if (u.includes('okta.com'))
199
+ return 'Okta';
200
+ if (u.includes('auth0.com'))
201
+ return 'Auth0';
202
+ if (u.includes('onelogin.com'))
203
+ return 'OneLogin';
204
+ if (u.includes('pingidentity.com'))
205
+ return 'Ping Identity';
206
+ return null;
176
207
  }
177
208
  const EMAIL_SELECTOR = 'input[type="email"], input[name*="user" i], input[name*="email" i], input[autocomplete="username"]';
178
209
  const PASS_SELECTOR = 'input[type="password"], input[autocomplete="current-password"]';
210
+ /** Replay recorded login steps from the Chrome extension. Returns true if login succeeded. */
179
211
  async function replayLoginRecipe(page, steps, creds) {
180
- for (const step of steps) {
181
- try {
182
- if (step.action === "wait") {
183
- await page.waitForTimeout(step.ms ?? 500).catch(() => {
184
- });
185
- } else if (step.action === "click") {
186
- const el = await firstVisible(page, step.selector);
187
- if (!el) return false;
188
- await el.click({ timeout: 5e3 }).catch(() => {
189
- });
190
- } else if (step.action === "fill") {
191
- const el = await firstVisible(page, step.selector);
192
- if (!el) return false;
193
- const val = (step.value ?? "").replace("{username}", creds.username).replace("{password}", creds.password);
194
- await reactFill(el, val);
195
- }
196
- await page.waitForTimeout(200).catch(() => {
197
- });
198
- } catch {
199
- return false;
212
+ for (const step of steps) {
213
+ try {
214
+ if (step.action === 'wait') {
215
+ await page.waitForTimeout(step.ms ?? 500).catch(() => { });
216
+ }
217
+ else if (step.action === 'click') {
218
+ const el = await firstVisible(page, step.selector);
219
+ if (!el)
220
+ return false;
221
+ await el.click({ timeout: 5_000 }).catch(() => { });
222
+ }
223
+ else if (step.action === 'fill') {
224
+ const el = await firstVisible(page, step.selector);
225
+ if (!el)
226
+ return false;
227
+ const val = (step.value ?? '')
228
+ .replace('{username}', creds.username)
229
+ .replace('{password}', creds.password);
230
+ await reactFill(el, val);
231
+ }
232
+ await page.waitForTimeout(200).catch(() => { });
233
+ }
234
+ catch {
235
+ return false;
236
+ }
200
237
  }
201
- }
202
- await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
203
- });
204
- const postUrl = page.url();
205
- const LOGIN_PATH_RE = /\/(login|signin|sign-in|auth|log-in|sso|oauth)(\/|$|\?)/i;
206
- if (!LOGIN_PATH_RE.test(new URL(postUrl).pathname)) return true;
207
- const passCount = await page.locator('input[type="password"]').count().catch(() => 0);
208
- return passCount === 0;
238
+ await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => { });
239
+ const postUrl = page.url();
240
+ const LOGIN_PATH_RE = /\/(login|signin|sign-in|auth|log-in|sso|oauth)(\/|$|\?)/i;
241
+ if (!LOGIN_PATH_RE.test(new URL(postUrl).pathname))
242
+ return true; // navigated away — success
243
+ const passCount = await page.locator('input[type="password"]').count().catch(() => 0);
244
+ return passCount === 0;
209
245
  }
210
- async function login(page, appUrl, creds) {
211
- await page.goto(appUrl, { waitUntil: "networkidle", timeout: 3e4 }).catch(() => {
212
- });
213
- if (creds.loginRecipe && creds.loginRecipe.length > 0) {
214
- const recipeOk = await replayLoginRecipe(page, creds.loginRecipe, creds);
215
- if (recipeOk) return true;
216
- await page.goto(appUrl, { waitUntil: "networkidle", timeout: 3e4 }).catch(() => {
217
- });
218
- }
219
- await dismissCookieBanners(page);
220
- let userField = await firstVisible(page, EMAIL_SELECTOR);
221
- if (!userField) {
222
- await navigateToLoginPage(page, appUrl);
223
- userField = await firstVisible(page, EMAIL_SELECTOR);
246
+ // ─── Main login function ───────────────────────────────────────────────────────
247
+ export async function login(page, appUrl, creds) {
248
+ await page.goto(appUrl, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
249
+ // Recipe replay — try exact recorded steps first when available
250
+ if (creds.loginRecipe && creds.loginRecipe.length > 0) {
251
+ const recipeOk = await replayLoginRecipe(page, creds.loginRecipe, creds);
252
+ if (recipeOk)
253
+ return true;
254
+ // Recipe failed — reload page and fall through to DOM heuristic
255
+ await page.goto(appUrl, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
256
+ }
257
+ // Dismiss cookie banners that might block form interaction
258
+ await dismissCookieBanners(page);
259
+ let userField = await firstVisible(page, EMAIL_SELECTOR);
260
+ // No email field on the landing page — this is a marketing homepage.
261
+ // Try to navigate to the actual login page.
224
262
  if (!userField) {
225
- await page.waitForSelector(EMAIL_SELECTOR, { timeout: 5e3 }).catch(() => {
226
- });
227
- userField = await firstVisible(page, EMAIL_SELECTOR);
263
+ await navigateToLoginPage(page, appUrl);
264
+ // NOTE: do NOT call dismissCookieBanners here — it can close login modals
265
+ // navigateToLoginPage already waits for email field or networkidle
266
+ userField = await firstVisible(page, EMAIL_SELECTOR);
267
+ if (!userField) {
268
+ // Last attempt: wait up to 5s for form to appear (SPA redirect or slow modal)
269
+ await page.waitForSelector(EMAIL_SELECTOR, { timeout: 5_000 }).catch(() => { });
270
+ userField = await firstVisible(page, EMAIL_SELECTOR);
271
+ }
228
272
  }
229
- }
230
- if (!userField) return false;
231
- await reactFill(userField, creds.username);
232
- await page.keyboard.press("Tab");
233
- let passField = await firstVisible(page, PASS_SELECTOR);
234
- if (!passField) {
235
- await clickLikelySubmit(page);
236
- await page.waitForSelector(PASS_SELECTOR, { timeout: 8e3 }).catch(() => {
237
- });
238
- const ssoProvider = isOAuthProviderUrl(page.url());
239
- if (ssoProvider) {
240
- throw Object.assign(
241
- new Error(
242
- `This app uses ${ssoProvider} SSO \u2014 ZeTa cannot automate OAuth login. Use the ZeTa Capture Chrome Extension instead: log in normally in Chrome, then click the extension to capture your session.`
243
- ),
244
- { isLoginError: true, isSSOError: true, ssoProvider }
245
- );
273
+ if (!userField)
274
+ return false;
275
+ await reactFill(userField, creds.username);
276
+ await page.keyboard.press('Tab');
277
+ let passField = await firstVisible(page, PASS_SELECTOR);
278
+ if (!passField) {
279
+ // Multi-step form: email-first, then password on next screen
280
+ await clickLikelySubmit(page);
281
+ // Wait for password field to appear (up to 8s) rather than a fixed 2s sleep —
282
+ // slow IdP servers (Okta, Azure) take 3-5s to validate email and render the next step.
283
+ await page.waitForSelector(PASS_SELECTOR, { timeout: 8_000 }).catch(() => { });
284
+ // Check for SSO redirect after email submission
285
+ const ssoProvider = isOAuthProviderUrl(page.url());
286
+ if (ssoProvider) {
287
+ throw Object.assign(new Error(`This app uses ${ssoProvider} SSO — ZeTa cannot automate OAuth login. ` +
288
+ `Use the ZeTa Capture Chrome Extension instead: log in normally in Chrome, ` +
289
+ `then click the extension to capture your session.`), { isLoginError: true, isSSOError: true, ssoProvider });
290
+ }
291
+ passField = await firstVisible(page, PASS_SELECTOR);
246
292
  }
247
- passField = await firstVisible(page, PASS_SELECTOR);
248
- }
249
- if (!passField) {
250
- const ssoProvider = isOAuthProviderUrl(page.url());
251
- if (ssoProvider) {
252
- throw Object.assign(
253
- new Error(
254
- `This app uses ${ssoProvider} SSO \u2014 ZeTa cannot automate OAuth login. Use the ZeTa Capture Chrome Extension instead: log in normally in Chrome, then click the extension to capture your session.`
255
- ),
256
- { isLoginError: true, isSSOError: true, ssoProvider }
257
- );
293
+ if (!passField) {
294
+ // Still no password field — check if we landed on an SSO provider
295
+ const ssoProvider = isOAuthProviderUrl(page.url());
296
+ if (ssoProvider) {
297
+ throw Object.assign(new Error(`This app uses ${ssoProvider} SSO — ZeTa cannot automate OAuth login. ` +
298
+ `Use the ZeTa Capture Chrome Extension instead: log in normally in Chrome, ` +
299
+ `then click the extension to capture your session.`), { isLoginError: true, isSSOError: true, ssoProvider });
300
+ }
301
+ return false;
258
302
  }
259
- return false;
260
- }
261
- await reactFill(passField, creds.password);
262
- await page.waitForTimeout(150).catch(() => {
263
- });
264
- await Promise.all([
265
- page.waitForLoadState("networkidle").catch(() => {
266
- }),
267
- page.keyboard.press("Enter")
268
- ]);
269
- const stillHasPass = await page.locator(PASS_SELECTOR).count().catch(() => 0);
270
- if (stillHasPass > 0) {
271
- await page.keyboard.press("Tab");
272
- await clickLikelySubmit(page);
273
- }
274
- const LOGIN_PATH_RE_NAV = /\/(login|signin|sign-in|auth|log-in|sso|oauth)(\/|$|\?)/i;
275
- if (LOGIN_PATH_RE_NAV.test(new URL(page.url()).pathname)) {
276
- await page.waitForURL(
277
- (url) => {
278
- try {
279
- return !LOGIN_PATH_RE_NAV.test(new URL(url).pathname);
280
- } catch {
281
- return true;
303
+ await reactFill(passField, creds.password);
304
+ // Press Enter — submits regardless of button disabled state
305
+ await page.waitForTimeout(150).catch(() => { });
306
+ await Promise.all([
307
+ page.waitForLoadState('networkidle').catch(() => { }),
308
+ page.keyboard.press('Enter'),
309
+ ]);
310
+ // Fallback: if Enter didn't navigate away, try Tab → click
311
+ const stillHasPass = await page.locator(PASS_SELECTOR).count().catch(() => 0);
312
+ if (stillHasPass > 0) {
313
+ await page.keyboard.press('Tab');
314
+ await clickLikelySubmit(page);
315
+ }
316
+ // Wait for post-submit navigation to complete — waitForLoadState resolves immediately
317
+ // if page is already idle, so use waitForURL to detect actual navigation away from login.
318
+ const LOGIN_PATH_RE_NAV = /\/(login|signin|sign-in|auth|log-in|sso|oauth)(\/|$|\?)/i;
319
+ if (LOGIN_PATH_RE_NAV.test(new URL(page.url()).pathname)) {
320
+ await page.waitForURL((url) => { try {
321
+ return !LOGIN_PATH_RE_NAV.test(new URL(url).pathname);
282
322
  }
283
- },
284
- { timeout: 12e3 }
285
- ).catch(() => {
286
- });
287
- }
288
- const ssoAfterLogin = isOAuthProviderUrl(page.url());
289
- if (ssoAfterLogin) {
290
- throw Object.assign(
291
- new Error(
292
- `This app uses ${ssoAfterLogin} SSO \u2014 ZeTa cannot automate OAuth login. Use the ZeTa Capture Chrome Extension instead.`
293
- ),
294
- { isLoginError: true, isSSOError: true, ssoProvider: ssoAfterLogin }
295
- );
296
- }
297
- const otpField = await firstVisible(page, [
298
- 'input[autocomplete="one-time-code"]',
299
- 'input[name*="otp" i]',
300
- 'input[name*="code" i]',
301
- 'input[aria-label*="code" i]',
302
- 'input[placeholder*="code" i]'
303
- ].join(", "));
304
- if (otpField) {
305
- const code = creds.oneTimeCode ?? await creds.getOneTimeCode?.();
306
- if (!code) return false;
307
- await reactFill(otpField, code);
308
- await clickLikelySubmit(page);
309
- }
310
- const postLoginUrl = page.url();
311
- const LOGIN_PATH_RE = /\/(login|signin|sign-in|auth|log-in|sso|oauth)(\/|$|\?)/i;
312
- const stillOnLoginPath = LOGIN_PATH_RE.test(new URL(postLoginUrl).pathname);
313
- const stillOnLogin = await page.locator(PASS_SELECTOR).count();
314
- if (stillOnLoginPath && stillOnLogin > 0) {
315
- const errorLoc = page.locator('[role="alert"], .error, [class*="error"], [class*="alert"]');
316
- await errorLoc.first().waitFor({ state: "visible", timeout: 2e3 }).catch(() => {
317
- });
318
- const pageError = await errorLoc.first().textContent().catch(() => null);
319
- if (pageError?.trim()) {
320
- throw Object.assign(new Error(pageError.trim()), { isLoginError: true });
323
+ catch {
324
+ return true;
325
+ } }, { timeout: 12_000 }).catch(() => { });
321
326
  }
322
- return false;
323
- }
324
- return true;
327
+ // Check for SSO redirect after password submit
328
+ const ssoAfterLogin = isOAuthProviderUrl(page.url());
329
+ if (ssoAfterLogin) {
330
+ throw Object.assign(new Error(`This app uses ${ssoAfterLogin} SSO — ZeTa cannot automate OAuth login. ` +
331
+ `Use the ZeTa Capture Chrome Extension instead.`), { isLoginError: true, isSSOError: true, ssoProvider: ssoAfterLogin });
332
+ }
333
+ // Handle OTP / 2FA
334
+ const otpField = await firstVisible(page, [
335
+ 'input[autocomplete="one-time-code"]',
336
+ 'input[name*="otp" i]',
337
+ 'input[name*="code" i]',
338
+ 'input[aria-label*="code" i]',
339
+ 'input[placeholder*="code" i]',
340
+ ].join(', '));
341
+ if (otpField) {
342
+ const code = creds.oneTimeCode ?? await creds.getOneTimeCode?.();
343
+ if (!code)
344
+ return false;
345
+ await reactFill(otpField, code);
346
+ await clickLikelySubmit(page);
347
+ }
348
+ // Success check: navigated away from login page, and/or no password field remains.
349
+ // Use URL as primary signal — dashboards can have hidden password fields (profile forms, etc.)
350
+ // that would cause false negatives if we only check input[type="password"].
351
+ const postLoginUrl = page.url();
352
+ const LOGIN_PATH_RE = /\/(login|signin|sign-in|auth|log-in|sso|oauth)(\/|$|\?)/i;
353
+ const stillOnLoginPath = LOGIN_PATH_RE.test(new URL(postLoginUrl).pathname);
354
+ const stillOnLogin = await page.locator(PASS_SELECTOR).count();
355
+ // Only treat as failure if BOTH: still on a login-path URL AND password field present
356
+ if (stillOnLoginPath && stillOnLogin > 0) {
357
+ const errorLoc = page.locator('[role="alert"], .error, [class*="error"], [class*="alert"]');
358
+ await errorLoc.first().waitFor({ state: 'visible', timeout: 2_000 }).catch(() => { });
359
+ const pageError = await errorLoc.first().textContent().catch(() => null);
360
+ if (pageError?.trim()) {
361
+ throw Object.assign(new Error(pageError.trim()), { isLoginError: true });
362
+ }
363
+ return false;
364
+ }
365
+ return true;
325
366
  }
326
- export {
327
- login
328
- };
329
- //# sourceMappingURL=auth.js.map