From f26722a7f524cd515d402312989047aaca176339 Mon Sep 17 00:00:00 2001 From: Aung Nanda Oo Date: Tue, 15 Jul 2025 12:47:28 -0700 Subject: [PATCH] feat(playwright): add robust login helper with captcha handler and tests --- .../src/internals/utils/playwright-utils.ts | 412 +++++++++++++- test/core/playwright_utils.test.ts | 516 +++++++++++++++++- yarn.lock | 100 ++-- 3 files changed, 959 insertions(+), 69 deletions(-) diff --git a/packages/playwright-crawler/src/internals/utils/playwright-utils.ts b/packages/playwright-crawler/src/internals/utils/playwright-utils.ts index ac7e1d813f41..eebc2c3ab002 100644 --- a/packages/playwright-crawler/src/internals/utils/playwright-utils.ts +++ b/packages/playwright-crawler/src/internals/utils/playwright-utils.ts @@ -35,7 +35,7 @@ import { type CheerioRoot, type Dictionary, expandShadowRoots, sleep } from '@cr import * as cheerio from 'cheerio'; import { getInjectableScript as getCookieClosingScript } from 'idcac-playwright'; import ow from 'ow'; -import type { Page, Response, Route } from 'playwright'; +import type { Locator, Page, Response, Route } from 'playwright'; import { LruCache } from '@apify/datastructures'; import log_ from '@apify/log'; @@ -662,6 +662,372 @@ export async function closeCookieModals(page: Page): Promise { await page.evaluate(getCookieClosingScript()); } +export interface LoginOptions { + /** + * The username/email for login. + */ + username: string; + + /** + * The password for login. + */ + password: string; + + /** + * Optional custom locators getters for login form elements. + * If not provided, default locators will be used. + */ + locators?: { + /** + * Returns locators for the username/email input field. + */ + getUsernameInput?: (page: Page) => Locator; + + /** + * Returns locators for the password input field. + */ + getPasswordInput?: (page: Page) => Locator; + + /** + * Returns locators for the submit button. + */ + getSubmitButton?: (page: Page) => Locator; + + /** + * Returns locators for the "Next" button in two-step login forms. + */ + getNextButton?: (page: Page) => Locator; + }; + + /** + * Optional custom function to detect if login succeeded. + * If not provided, a default heuristic will be used. + * @param page The Playwright page + * @returns Promise that resolves to true if login succeeded, false otherwise + */ + detectLoginSuccess?: (page: Page) => Promise; + + /** + * Timeout for login operations in milliseconds. + * @default 10_000 + */ + timeoutMs?: number; + + /** + * Optional custom function to handle captchas during login. + * If not provided, captchas will be ignored. + * @param page The Playwright page + * @returns Promise that resolves when captcha is handled + */ + handleCaptcha?: (page: Page) => Promise; + + /** + * Optional timeout (ms) for the captcha handler. + * If the handler does not resolve in this time, login will fail. + * @default 30000 + */ + captchaTimeoutMs?: number; +} + +/** + * Attempts to log in to a website using the provided credentials. + * + * This function can handle both single-step and two-step login forms. + * It will automatically detect the type of login form and attempt to fill it out. + * + * @param page The Playwright page + * @param options Login options including username, password, and optional configurations + * @returns Promise that resolves when login is complete + * @throws Error if login fails or login form is not detected + */ +export async function login(page: Page, options: LoginOptions): Promise { + ow(page, ow.object.validate(validators.browserPage)); + ow( + options, + ow.object.exactShape({ + username: ow.string.nonEmpty, + password: ow.string.nonEmpty, + detectLoginForm: ow.optional.function, + detectLoginSuccess: ow.optional.function, + timeoutMs: ow.optional.number.positive, + locators: ow.optional.object, + handleCaptcha: ow.optional.function, + captchaTimeoutMs: ow.optional.number.positive, + }), + ); + + const { + username, + password, + timeoutMs = 10_000, + detectLoginSuccess = getDefaultDetectLoginSuccess({ timeoutMs, page }), + locators = {}, + handleCaptcha, + captchaTimeoutMs = 30000, + } = options; + + // Merge custom locators with defaults + const finalLocators = { + usernameInput: + locators.getUsernameInput?.(page) ?? + [ + page.locator('input[name="username"]'), + page.locator('input[name="email"]'), + page.locator('input[name="user"]'), + page.locator('input[name="login"]'), + page.locator('input[type="email"]'), + page.locator('input[id*="username"]'), + page.locator('input[id*="email"]'), + page.locator('input[id*="login"]'), + page.locator('input[placeholder*="username" i]'), + page.locator('input[placeholder*="email" i]'), + page.locator('input[placeholder*="user" i]'), + page.locator('input[aria-label*="username" i]'), + page.locator('input[aria-label*="email" i]'), + page.locator('input[class*="username"]'), + page.locator('input[class*="email"]'), + page.locator('input[class*="user"]'), + ].reduce((acc, locator) => acc.or(locator), page.locator('input[class*="login"]')), + passwordInput: + locators.getPasswordInput?.(page) ?? + [ + page.locator('input[name="password"]'), + page.locator('input[type="password"]'), + page.locator('input[id*="password"]'), + page.locator('input[placeholder*="password" i]'), + page.locator('input[aria-label*="password" i]'), + ].reduce((acc, locator) => acc.or(locator), page.locator('input[class*="password"]')), + submitButton: + locators.getSubmitButton?.(page) ?? + [ + page.locator('button[type="submit"]'), + page.locator('input[type="submit"]'), + page.locator('button[name="submit"]'), + page.locator('button[id*="submit"]'), + page.locator('button[id*="login"]'), + page.locator('button[id*="signin"]'), + page.locator('button[class*="submit"]'), + page.locator('button[class*="login"]'), + page.locator('button[class*="signin"]'), + page.locator('button:has-text("Log in")'), + page.locator('button:has-text("Sign in")'), + page.locator('button:has-text("Login")'), + page.locator('button:has-text("Submit")'), + page.locator('a[role="button"]:has-text("Log in")'), + page.locator('a[role="button"]:has-text("Sign in")'), + ].reduce((acc, locator) => acc.or(locator), page.locator('a[role="button"]:has-text("Login")')), + nextButton: + locators.getNextButton?.(page) ?? + [ + page.locator('button:has-text("Next")'), + page.locator('button:has-text("Continue")'), + page.locator('button[id*="next"]'), + page.locator('button[class*="next"]'), + page.locator('button[class*="continue"]'), + page.locator('input[type="submit"][value*="Next"]'), + ].reduce((acc, locator) => acc.or(locator), page.locator('input[type="submit"][value*="Continue"]')), + }; + + // Check if username input is present + await finalLocators.usernameInput.first().waitFor({ timeout: timeoutMs }); + const hasUsernameInput = await finalLocators.usernameInput + .first() + .isVisible() + .catch(() => false); + if (!hasUsernameInput) { + // No username input detected, assume already logged in or not needed + return; + } + + // Check if password field is immediately visible (single-step login) + const passwordInputVisible = await finalLocators.passwordInput + .first() + .isVisible() + .catch(() => false); + + if (passwordInputVisible) { + await performSingleStepLogin({ page, username, password, locators: finalLocators, timeoutMs, handleCaptcha, captchaTimeoutMs }); + } else { + await performTwoStepLogin({ page, username, password, locators: finalLocators, timeoutMs, handleCaptcha, captchaTimeoutMs }); + } + + // Check if login was successful + const loginSuccessful = await detectLoginSuccess(page); + if (!loginSuccessful) { + throw new Error('Login failed - success detection heuristic indicates login was not successful'); + } +} + +/** + * Default heuristic to detect if login was successful. + */ +function getDefaultDetectLoginSuccess({ timeoutMs, page }: { timeoutMs: number; page: Page }): () => Promise { + return async () => { + try { + const indicators = { + failure: [ + page.getByText('Invalid credentials'), + page.getByText('Invalid username or password'), + page.getByText('Login failed'), + page.getByText('Authentication failed'), + page.getByText('Incorrect username or password'), + page.getByText('Wrong username or password'), + page.locator('[class*="error"]'), + page.locator('[class*="alert"]'), + page.locator('[role="alert"]'), + page.locator('.login-error'), + page.locator('.error-message'), + page.locator('#error'), + ].reduce((acc, locator) => acc.or(locator), page.locator('.failure')), + success: [ + page.getByText('Welcome'), + page.getByText('Dashboard'), + page.getByText('Profile'), + page.getByText('Account'), + page.getByText('Logout'), + page.getByText('Sign out'), + page.locator('a[href*="logout"]'), + page.locator('a[href*="signout"]'), + page.locator('button:has-text("Logout")'), + page.locator('button:has-text("Sign out")'), + page.locator('[class*="dashboard"]'), + page.locator('[class*="profile"]'), + page.locator('[class*="account"]'), + page.locator('[data-testid*="user-menu"]'), + ].reduce((acc, locator) => acc.or(locator), page.locator('[data-testid*="profile"]')), + }; + + // Check visibility of any of the indicators + const indicatorPromises = Object.entries(indicators).map(async ([key, locator]) => { + const locatorFirst = locator.first(); + await locatorFirst.waitFor({ timeout: timeoutMs }); + return { type: key as keyof typeof indicators, visible: await locatorFirst.isVisible() }; + }); + + try { + // Wait for first indicator to be resolved + const visibleIndicator = await Promise.any(indicatorPromises); + if (visibleIndicator.visible) { + if (visibleIndicator.type === 'failure') { + return false; + } + return true; + } + } catch { + // continue to next indicator + } + + // Check if we're no longer on a login page + const currentUrl = page.url(); + const isLoginPage = /login|signin|auth|sign-in/i.test(currentUrl); + + // If we're not on a login page and no failure indicators, assume success + return !isLoginPage; + } catch (error) { + // If we can't determine success, assume failure for safety + return false; + } + }; +} + +// Helper to wait for and fill a field, with error handling +async function waitAndFill(locator: Locator, value: string, timeoutMs: number, fieldName: string) { + try { + await locator.waitFor({ timeout: timeoutMs }); + await locator.fill(value); + } catch (err) { + throw new Error(`Failed to fill ${fieldName} field: ${(err as Error).message}`); + } +} + +// Helper to call handleCaptcha if provided, with error handling and optional timeout +async function maybeHandleCaptcha(page: Page, handleCaptcha?: (page: Page) => Promise, captchaTimeoutMs = 30000) { + if (!handleCaptcha) return; + try { + await Promise.race([ + handleCaptcha(page), + new Promise((_, reject) => setTimeout(() => reject(new Error('Captcha handler timed out')), captchaTimeoutMs)), + ]); + } catch (err) { + throw new Error(`Captcha handler failed: ${(err as Error).message}`); + } +} + +async function performSingleStepLogin({ + page, + username, + password, + locators, + timeoutMs = 30_000, + handleCaptcha, + captchaTimeoutMs = 30000, +}: { + page: Page; + username: string; + password: string; + locators: { usernameInput: Locator; passwordInput: Locator; submitButton: Locator; nextButton: Locator }; + timeoutMs: number; + handleCaptcha?: (page: Page) => Promise; + captchaTimeoutMs?: number; +}): Promise { + const usernameField = locators.usernameInput.first(); + log.debug('Filling username field'); + await waitAndFill(usernameField, username, timeoutMs, 'username'); + const passwordField = locators.passwordInput.first(); + log.debug('Filling password field'); + await waitAndFill(passwordField, password, timeoutMs, 'password'); + await maybeHandleCaptcha(page, handleCaptcha, captchaTimeoutMs); + try { + const submitButton = locators.submitButton.first(); + await submitButton.waitFor({ timeout: timeoutMs }); + await submitButton.click(); + } catch (err) { + throw new Error(`Failed to click submit button: ${(err as Error).message}`); + } + await page.waitForLoadState('networkidle', { timeout: timeoutMs }).catch(() => {}); +} + +async function performTwoStepLogin({ + page, + username, + password, + locators, + timeoutMs = 30_000, + handleCaptcha, + captchaTimeoutMs = 30000, +}: { + page: Page; + username: string; + password: string; + locators: { usernameInput: Locator; passwordInput: Locator; submitButton: Locator; nextButton: Locator }; + timeoutMs: number; + handleCaptcha?: (page: Page) => Promise; + captchaTimeoutMs?: number; +}): Promise { + const usernameField = locators.usernameInput.first(); + log.debug('Filling username field'); + await waitAndFill(usernameField, username, timeoutMs, 'username'); + try { + const nextButton = locators.nextButton.first(); + await nextButton.waitFor({ timeout: timeoutMs }); + await nextButton.click(); + } catch (err) { + throw new Error(`Failed to click next button: ${(err as Error).message}`); + } + const passwordField = locators.passwordInput.first(); + log.debug('Filling password field'); + await waitAndFill(passwordField, password, timeoutMs, 'password'); + await maybeHandleCaptcha(page, handleCaptcha, captchaTimeoutMs); + try { + const submitButton = locators.submitButton.first(); + await submitButton.waitFor({ timeout: timeoutMs }); + await submitButton.click(); + } catch (err) { + throw new Error(`Failed to click submit button: ${(err as Error).message}`); + } + await page.waitForLoadState('networkidle', { timeout: timeoutMs }).catch(() => {}); +} + interface HandleCloudflareChallengeOptions { /** Logging defaults to the `debug` level, use this flag to log to `info` level instead. */ verbose?: boolean; @@ -1017,6 +1383,48 @@ export interface PlaywrightContextUtils { * @param [options] */ handleCloudflareChallenge(options?: HandleCloudflareChallengeOptions): Promise; + + /** + * Attempts to log in to a website using the provided credentials. + * + * This function can handle both single-step and two-step login forms. + * It will automatically detect the type of login form and attempt to fill it out. + * If no login form is detected, the function will resolve without doing anything. + * + * **Example usage** + * ```ts + * async requestHandler({ login }) { + * await login({ + * username: 'your-username', + * password: 'your-password', + * }); + * } + * ``` + * + * **Custom detection and handling** + * ```ts + * async requestHandler({ login }) { + * await login({ + * username: 'your-username', + * password: 'your-password', + * locators: { + * getUsernameInput: (page) => page.locator('#username'), + * getPasswordInput: (page) => page.locator('#password'), + * getSubmitButton: (page) => page.locator('#submit'), + * }, + * detectLoginSuccess: async (page) => { + * // Custom logic to detect successful login + * return page.locator('.user-menu').isVisible(); + * }, + * }); + * } + * ``` + * + * @param options Login options including username, password, and optional configurations + * @returns Promise that resolves when login is complete + * @throws Error if login fails + */ + login(options: LoginOptions): Promise; } export function registerUtilsToContext( @@ -1063,6 +1471,7 @@ export function registerUtilsToContext( context.handleCloudflareChallenge = async (options?: HandleCloudflareChallengeOptions) => { return handleCloudflareChallenge(context.page, context.request.url, context.session, options); }; + context.login = async (options: LoginOptions) => login(context.page, options); } export { enqueueLinksByClickingElements }; @@ -1081,4 +1490,5 @@ export const playwrightUtils = { closeCookieModals, RenderingTypePredictor, handleCloudflareChallenge, + login, }; diff --git a/test/core/playwright_utils.test.ts b/test/core/playwright_utils.test.ts index dc038b5c2ff1..67e0e0b2a669 100644 --- a/test/core/playwright_utils.test.ts +++ b/test/core/playwright_utils.test.ts @@ -2,7 +2,7 @@ import type { Server } from 'node:http'; import path from 'node:path'; import { KeyValueStore, launchPlaywright, playwrightUtils, Request } from '@crawlee/playwright'; -import type { Browser, Page } from 'playwright'; +import type { Browser, Locator, Page } from 'playwright'; import { chromium } from 'playwright'; import { runExampleComServer } from 'test/shared/_helper'; import { MemoryStorageEmulator } from 'test/shared/MemoryStorageEmulator'; @@ -325,7 +325,8 @@ describe('playwrightUtils', () => { expect(before).toBe(false); await playwrightUtils.infiniteScroll(page, { - waitForSecs: Infinity, + // waitForSecs: Infinity, + waitForSecs: Number.POSITIVE_INFINITY, maxScrollHeight: 1000, stopScrollCallback: async () => true, }); @@ -342,7 +343,8 @@ describe('playwrightUtils', () => { expect(before).toBe(false); await playwrightUtils.infiniteScroll(page, { - waitForSecs: Infinity, + // waitForSecs: Infinity, + waitForSecs: Number.POSITIVE_INFINITY, stopScrollCallback: async () => true, }); @@ -386,4 +388,512 @@ describe('playwrightUtils', () => { await browser.close(); } }); + + + describe('login()', () => { + const getLocatorMock = () => { + const locatorMock = { + isVisible: vitest.fn().mockResolvedValue(true), + waitFor: vitest.fn(), + fill: vitest.fn(), + click: vitest.fn(), + first: vitest.fn(), + or: vitest.fn(), + }; + locatorMock.first.mockReturnValue(locatorMock); + locatorMock.or.mockReturnValue(locatorMock); + return locatorMock; + }; + type LocatorMock = ReturnType; + + let browser: Browser = null as any; + beforeAll(async () => { + browser = await launchPlaywright(launchContext); + }); + afterAll(async () => { + await browser.close(); + }); + + let page: Page; + let newLocatorMock: LocatorMock; + let usernameInputMock: LocatorMock; + let passwordInputMock: LocatorMock; + let submitButtonMock: LocatorMock; + let nextButtonMock: LocatorMock; + beforeEach(async () => { + page = await browser.newPage(); + newLocatorMock = getLocatorMock(); + usernameInputMock = getLocatorMock(); + passwordInputMock = getLocatorMock(); + submitButtonMock = getLocatorMock(); + nextButtonMock = getLocatorMock(); + vitest.spyOn(page, 'locator').mockReturnValue(newLocatorMock as unknown as Locator); + vitest.spyOn(page, 'getByText').mockResolvedValue(newLocatorMock as unknown as Locator); + }); + afterEach(async () => { + await page.close(); + }); + + // Helper to wait for and fill a field, with error handling + async function waitAndFill(locator: Locator, value: string, timeoutMs: number, fieldName: string) { + try { + await locator.waitFor({ timeout: timeoutMs }); + await locator.fill(value); + } catch (err) { + throw new Error(`Failed to fill ${fieldName} field: ${(err as Error).message}`); + } + } + + // Helper to call handleCaptcha if provided, with error handling and optional timeout + async function maybeHandleCaptcha(page: Page, handleCaptcha?: (page: Page) => Promise, captchaTimeoutMs = 30000) { + if (!handleCaptcha) return; + try { + await Promise.race([ + handleCaptcha(page), + new Promise((_, reject) => setTimeout(() => reject(new Error('Captcha handler timed out')), captchaTimeoutMs)), + ]); + } catch (err) { + throw new Error(`Captcha handler failed: ${(err as Error).message}`); + } + } + + async function performSingleStepLogin({ + page, + username, + password, + locators, + timeoutMs = 30_000, + handleCaptcha, + captchaTimeoutMs = 30000, + }: { + page: Page; + username: string; + password: string; + locators: { usernameInput: Locator; passwordInput: Locator; submitButton: Locator; nextButton: Locator }; + timeoutMs: number; + handleCaptcha?: (page: Page) => Promise; + captchaTimeoutMs?: number; + }): Promise { + const usernameField = locators.usernameInput.first(); + await waitAndFill(usernameField, username, timeoutMs, 'username'); + const passwordField = locators.passwordInput.first(); + await waitAndFill(passwordField, password, timeoutMs, 'password'); + await maybeHandleCaptcha(page, handleCaptcha, captchaTimeoutMs); + try { + const submitButton = locators.submitButton.first(); + await submitButton.waitFor({ timeout: timeoutMs }); + await submitButton.click(); + } catch (err) { + throw new Error(`Failed to click submit button: ${(err as Error).message}`); + } + await page.waitForLoadState('networkidle', { timeout: timeoutMs }).catch(() => {}); + } + + async function performTwoStepLogin({ + page, + username, + password, + locators, + timeoutMs = 30_000, + handleCaptcha, + captchaTimeoutMs = 30000, + }: { + page: Page; + username: string; + password: string; + locators: { usernameInput: Locator; passwordInput: Locator; submitButton: Locator; nextButton: Locator }; + timeoutMs: number; + handleCaptcha?: (page: Page) => Promise; + captchaTimeoutMs?: number; + }): Promise { + const usernameField = locators.usernameInput.first(); + await waitAndFill(usernameField, username, timeoutMs, 'username'); + try { + const nextButton = locators.nextButton.first(); + await nextButton.waitFor({ timeout: timeoutMs }); + await nextButton.click(); + } catch (err) { + throw new Error(`Failed to click next button: ${(err as Error).message}`); + } + const passwordField = locators.passwordInput.first(); + await waitAndFill(passwordField, password, timeoutMs, 'password'); + await maybeHandleCaptcha(page, handleCaptcha, captchaTimeoutMs); + try { + const submitButton = locators.submitButton.first(); + await submitButton.waitFor({ timeout: timeoutMs }); + await submitButton.click(); + } catch (err) { + throw new Error(`Failed to click submit button: ${(err as Error).message}`); + } + await page.waitForLoadState('networkidle', { timeout: timeoutMs }).catch(() => {}); + } + + test('single-step login success', async () => { + const pageWaitForLoadStateSpy = vitest.spyOn(page, 'waitForLoadState').mockResolvedValue(); + + usernameInputMock.isVisible.mockResolvedValue(true); + // Password is visible in single-step flow + passwordInputMock.isVisible.mockResolvedValue(true); + + const detectLoginSuccessMock = vitest.fn().mockResolvedValue(true); + + await playwrightUtils.login(page, { + username: 'testuser', + password: 'testpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + }, + detectLoginSuccess: detectLoginSuccessMock, + }); + + // Verify interactions + expect(usernameInputMock.fill).toHaveBeenCalledWith('testuser'); + expect(passwordInputMock.fill).toHaveBeenCalledWith('testpass'); + expect(submitButtonMock.click).toHaveBeenCalledTimes(1); + expect(pageWaitForLoadStateSpy).toHaveBeenCalledWith('networkidle', { timeout: 10_000 }); + expect(detectLoginSuccessMock).toHaveBeenCalledWith(page); + }); + + test('single-step login failure', async () => { + usernameInputMock.isVisible.mockResolvedValue(true); + // Password is visible in single-step flow + passwordInputMock.isVisible.mockResolvedValue(true); + + await expect( + playwrightUtils.login(page, { + username: 'testuser', + password: 'wrongpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + }, + detectLoginSuccess: async () => false, + }), + ).rejects.toThrow('Login failed - success detection heuristic indicates login was not successful'); + }); + + test('two-step login success', async () => { + usernameInputMock.isVisible.mockResolvedValue(true); + // Password is not visible in two-step flow + passwordInputMock.isVisible.mockResolvedValue(false); + + await playwrightUtils.login(page, { + username: 'testuser', + password: 'testpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + getNextButton: () => nextButtonMock as unknown as Locator, + }, + detectLoginSuccess: async () => true, + }); + + // Verify interactions + expect(usernameInputMock.fill).toHaveBeenCalledWith('testuser'); + expect(passwordInputMock.fill).toHaveBeenCalledWith('testpass'); + expect(nextButtonMock.click).toHaveBeenCalledOnce(); + expect(submitButtonMock.click).toHaveBeenCalledOnce(); + }); + + test('two-step login failure', async () => { + usernameInputMock.isVisible.mockResolvedValue(true); + // Password is not visible in two-step flow + passwordInputMock.isVisible.mockResolvedValue(false); + + await expect( + playwrightUtils.login(page, { + username: 'testuser', + password: 'wrongpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + getNextButton: () => nextButtonMock as unknown as Locator, + }, + detectLoginSuccess: async () => false, + }), + ).rejects.toThrow('Login failed - success detection heuristic indicates login was not successful'); + }); + + test('no username input detected', async () => { + // Mock no username input detected + usernameInputMock.isVisible.mockResolvedValue(false); + + // Should resolve without error when no username input is detected + await expect( + playwrightUtils.login(page, { + username: 'testuser', + password: 'testpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + }, + }), + ).resolves.toBeUndefined(); + + // Should not attempt to fill or click anything + expect(usernameInputMock.fill).not.toHaveBeenCalled(); + expect(passwordInputMock.fill).not.toHaveBeenCalled(); + expect(submitButtonMock.click).not.toHaveBeenCalled(); + }); + + test('no password input detected', async () => { + // Mock no username input detected + passwordInputMock.fill.mockRejectedValue(new Error('Failed to fill password')); + + // Should resolve without error when no username input is detected + await expect( + playwrightUtils.login(page, { + username: 'testuser', + password: 'testpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + }, + }), + ).rejects.toThrow('Failed to fill password'); + }); + + test('default locators usage', async () => { + await playwrightUtils.login(page, { + username: 'testuser', + password: 'testpass', + detectLoginSuccess: async () => true, + }); + + // Verify custom selectors were used + expect(newLocatorMock.fill).toHaveBeenCalledWith('testuser'); + expect(newLocatorMock.fill).toHaveBeenCalledWith('testpass'); + expect(newLocatorMock.click).toHaveBeenCalled(); + expect(newLocatorMock.or).toHaveBeenCalledTimes(42); + }); + + test('default detectLoginSuccess usage - failure indicator', async () => { + // Mock failed flow + newLocatorMock.isVisible.mockImplementation(async () => { + const callCount = newLocatorMock.isVisible.mock.calls.length; + if (callCount === 1) return true; // failure indicator visible + + await new Promise((resolve) => setTimeout(resolve, 100)); + return false; // no success indicator + }); + + await expect( + playwrightUtils.login(page, { + username: 'testuser', + password: 'wrongpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + }, + }), + ).rejects.toThrow('Login failed - success detection heuristic indicates login was not successful'); + expect(newLocatorMock.or).toHaveBeenCalledTimes(32); + }); + + test('default detectLoginSuccess usage - success indicator', async () => { + // Mock successful flow + newLocatorMock.isVisible.mockImplementation(async () => { + const callCount = newLocatorMock.isVisible.mock.calls.length; + // no failure indicator + if (callCount === 1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + return false; + } + // success indicator visible + return true; + }); + + await expect( + playwrightUtils.login(page, { + username: 'testuser', + password: 'wrongpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + }, + }), + ).resolves.toBeUndefined(); + expect(newLocatorMock.or).toHaveBeenCalledTimes(32); + }); + + test('default detectLoginSuccess usage - path changed', async () => { + // Neither failure nor success indicator visible + newLocatorMock.isVisible.mockResolvedValue(false); + vitest.spyOn(page, 'url').mockResolvedValue('https://example.com/dashboard'); + + await expect( + playwrightUtils.login(page, { + username: 'testuser', + password: 'wrongpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + }, + }), + ).resolves.toBeUndefined(); + }); + + test('default detectLoginSuccess usage - path is still login', async () => { + // Neither failure nor success indicator visible + newLocatorMock.isVisible.mockResolvedValue(false); + vitest.spyOn(page, 'url').mockReturnValue('https://example.com/login'); + + await expect( + playwrightUtils.login(page, { + username: 'testuser', + password: 'wrongpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + }, + }), + ).rejects.toThrow('Login failed - success detection heuristic indicates login was not successful'); + }); + + // TODO: remove this before merging, it's just for development, testing against 3rd party live website is not a good idea + test('live website login - SauceDemo', async () => { + vitest.spyOn(page, 'locator').mockRestore(); + vitest.spyOn(page, 'getByText').mockRestore(); + + try { + // Navigate to SauceDemo - a popular demo site for testing automation + await page.goto('https://www.saucedemo.com/'); + + // Use the login function with known test credentials + await playwrightUtils.login(page, { + username: 'standard_user', + password: 'secret_sauce', + }); + + // Verify we successfully logged in by checking for the inventory page + const currentUrl = page.url(); + expect(currentUrl).toContain('/inventory.html'); + + // Also verify the presence of the logout button which indicates successful login + // First open the menu to make the logout button visible + const menuButton = page.locator('[id="react-burger-menu-btn"]'); + await menuButton.click(); + + const logoutButton = page.locator('[data-test="logout-sidebar-link"]'); + await logoutButton.waitFor({ state: 'visible', timeout: 5000 }); + + // Verify we can see the products container + const productsContainer = page.locator('[data-test="inventory-container"]'); + await productsContainer.waitFor({ state: 'visible', timeout: 5000 }); + } catch (error: unknown) { + // If the test fails, it might be due to network issues or site changes + // Log the error but don't fail the entire test suite + console.warn('SauceDemo login test failed - this might be due to network issues:', error); + // Re-throw only if it's not a network-related error + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('net::') || errorMessage.includes('timeout')) { + console.warn('Skipping SauceDemo test due to network issues'); + return; + } + throw error; + } + }); + + // TODO: remove this before merging, it's just for development, testing against 3rd party live website is not a good idea + test('live website login failure - SauceDemo', async () => { + vitest.spyOn(page, 'locator').mockRestore(); + vitest.spyOn(page, 'getByText').mockRestore(); + + try { + // Navigate to SauceDemo - a popular demo site for testing automation + await page.goto('https://www.saucedemo.com/'); + + await expect(() => + // Use the login function with bad credentials + playwrightUtils.login(page, { + username: 'standard_user', + password: 'bad_password', + }), + ).rejects.toThrowError('Login failed - success detection heuristic indicates login was not successful'); + } catch (error: unknown) { + // If the test fails, it might be due to network issues or site changes + // Log the error but don't fail the entire test suite + console.warn('SauceDemo login test failed - this might be due to network issues:', error); + // Re-throw only if it's not a network-related error + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('net::') || errorMessage.includes('timeout')) { + console.warn('Skipping SauceDemo test due to network issues'); + return; + } + throw error; + } + }); + + test('login() calls handleCaptcha if provided (single-step)', async () => { + const handleCaptcha = vitest.fn().mockResolvedValue(undefined); + usernameInputMock.isVisible.mockResolvedValue(true); + passwordInputMock.isVisible.mockResolvedValue(true); + + await playwrightUtils.login(page, { + username: 'testuser', + password: 'testpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + }, + detectLoginSuccess: async () => true, + handleCaptcha, + }); + + expect(handleCaptcha).toHaveBeenCalledWith(page); + }); + + test('login() fails if handleCaptcha throws', async () => { + const handleCaptcha = vitest.fn().mockRejectedValue(new Error('Captcha failed to solve')); + usernameInputMock.isVisible.mockResolvedValue(true); + passwordInputMock.isVisible.mockResolvedValue(true); + + await expect( + playwrightUtils.login(page, { + username: 'testuser', + password: 'testpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + }, + detectLoginSuccess: async () => true, + handleCaptcha, + }), + ).rejects.toThrow('Captcha handler failed: Captcha failed to solve'); + }); + + test('login() fails if handleCaptcha times out', async () => { + const handleCaptcha = vitest.fn().mockImplementation(() => new Promise(() => {})); // never resolves + usernameInputMock.isVisible.mockResolvedValue(true); + passwordInputMock.isVisible.mockResolvedValue(true); + + await expect( + playwrightUtils.login(page, { + username: 'testuser', + password: 'testpass', + locators: { + getUsernameInput: () => usernameInputMock as unknown as Locator, + getPasswordInput: () => passwordInputMock as unknown as Locator, + getSubmitButton: () => submitButtonMock as unknown as Locator, + }, + detectLoginSuccess: async () => true, + handleCaptcha, + captchaTimeoutMs: 100, // very short timeout + }), + ).rejects.toThrow('Captcha handler failed: Captcha handler timed out'); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 2144cd58a498..e601bf4bdc10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30,13 +30,12 @@ __metadata: linkType: hard "@apify/eslint-config@npm:^1.0.0": - version: 1.0.1 - resolution: "@apify/eslint-config@npm:1.0.1" + version: 1.1.0 + resolution: "@apify/eslint-config@npm:1.1.0" dependencies: "@eslint/compat": "npm:^1.2.6" - "@jirimoravcik/eslint-plugin-import": "npm:2.32.0" eslint-config-airbnb-base: "npm:^15.0.0" - eslint-plugin-import: "npm:^2.31.0" + eslint-plugin-import: "npm:^2.32.0" eslint-plugin-simple-import-sort: "npm:^12.1.1" globals: "npm:^15.14.0" peerDependencies: @@ -48,18 +47,18 @@ __metadata: optional: true typescript-eslint: optional: true - checksum: 10c0/e64b814c3431a999e8dfa028c09b888fe0fb8299595b63ce5bd54a76778a7bdba9110064c0ecd6eb8f2bbcc186e3b306a058f81498f22a85c1313a80198f0e77 + checksum: 10c0/9c1461d859d02bbbb59a6004aa289054a7fca33e573d703ffb6fe62f021607ba298e1dba2ac8c1cc43362150be5444e0112efa98f768d8d06409c3f939671c0e languageName: node linkType: hard "@apify/input_secrets@npm:^1.1.40": - version: 1.2.0 - resolution: "@apify/input_secrets@npm:1.2.0" + version: 1.2.1 + resolution: "@apify/input_secrets@npm:1.2.1" dependencies: "@apify/log": "npm:^2.5.20" "@apify/utilities": "npm:^2.16.2" ow: "npm:^0.28.2" - checksum: 10c0/1b117940adc2b302ca18ecb503cc0e418f631a1b949ecf44413dbec2e02fe1ccf7b70b9efa3ec4f8ae3f2345c436d092986606638b879c69277220598953d8c0 + checksum: 10c0/0494940e807d33acc2779576a2fe4610bb359e535e8da31d3d06c5b2e6efda20a775711fcb51a1be0ed672116bee286bc00341d31135c9c27fcbe5b7f9f24a2d languageName: node linkType: hard @@ -1482,35 +1481,6 @@ __metadata: languageName: node linkType: hard -"@jirimoravcik/eslint-plugin-import@npm:2.32.0": - version: 2.32.0 - resolution: "@jirimoravcik/eslint-plugin-import@npm:2.32.0" - dependencies: - "@rtsao/scc": "npm:^1.1.0" - array-includes: "npm:^3.1.8" - array.prototype.findlastindex: "npm:^1.2.5" - array.prototype.flat: "npm:^1.3.3" - array.prototype.flatmap: "npm:^1.3.3" - debug: "npm:^3.2.7" - doctrine: "npm:^2.1.0" - eslint-import-resolver-node: "npm:^0.3.9" - eslint-module-utils: "npm:^2.12.0" - hasown: "npm:^2.0.2" - is-core-module: "npm:^2.16.1" - is-glob: "npm:^4.0.3" - minimatch: "npm:^3.1.2" - object.fromentries: "npm:^2.0.8" - object.groupby: "npm:^1.0.3" - object.values: "npm:^1.2.1" - semver: "npm:^6.3.1" - string.prototype.trimend: "npm:^1.0.9" - tsconfig-paths: "npm:^3.15.0" - peerDependencies: - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - checksum: 10c0/fbeb711869991914010fd122ab1555fe56b077c5a5db46e0ab7147897f4fe76b7f4f044884b04bcb17a7103fb0d7039ceff743987d2c8dd353ce1ba5391355a6 - languageName: node - linkType: hard - "@jridgewell/gen-mapping@npm:^0.3.5": version: 0.3.12 resolution: "@jridgewell/gen-mapping@npm:0.3.12" @@ -2759,20 +2729,20 @@ __metadata: linkType: hard "@types/node@npm:*": - version: 24.0.10 - resolution: "@types/node@npm:24.0.10" + version: 24.0.13 + resolution: "@types/node@npm:24.0.13" dependencies: undici-types: "npm:~7.8.0" - checksum: 10c0/11dbd869d3e12ee7b7818113588950e538783e45d227122174c763cb05977defbd1e01b44b5ccd4b8997e42c3df7f7b83c6ee05cfa43d924c8882886bc5f6582 + checksum: 10c0/e1f3d4ea8973b1f2f987814ac5343d05ad8b56bf7fa41755295dee0ce0f7b4e2de4f3daef5296429180228970cef0d70f2ad873c61dbafc6f5eeca9d023aba76 languageName: node linkType: hard "@types/node@npm:^22.5.1": - version: 22.16.0 - resolution: "@types/node@npm:22.16.0" + version: 22.16.3 + resolution: "@types/node@npm:22.16.3" dependencies: undici-types: "npm:~6.21.0" - checksum: 10c0/6219b521062f6c38d4d85ebd25807bd7f2bc703a5acba24e2c6716938d9d6cefd6fafd7b5156f61580eb58a0d82e8921751b778655675389631d813e5f261c03 + checksum: 10c0/ea6829d0691713e216c15a767f87e412fa0f45c7ed4d49419098d967467e20a5dcdf66fee024e314deab2779f7e5282e1839ee918e845be27f14179247ec947a languageName: node linkType: hard @@ -3556,7 +3526,7 @@ __metadata: languageName: node linkType: hard -"array-includes@npm:^3.1.8, array-includes@npm:^3.1.9": +"array-includes@npm:^3.1.9": version: 3.1.9 resolution: "array-includes@npm:3.1.9" dependencies: @@ -3579,7 +3549,7 @@ __metadata: languageName: node linkType: hard -"array.prototype.findlastindex@npm:^1.2.5, array.prototype.findlastindex@npm:^1.2.6": +"array.prototype.findlastindex@npm:^1.2.6": version: 1.2.6 resolution: "array.prototype.findlastindex@npm:1.2.6" dependencies: @@ -3749,9 +3719,9 @@ __metadata: linkType: hard "bare-events@npm:^2.2.0, bare-events@npm:^2.5.4": - version: 2.5.4 - resolution: "bare-events@npm:2.5.4" - checksum: 10c0/877a9cea73d545e2588cdbd6fd01653e27dac48ad6b44985cdbae73e1f57f292d4ba52e25d1fba53674c1053c463d159f3d5c7bc36a2e6e192e389b499ddd627 + version: 2.6.0 + resolution: "bare-events@npm:2.6.0" + checksum: 10c0/9bdd727a8df81aae14746c9bb860102f6c5aafc028f17e3a8620f40dc8bfe816ed46b0c50cb3200d1a1099f8028da27110cf711267b296767f37d3e4c6a9d4a6 languageName: node linkType: hard @@ -4207,15 +4177,15 @@ __metadata: linkType: hard "chai@npm:^5.2.0": - version: 5.2.0 - resolution: "chai@npm:5.2.0" + version: 5.2.1 + resolution: "chai@npm:5.2.1" dependencies: assertion-error: "npm:^2.0.1" check-error: "npm:^2.1.1" deep-eql: "npm:^5.0.1" loupe: "npm:^3.1.0" pathval: "npm:^2.0.0" - checksum: 10c0/dfd1cb719c7cebb051b727672d382a35338af1470065cb12adb01f4ee451bbf528e0e0f9ab2016af5fc1eea4df6e7f4504dc8443f8f00bd8fb87ad32dc516f7d + checksum: 10c0/58209c03ae9b2fd97cfa1cb0fbe372b1906e6091311b9ba1b0468cc4923b0766a50a1050a164df3ccefb9464944c9216b632f1477c9e429068013bdbb57220f6 languageName: node linkType: hard @@ -4904,9 +4874,9 @@ __metadata: linkType: hard "csv-stringify@npm:^6.2.0": - version: 6.5.2 - resolution: "csv-stringify@npm:6.5.2" - checksum: 10c0/8d2c601ce99c4baf5009abb16a9021cfd8d91a7be660f54343cba566ee5057d0ef517e0afde91e7e8803aeafb81268f6f04e47cb272462553b12f8e65c9c0674 + version: 6.6.0 + resolution: "csv-stringify@npm:6.6.0" + checksum: 10c0/2e5b14ff1e434aba7b8cae74faa0329c1d967654820f2ae3f358a660b5887ab623224ed8eb7f3ab6d0f7342663c965ca079ca420b6046210709f72e8aec87d94 languageName: node linkType: hard @@ -5216,9 +5186,9 @@ __metadata: linkType: hard "devtools-protocol@npm:*": - version: 0.0.1481382 - resolution: "devtools-protocol@npm:0.0.1481382" - checksum: 10c0/f694a8ddf0598d1a0fdcc3b1b60eac18a179531bdce40451184e4b1a28b20337cf4271475a7cb5995e3c6075ce1189c0afad2e61eb7074444e87b3f7c7a1974a + version: 0.0.1484773 + resolution: "devtools-protocol@npm:0.0.1484773" + checksum: 10c0/05d756555c94cff01d580a2f129b1ec8b52bd542b140f775c8a41eb5ba0e2b7bee14c24afef6b0210c0780b331b7348d56ada048d39f576f957872c0f822a536 languageName: node linkType: hard @@ -5377,9 +5347,9 @@ __metadata: linkType: hard "electron-to-chromium@npm:^1.5.173": - version: 1.5.180 - resolution: "electron-to-chromium@npm:1.5.180" - checksum: 10c0/3042aa1d20e1ff0b8205bc856a98a7f94aafb3051d468a40d9bb3dac65b36dc48b9c1197c5a56ea179f39231668a3f0c35b02c3a58d0fe25d047613e07a21565 + version: 1.5.182 + resolution: "electron-to-chromium@npm:1.5.182" + checksum: 10c0/45d45a2d5d304547818574ca083e739e5099bab650b655329a1a39ff2aaa2bf8ba2114c8fc13b5d96014233181d87ec8f84a48d976c5b91140036ceb587bb8e5 languageName: node linkType: hard @@ -5891,7 +5861,7 @@ __metadata: languageName: node linkType: hard -"eslint-module-utils@npm:^2.12.0, eslint-module-utils@npm:^2.12.1": +"eslint-module-utils@npm:^2.12.1": version: 2.12.1 resolution: "eslint-module-utils@npm:2.12.1" dependencies: @@ -5903,7 +5873,7 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-import@npm:^2.31.0": +"eslint-plugin-import@npm:^2.32.0": version: 2.32.0 resolution: "eslint-plugin-import@npm:2.32.0" dependencies: @@ -14053,8 +14023,8 @@ __metadata: linkType: hard "zod@npm:^3.24.1": - version: 3.25.75 - resolution: "zod@npm:3.25.75" - checksum: 10c0/e11c83dcd1437401c1edf4f0448bd13b9133e83196385d0ee5407662bef6c08099cb511b5e65f11f2feba244b6709bcd34077c9450c9f3064f75df51b362b5f2 + version: 3.25.76 + resolution: "zod@npm:3.25.76" + checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c languageName: node linkType: hard