diff --git a/src/browser/client.ts b/src/browser/client.ts index 368a845..a7c5cc1 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -6,6 +6,7 @@ import fs from 'fs'; export class BrowserClient { private context: BrowserContext | null = null; + private sessionDir: string | null = null; constructor( private config: Config, @@ -14,6 +15,7 @@ export class BrowserClient { async initialize(): Promise { const sessionDir = path.resolve(process.cwd(), this.config.browser.sessionDir); + this.sessionDir = sessionDir; // Ensure session directory exists if (!fs.existsSync(sessionDir)) { @@ -171,6 +173,26 @@ export class BrowserClient { page.waitForURL((url) => !url.toString().includes('/login'), { timeout: 20000 }).catch(() => undefined), ]); + // Give cookies/time to settle + await page.waitForTimeout(2000); + + const obstacleInfo = await this.detectLoginObstacles(page); + if (obstacleInfo.twoFactor) { + this.logger.error('Login requires 2FA. Please run "npm run login" locally to complete it once.'); + await this.captureLoginDebug(page, 'login-2fa'); + return false; + } + if (obstacleInfo.captcha) { + this.logger.error('Login blocked by CAPTCHA. Please run "npm run login" locally to complete it once.'); + await this.captureLoginDebug(page, 'login-captcha'); + return false; + } + if (obstacleInfo.errorText) { + this.logger.error(`Login failed with error: ${obstacleInfo.errorText}`); + await this.captureLoginDebug(page, 'login-error'); + return false; + } + // Confirm auth via UI or API check const isValid = await this.isSessionValid(); if (isValid) { @@ -179,10 +201,7 @@ export class BrowserClient { } // Check for error messages - const errorContent = await page.evaluate(() => { - const err = document.querySelector('[role="alert"], .AnimatedForm__errorMessage'); - return err ? err.textContent : null; - }); + const errorContent = obstacleInfo.errorText; if (errorContent) { this.logger.error(`Login failed with error: ${errorContent}`); @@ -190,6 +209,7 @@ export class BrowserClient { this.logger.error('Login flow finished but session is invalid.'); } + await this.captureLoginDebug(page, 'login-invalid-session'); return false; } catch (error) { @@ -197,4 +217,67 @@ export class BrowserClient { return false; } } + + private async detectLoginObstacles(page: Page): Promise<{ + twoFactor: boolean; + captcha: boolean; + errorText: string | null; + }> { + try { + const info = await page.evaluate(() => { + const errorCandidates = Array.from( + document.querySelectorAll('[role="alert"], .AnimatedForm__errorMessage, [data-testid*="error"], [id*="error"]') + ) + .map((el) => el.textContent?.trim()) + .filter((text) => text && text.length > 0) as string[]; + + const otpInput = document.querySelector('input[name="otp"], input[autocomplete="one-time-code"], input[type="tel"][maxlength="6"]'); + const captchaElement = document.querySelector('iframe[src*="captcha"], iframe[src*="recaptcha"], [data-sitekey], #captcha, .captcha'); + + return { + errorText: errorCandidates[0] ?? null, + hasOtp: Boolean(otpInput), + hasCaptcha: Boolean(captchaElement), + }; + }); + + const frameCaptcha = page.frames().some((frame) => /captcha|recaptcha/i.test(frame.url())); + + return { + twoFactor: info.hasOtp, + captcha: info.hasCaptcha || frameCaptcha, + errorText: info.errorText, + }; + } catch { + return { twoFactor: false, captcha: false, errorText: null }; + } + } + + private async captureLoginDebug(page: Page, label: string): Promise { + try { + const baseDir = this.sessionDir ?? path.resolve(process.cwd(), 'data'); + if (!fs.existsSync(baseDir)) { + fs.mkdirSync(baseDir, { recursive: true }); + } + + const safeTimestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const screenshotPath = path.join(baseDir, `${label}-${safeTimestamp}.png`); + const htmlPath = path.join(baseDir, `${label}-${safeTimestamp}.html`); + + await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => undefined); + const html = await page.content().catch(() => ''); + if (html) { + fs.writeFileSync(htmlPath, html, 'utf-8'); + } + + this.logger.info(`Saved login debug artifacts to ${screenshotPath} and ${htmlPath}`); + this.logger.info(`Login debug URL: ${page.url()}`); + const title = await page.title().catch(() => ''); + if (title) { + this.logger.info(`Login debug title: ${title}`); + } + } catch (error) { + this.logger.warn('Failed to capture login debug artifacts:', error); + } + } }