diff --git a/src/functions/Login.ts b/src/functions/Login.ts index 23c1f85..624b813 100644 --- a/src/functions/Login.ts +++ b/src/functions/Login.ts @@ -8,6 +8,7 @@ import { saveSessionData } from '../util/Load' import { MicrosoftRewardsBot } from '../index' import { OAuth } from '../interface/OAuth' import { Retry } from '../util/Retry' +import { LoginState, LoginStateDetector } from '../util/LoginStateDetector' // ------------------------------- // Constants / Tunables @@ -246,7 +247,17 @@ export class Login { await this.bot.browser.utils.reloadBadPage(page) await this.bot.utils.wait(250) - const portalSelector = await this.waitForRewardsRoot(page, 3500) + // IMPROVED: Increased timeout from 3.5s to 8s for slow connections + let portalSelector = await this.waitForRewardsRoot(page, 8000) + + // IMPROVED: Retry once if initial check failed + if (!portalSelector) { + this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal not detected (8s), retrying once...', 'warn') + await this.bot.utils.wait(1000) + await this.bot.browser.utils.reloadBadPage(page) + portalSelector = await this.waitForRewardsRoot(page, 5000) + } + if (portalSelector) { // Additional validation: make sure we're not just on the page but actually logged in // Check if we're redirected to login @@ -256,7 +267,7 @@ export class Login { return false } - this.bot.log(this.bot.isMobile, 'LOGIN', `Existing session still valid (${portalSelector})`) + this.bot.log(this.bot.isMobile, 'LOGIN', `✅ Existing session still valid (${portalSelector}) — saved 2-3 minutes!`) await this.checkAccountLocked(page) return true } @@ -280,22 +291,49 @@ export class Login { } private async performLoginFlow(page: Page, email: string, password: string) { + // Step 1: Input email await this.inputEmail(page, email) - await this.bot.utils.wait(1000) - await this.bot.browser.utils.reloadBadPage(page) + + // Step 2: Wait for transition to password page (VALIDATION PROGRESSIVE) + this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for password page transition...') + const passwordPageReached = await LoginStateDetector.waitForAnyState( + page, + [LoginState.PasswordPage, LoginState.TwoFactorRequired, LoginState.LoggedIn], + 8000 + ) + + if (passwordPageReached === LoginState.LoggedIn) { + this.bot.log(this.bot.isMobile, 'LOGIN', 'Already authenticated after email (fast path)') + return + } + + if (!passwordPageReached) { + this.bot.log(this.bot.isMobile, 'LOGIN', 'Password page not reached after 8s, continuing anyway...', 'warn') + } else { + this.bot.log(this.bot.isMobile, 'LOGIN', `Transitioned to state: ${passwordPageReached}`) + } + await this.bot.utils.wait(500) + await this.bot.browser.utils.reloadBadPage(page) + + // Step 3: Recovery mismatch check await this.tryRecoveryMismatchCheck(page, email) if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'recovery-mismatch') { this.bot.log(this.bot.isMobile,'LOGIN','Recovery mismatch detected – stopping before password entry','warn') return } - // Try switching to password if a locale link is present (FR/EN) + + // Step 4: Try switching to password if needed await this.switchToPasswordLink(page) + + // Step 5: Input password or handle 2FA await this.inputPasswordOr2FA(page, password) if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'sign-in-blocked') { this.bot.log(this.bot.isMobile, 'LOGIN', 'Blocked sign-in detected — halting.', 'warn') return } + + // Step 6: Final checks await this.checkAccountLocked(page) await this.awaitRewardsPortal(page) } @@ -460,17 +498,26 @@ export class Login { const usedTotp = await this.tryAutoTotp(page, 'manual 2FA entry') if (usedTotp) return - // Manual prompt - simplified without interval checking + // Manual prompt with 120s timeout this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for user 2FA code (SMS / Email / App fallback)') const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) try { - const code = await new Promise(res => { - rl.question('Enter 2FA code:\n', ans => { - rl.close() - res(ans.trim()) + // IMPROVED: Add 120s timeout to prevent infinite blocking + const code = await Promise.race([ + new Promise(res => { + rl.question('Enter 2FA code:\n', ans => { + rl.close() + res(ans.trim()) + }) + }), + new Promise((_, reject) => { + setTimeout(() => { + rl.close() + reject(new Error('2FA code input timeout after 120s')) + }, 120000) }) - }) + ]) // Check if input field still exists before trying to fill const inputExists = await page.locator('input[name="otc"]').first().isVisible({ timeout: 1000 }).catch(() => false) @@ -483,6 +530,13 @@ export class Login { await page.fill('input[name="otc"]', code) await page.keyboard.press('Enter') this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted') + } catch (error) { + if (error instanceof Error && error.message.includes('timeout')) { + this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code input timeout (120s) - user AFK', 'error') + throw error + } + // Other errors, just log and continue + this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code entry error: ' + error, 'warn') } finally { try { rl.close() } catch {/* ignore */} } @@ -811,6 +865,13 @@ export class Login { let lastUrl = '' let checkCount = 0 + // EARLY EXIT: Check if already logged in immediately + const initialState = await LoginStateDetector.detectState(page) + if (initialState.state === LoginState.LoggedIn) { + this.bot.log(this.bot.isMobile, 'LOGIN', 'Already on rewards portal (early exit)') + return + } + while (Date.now() - start < DEFAULT_TIMEOUTS.loginMaxMs) { checkCount++ @@ -820,6 +881,19 @@ export class Login { lastUrl = currentUrl } + // SMART CHECK: Use LoginStateDetector every 5 iterations for fast detection + if (checkCount % 5 === 0) { + const state = await LoginStateDetector.detectState(page) + if (state.state === LoginState.LoggedIn) { + this.bot.log(this.bot.isMobile, 'LOGIN', `State detector confirmed: ${state.state} (confidence: ${state.confidence})`) + break + } + if (state.state === LoginState.Blocked) { + this.bot.log(this.bot.isMobile, 'LOGIN', 'Blocked state detected during portal wait', 'error') + throw new Error('Account blocked during login') + } + } + // OPTIMIZATION: Quick URL check first const u = new URL(currentUrl) const isRewardsHost = u.hostname === LOGIN_TARGET.host diff --git a/src/util/LoginStateDetector.ts b/src/util/LoginStateDetector.ts new file mode 100644 index 0000000..534f695 --- /dev/null +++ b/src/util/LoginStateDetector.ts @@ -0,0 +1,184 @@ +import type { Page } from 'playwright' + +/** + * Login flow states for better tracking and debugging + */ +export enum LoginState { + Unknown = 'UNKNOWN', + InitialLoad = 'INITIAL_LOAD', + EmailPage = 'EMAIL_PAGE', + EmailSubmitted = 'EMAIL_SUBMITTED', + PasswordPage = 'PASSWORD_PAGE', + PasswordSubmitted = 'PASSWORD_SUBMITTED', + TwoFactorRequired = '2FA_REQUIRED', + TwoFactorSubmitted = '2FA_SUBMITTED', + PasskeyPrompt = 'PASSKEY_PROMPT', + RecoveryCheck = 'RECOVERY_CHECK', + LoggedIn = 'LOGGED_IN', + Blocked = 'BLOCKED', + Error = 'ERROR' +} + +/** + * Result of login state detection + */ +export interface LoginStateDetection { + state: LoginState + confidence: 'high' | 'medium' | 'low' + url: string + indicators: string[] +} + +/** + * LoginStateDetector: Intelligent detection of current login flow state + * Helps avoid assumptions and provides clear visibility of where we are + */ +export class LoginStateDetector { + /** + * Detect current state of login flow based on page URL and DOM + */ + static async detectState(page: Page): Promise { + const url = page.url() + const indicators: string[] = [] + + // State 1: Already logged in (rewards portal) + if (url.includes('rewards.bing.com') || url.includes('rewards.microsoft.com')) { + const hasPortal = await page.locator('html[data-role-name*="RewardsPortal"], #dashboard, main[data-bi-name="dashboard"]') + .first() + .isVisible({ timeout: 1000 }) + .catch(() => false) + + if (hasPortal) { + indicators.push('Rewards portal detected') + return { state: LoginState.LoggedIn, confidence: 'high', url, indicators } + } + + // On rewards domain but no portal = might be loading or error + const bodyLength = await page.evaluate(() => document.body?.innerText?.length || 0).catch(() => 0) + if (bodyLength > 100) { + indicators.push('On rewards domain, checking content...') + return { state: LoginState.LoggedIn, confidence: 'medium', url, indicators } + } + } + + // State 2: Microsoft login pages + if (url.includes('login.live.com') || url.includes('login.microsoftonline.com')) { + // Check for email input + const hasEmailInput = await page.locator('input[type="email"], input[name="loginfmt"]') + .first() + .isVisible({ timeout: 800 }) + .catch(() => false) + + if (hasEmailInput) { + indicators.push('Email input field detected') + return { state: LoginState.EmailPage, confidence: 'high', url, indicators } + } + + // Check for password input + const hasPasswordInput = await page.locator('input[type="password"], input[name="passwd"]') + .first() + .isVisible({ timeout: 800 }) + .catch(() => false) + + if (hasPasswordInput) { + indicators.push('Password input field detected') + return { state: LoginState.PasswordPage, confidence: 'high', url, indicators } + } + + // Check for 2FA/OTP input + const hasTotpInput = await page.locator('input[name="otc"], input[autocomplete="one-time-code"]') + .first() + .isVisible({ timeout: 800 }) + .catch(() => false) + + if (hasTotpInput) { + indicators.push('2FA/TOTP input field detected') + return { state: LoginState.TwoFactorRequired, confidence: 'high', url, indicators } + } + + // Check for passkey/Windows Hello prompts + const hasPasskeyPrompt = await page.locator('[data-testid="title"]') + .first() + .textContent() + .then((text: string | null) => /sign in faster|passkey|fingerprint|windows hello/i.test(text || '')) + .catch(() => false) + + if (hasPasskeyPrompt) { + indicators.push('Passkey/Windows Hello prompt detected') + return { state: LoginState.PasskeyPrompt, confidence: 'high', url, indicators } + } + + // Check for blocked/error messages + const hasBlockedMessage = await page.locator('[data-testid="title"], h1') + .first() + .textContent() + .then((text: string | null) => /can[''`]?t sign you in|blocked|locked/i.test(text || '')) + .catch(() => false) + + if (hasBlockedMessage) { + indicators.push('Account blocked/error message detected') + return { state: LoginState.Blocked, confidence: 'high', url, indicators } + } + + // Generic login page + indicators.push('On Microsoft login domain, state unclear') + return { state: LoginState.Unknown, confidence: 'low', url, indicators } + } + + // State 3: OAuth flow (mobile token) + if (url.includes('/oauth20_authorize.srf') || url.includes('/oauth20_desktop.srf')) { + indicators.push('OAuth flow detected') + return { state: LoginState.EmailSubmitted, confidence: 'medium', url, indicators } + } + + // Unknown state + indicators.push('Unknown page') + return { state: LoginState.Unknown, confidence: 'low', url, indicators } + } + + /** + * Wait for state transition with timeout + * Returns true if expected state reached, false if timeout + */ + static async waitForState( + page: Page, + expectedState: LoginState, + timeoutMs: number = 10000 + ): Promise { + const start = Date.now() + + while (Date.now() - start < timeoutMs) { + const detection = await LoginStateDetector.detectState(page) + if (detection.state === expectedState) { + return true + } + + // Fast polling for quick transitions + await new Promise(resolve => setTimeout(resolve, 200)) + } + + return false + } + + /** + * Wait for any of multiple states (whichever comes first) + */ + static async waitForAnyState( + page: Page, + expectedStates: LoginState[], + timeoutMs: number = 10000 + ): Promise { + const start = Date.now() + + while (Date.now() - start < timeoutMs) { + const detection = await LoginStateDetector.detectState(page) + if (expectedStates.includes(detection.state)) { + return detection.state + } + + await new Promise(resolve => setTimeout(resolve, 200)) + } + + return null + } +} diff --git a/src/util/SecurityDetector.ts b/src/util/SecurityDetector.ts new file mode 100644 index 0000000..a638300 --- /dev/null +++ b/src/util/SecurityDetector.ts @@ -0,0 +1,210 @@ +import type { Page } from 'playwright' + +/** + * Security incident detected during login/authentication + */ +export interface SecurityIncident { + kind: string + account: string + details?: string[] + next?: string[] + docsUrl?: string +} + +/** + * SecurityDetector: Centralized detection of login security blocks and anomalies + * Extracted from Login.ts for testability and separation of concerns + */ +export class SecurityDetector { + // Sign-in block patterns (Microsoft security messages) + private static readonly SIGN_IN_BLOCK_PATTERNS: { re: RegExp; label: string }[] = [ + { re: /we can[''`]?t sign you in/i, label: 'cant-sign-in' }, + { re: /incorrect account or password too many times/i, label: 'too-many-incorrect' }, + { re: /used an incorrect account or password too many times/i, label: 'too-many-incorrect-variant' }, + { re: /sign-in has been blocked/i, label: 'sign-in-blocked-phrase' }, + { re: /your account has been locked/i, label: 'account-locked' }, + { re: /your account or password is incorrect too many times/i, label: 'incorrect-too-many-times' } + ] + + /** + * Check if page contains sign-in blocked message + * Returns matched pattern label or null + */ + static async detectSignInBlocked(page: Page): Promise { + try { + let text = '' + const selectors = ['[data-testid="title"]', 'h1', 'div[role="heading"]', 'div.text-title'] + + for (const sel of selectors) { + const el = await page.waitForSelector(sel, { timeout: 600 }).catch(() => null) + if (el) { + const t = (await el.textContent() || '').trim() + if (t && t.length < 300) text += ' ' + t + } + } + + const lower = text.toLowerCase() + for (const p of SecurityDetector.SIGN_IN_BLOCK_PATTERNS) { + if (p.re.test(lower)) { + return p.label + } + } + + return null + } catch { + return null + } + } + + /** + * Parse masked email from Microsoft recovery prompts + * Returns { prefix: string, domain: string } or null + * Examples: "k*****@domain.com" → { prefix: "k", domain: "domain.com" } + * "ko****@domain.com" → { prefix: "ko", domain: "domain.com" } + */ + static parseMaskedEmail(masked: string): { prefix: string; domain: string } | null { + // Pattern: 1-2 visible chars, then masked chars, then @domain + const regex = /([a-zA-Z0-9]{1,2})[a-zA-Z0-9*•._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/ + const match = regex.exec(masked) + + if (!match) { + // Fallback: try looser pattern + const loose = /([a-zA-Z0-9])[*•][a-zA-Z0-9*•._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/.exec(masked) + if (!loose) return null + return { + prefix: (loose[1] || '').toLowerCase(), + domain: (loose[2] || '').toLowerCase() + } + } + + return { + prefix: (match[1] || '').toLowerCase(), + domain: (match[2] || '').toLowerCase() + } + } + + /** + * Check if masked email matches expected recovery email + * Lenient matching: only compare visible prefix (1-2 chars) + domain + */ + static matchesMaskedEmail( + observed: { prefix: string; domain: string }, + expected: { prefix: string; domain: string } + ): boolean { + if (observed.domain !== expected.domain) return false + + // If only 1 char visible, match first char only + if (observed.prefix.length === 1) { + return expected.prefix.startsWith(observed.prefix) + } + + // If 2 chars visible, both must match + return expected.prefix === observed.prefix + } + + /** + * Extract all masked emails from page (candidates for recovery email check) + * Returns array of masked email strings found in DOM + */ + static async extractRecoveryCandidates(page: Page): Promise { + const candidates: string[] = [] + + // Priority 1: Direct selectors (Microsoft variants + French) + const directSelectors = [ + '[data-testid="recoveryEmailHint"]', + '#recoveryEmail', + '[id*="ProofEmail"]', + '[id*="EmailProof"]', + '[data-testid*="Email"]', + 'span:has(span.fui-Text)' + ] + + for (const sel of directSelectors) { + const el = await page.waitForSelector(sel, { timeout: 1000 }).catch(() => null) + if (el) { + const t = (await el.textContent() || '').trim() + if (t) candidates.push(t) + } + } + + // Priority 2: List items + const listItems = page.locator('[role="listitem"], li') + const count = await listItems.count().catch(() => 0) + for (let i = 0; i < Math.min(count, 12); i++) { + const t = (await listItems.nth(i).textContent().catch(() => ''))?.trim() || '' + if (t && /@/.test(t)) candidates.push(t) + } + + // Priority 3: XPath generic masked patterns + const xpath = page.locator('xpath=//*[contains(normalize-space(.), "@") and (contains(normalize-space(.), "*") or contains(normalize-space(.), "•"))]') + const xpCount = await xpath.count().catch(() => 0) + for (let i = 0; i < Math.min(xpCount, 12); i++) { + const t = (await xpath.nth(i).textContent().catch(() => ''))?.trim() || '' + if (t && t.length < 300) candidates.push(t) + } + + // Priority 4: Full HTML scan fallback + if (candidates.length === 0) { + try { + const html = await page.content() + const generic = /[A-Za-z0-9]{1,4}[*•]{2,}[A-Za-z0-9*•._-]*@[A-Za-z0-9.-]+/g + const frPhrase = /Nous\s+enverrons\s+un\s+code\s+à\s+([^<@]*[A-Za-z0-9]{1,4}[*•]{2,}[A-Za-z0-9*•._-]*@[A-Za-z0-9.-]+)[^.]{0,120}?Pour\s+vérifier/gi + + const found = new Set() + let m: RegExpExecArray | null + while ((m = generic.exec(html)) !== null) found.add(m[0]) + while ((m = frPhrase.exec(html)) !== null) { + const raw = m[1]?.replace(/<[^>]+>/g, '').trim() + if (raw) found.add(raw) + } + + candidates.push(...Array.from(found)) + } catch { + /* ignore HTML scan errors */ + } + } + + // Deduplicate + const seen = new Set() + return candidates + .map(s => s.replace(/\s+/g, ' ').trim()) + .filter(t => t && !seen.has(t) && seen.add(t)) + .filter(t => /@/.test(t) && /[*•]/.test(t)) // Must be masked email + } + + /** + * Check if page contains account locked indicator + */ + static async detectAccountLocked(page: Page): Promise { + return await page.waitForSelector('#serviceAbuseLandingTitle', { timeout: 1200 }) + .then(() => true) + .catch(() => false) + } + + /** + * Parse email into { prefix: first 2 chars, domain } + */ + static parseEmailReference(email: string): { prefix: string; domain: string } | null { + if (!email || !/@/.test(email)) return null + + const [local, domain] = email.split('@') + if (!local || !domain) return null + + return { + prefix: local.slice(0, 2).toLowerCase(), + domain: domain.toLowerCase() + } + } + + /** + * Get documentation URL for security incident + */ + static getDocsUrl(anchor?: string): string { + const base = process.env.DOCS_BASE?.trim() || 'https://github.com/Obsidian-wtf/Microsoft-Rewards-Bot/blob/main/docs/security.md' + const map: Record = { + 'recovery-email-mismatch': '#recovery-email-mismatch', + 'we-cant-sign-you-in': '#we-cant-sign-you-in-blocked' + } + return anchor && map[anchor] ? `${base}${map[anchor]}` : base + } +} diff --git a/tests/loginStateDetector.test.ts b/tests/loginStateDetector.test.ts new file mode 100644 index 0000000..739d6cb --- /dev/null +++ b/tests/loginStateDetector.test.ts @@ -0,0 +1,239 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { LoginState, LoginStateDetector } from '../src/util/LoginStateDetector' + +/** + * Tests for LoginStateDetector - login flow state machine + */ + +test('LoginState enum contains expected states', () => { + assert.ok(LoginState.EmailPage, 'Should have EmailPage state') + assert.ok(LoginState.PasswordPage, 'Should have PasswordPage state') + assert.ok(LoginState.TwoFactorRequired, 'Should have TwoFactorRequired state') + assert.ok(LoginState.LoggedIn, 'Should have LoggedIn state') + assert.ok(LoginState.Blocked, 'Should have Blocked state') +}) + +test('detectState returns LoginStateDetection structure', async () => { + // Mock page object + const mockPage = { + url: () => 'https://rewards.bing.com/', + locator: (selector: string) => ({ + first: () => ({ + isVisible: () => Promise.resolve(true), + textContent: () => Promise.resolve('Test') + }) + }), + evaluate: () => Promise.resolve(150) + } + + const detection = await LoginStateDetector.detectState(mockPage as any) + + assert.ok(detection, 'Should return detection object') + assert.ok(typeof detection.state === 'string', 'Should have state property') + assert.ok(['high', 'medium', 'low'].includes(detection.confidence), 'Should have valid confidence') + assert.ok(typeof detection.url === 'string', 'Should have url property') + assert.ok(Array.isArray(detection.indicators), 'Should have indicators array') +}) + +test('detectState identifies LoggedIn state on rewards domain', async () => { + const mockPage = { + url: () => 'https://rewards.bing.com/dashboard', + locator: (selector: string) => { + if (selector.includes('RewardsPortal') || selector.includes('dashboard')) { + return { + first: () => ({ + isVisible: () => Promise.resolve(true) + }) + } + } + return { + first: () => ({ + isVisible: () => Promise.resolve(false) + }) + } + }, + evaluate: () => Promise.resolve(200) + } + + const detection = await LoginStateDetector.detectState(mockPage as any) + + assert.equal(detection.state, LoginState.LoggedIn, 'Should detect LoggedIn state') + assert.equal(detection.confidence, 'high', 'Should have high confidence') + assert.ok(detection.indicators.length > 0, 'Should have indicators') +}) + +test('detectState identifies EmailPage state on login.live.com', async () => { + const mockPage = { + url: () => 'https://login.live.com/login.srf', + locator: (selector: string) => { + if (selector.includes('email') || selector.includes('loginfmt')) { + return { + first: () => ({ + isVisible: () => Promise.resolve(true) + }) + } + } + return { + first: () => ({ + isVisible: () => Promise.resolve(false), + textContent: () => Promise.resolve(null) + }) + } + }, + evaluate: () => Promise.resolve(100) + } + + const detection = await LoginStateDetector.detectState(mockPage as any) + + assert.equal(detection.state, LoginState.EmailPage, 'Should detect EmailPage state') + assert.equal(detection.confidence, 'high', 'Should have high confidence') +}) + +test('detectState identifies PasswordPage state', async () => { + const mockPage = { + url: () => 'https://login.live.com/ppsecure/post.srf', + locator: (selector: string) => { + if (selector.includes('password') || selector.includes('passwd')) { + return { + first: () => ({ + isVisible: () => Promise.resolve(true) + }) + } + } + return { + first: () => ({ + isVisible: () => Promise.resolve(false), + textContent: () => Promise.resolve(null) + }) + } + }, + evaluate: () => Promise.resolve(100) + } + + const detection = await LoginStateDetector.detectState(mockPage as any) + + assert.equal(detection.state, LoginState.PasswordPage, 'Should detect PasswordPage state') + assert.equal(detection.confidence, 'high', 'Should have high confidence') +}) + +test('detectState identifies TwoFactorRequired state', async () => { + const mockPage = { + url: () => 'https://login.live.com/proofs.srf', + locator: (selector: string) => { + if (selector.includes('otc') || selector.includes('one-time-code')) { + return { + first: () => ({ + isVisible: () => Promise.resolve(true) + }) + } + } + return { + first: () => ({ + isVisible: () => Promise.resolve(false), + textContent: () => Promise.resolve(null) + }) + } + }, + evaluate: () => Promise.resolve(100) + } + + const detection = await LoginStateDetector.detectState(mockPage as any) + + assert.equal(detection.state, LoginState.TwoFactorRequired, 'Should detect TwoFactorRequired state') + assert.equal(detection.confidence, 'high', 'Should have high confidence') +}) + +test('detectState identifies PasskeyPrompt state', async () => { + const mockPage = { + url: () => 'https://login.live.com/login.srf', + locator: (selector: string) => { + if (selector.includes('[data-testid="title"]')) { + return { + first: () => ({ + isVisible: () => Promise.resolve(false), + textContent: () => Promise.resolve('Sign in faster with passkey') + }) + } + } + return { + first: () => ({ + isVisible: () => Promise.resolve(false), + textContent: () => Promise.resolve(null) + }) + } + }, + evaluate: () => Promise.resolve(100) + } + + const detection = await LoginStateDetector.detectState(mockPage as any) + + assert.equal(detection.state, LoginState.PasskeyPrompt, 'Should detect PasskeyPrompt state') + assert.equal(detection.confidence, 'high', 'Should have high confidence') +}) + +test('detectState identifies Blocked state', async () => { + const mockPage = { + url: () => 'https://login.live.com/err.srf', + locator: (selector: string) => { + if (selector.includes('[data-testid="title"]') || selector.includes('h1')) { + return { + first: () => ({ + isVisible: () => Promise.resolve(false), + textContent: () => Promise.resolve("We can't sign you in") + }) + } + } + return { + first: () => ({ + isVisible: () => Promise.resolve(false), + textContent: () => Promise.resolve(null) + }) + } + }, + evaluate: () => Promise.resolve(100) + } + + const detection = await LoginStateDetector.detectState(mockPage as any) + + assert.equal(detection.state, LoginState.Blocked, 'Should detect Blocked state') + assert.equal(detection.confidence, 'high', 'Should have high confidence') +}) + +test('detectState returns Unknown for ambiguous pages', async () => { + const mockPage = { + url: () => 'https://login.live.com/unknown.srf', + locator: () => ({ + first: () => ({ + isVisible: () => Promise.resolve(false), + textContent: () => Promise.resolve(null) + }) + }), + evaluate: () => Promise.resolve(50) + } + + const detection = await LoginStateDetector.detectState(mockPage as any) + + assert.equal(detection.state, LoginState.Unknown, 'Should return Unknown for ambiguous pages') + assert.equal(detection.confidence, 'low', 'Should have low confidence') +}) + +test('detectState handles errors gracefully', async () => { + const mockPage = { + url: () => { throw new Error('Network error') }, + locator: () => ({ + first: () => ({ + isVisible: () => Promise.reject(new Error('Element not found')) + }) + }), + evaluate: () => Promise.reject(new Error('Evaluation failed')) + } + + try { + await LoginStateDetector.detectState(mockPage as any) + assert.fail('Should throw error') + } catch (e) { + assert.ok(e instanceof Error, 'Should throw Error instance') + } +})