diff --git a/src/core.ts b/src/core.ts index 6d05c0e..1675d2d 100644 --- a/src/core.ts +++ b/src/core.ts @@ -69,6 +69,7 @@ export class AbleDOM { Map > = new Map(); private _currentNotAnchoredIssues: ValidationIssue[] = []; + private _readIssues: WeakSet = new Set(); constructor(win: Window, props: AbleDOMProps = {}) { this._win = win; @@ -545,34 +546,78 @@ export class AbleDOM { this._addIssue(rule, issue); }; - private _getCurrentIssues(): ValidationIssue[] { - const issues = this._currentNotAnchoredIssues.slice(0); + private _getCurrentIssues(markAsRead: boolean): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + this._currentNotAnchoredIssues.forEach((issue) => { + if (!this._readIssues.has(issue)) { + issues.push(issue); + } + + if (markAsRead) { + this._readIssues.add(issue); + } + }); this._currentAnchoredIssues.forEach((issueByRule) => { issueByRule.forEach((issue) => { - issues.push(issue); + if (!this._readIssues.has(issue)) { + issues.push(issue); + } + + if (markAsRead) { + this._readIssues.add(issue); + } }); }); return issues; } - idle(): Promise { + idle( + markAsRead?: boolean, + timeout?: number, + ): Promise { if (!this._clearValidationTimeout) { - return Promise.resolve(this._getCurrentIssues()); - } + return Promise.resolve(this._getCurrentIssues(!!markAsRead)); + } + + let timeoutClear: (() => void) | undefined; + let timeoutResolve: (() => void) | undefined; + let timeoutPromise = timeout + ? new Promise((resolve) => { + timeoutResolve = () => { + timeoutClear?.(); + timeoutResolve = undefined; + resolve(null); + }; + + let timeoutTimer = this._win.setTimeout(() => { + timeoutClear = undefined; + timeoutResolve?.(); + }, timeout); + + timeoutClear = () => { + this._win.clearTimeout(timeoutTimer); + timeoutClear = undefined; + }; + }) + : undefined; if (!this._idlePromise) { this._idlePromise = new Promise((resolve) => { this._idleResolve = () => { delete this._idlePromise; delete this._idleResolve; - resolve(this._getCurrentIssues()); + resolve(this._getCurrentIssues(!!markAsRead)); + timeoutResolve?.(); }; }); } - return this._idlePromise; + return timeoutPromise + ? Promise.race([this._idlePromise, timeoutPromise]) + : this._idlePromise; } clearCurrentIssues(anchored = true, notAnchored = true): void { diff --git a/tests/testingMode/testingMode.test.ts b/tests/testingMode/testingMode.test.ts index 86b8442..d133891 100644 --- a/tests/testingMode/testingMode.test.ts +++ b/tests/testingMode/testingMode.test.ts @@ -7,7 +7,7 @@ import { loadTestPage, issueSelector } from "../utils"; interface WindowWithAbleDOMInstance extends Window { ableDOMInstanceForTesting?: { - idle: () => Promise; + idle: (markAsRead?: boolean, timeout?: number) => Promise; highlightElement: (element: HTMLElement, scrollIntoView: boolean) => void; }; } @@ -140,4 +140,132 @@ test.describe("exposeInstanceForTesting prop", () => { expect(issues!.length).toBe(1); expect(issues![0].id).toBe("focusable-element-label"); }); + + test("idle() with markAsRead=true should not return same issues on subsequent calls", async ({ + page, + }) => { + await loadTestPage(page, "tests/testingMode/exposed-headless.html"); + + // Create an issue + await page.evaluate(() => { + const btn = document.getElementById("button-1"); + if (btn) { + btn.innerText = ""; + } + }); + + // First call with markAsRead=true + const firstCallIssues = await page.evaluate(async () => { + const instance = (window as WindowWithAbleDOMInstance) + .ableDOMInstanceForTesting; + const result = await instance?.idle(true); + return result?.length ?? 0; + }); + + expect(firstCallIssues).toBe(1); + + // Second call should return empty array since issues were marked as read + const secondCallIssues = await page.evaluate(async () => { + const instance = (window as WindowWithAbleDOMInstance) + .ableDOMInstanceForTesting; + const result = await instance?.idle(); + return result?.length ?? 0; + }); + + expect(secondCallIssues).toBe(0); + }); + + test("idle() with markAsRead=false should return same issues on subsequent calls", async ({ + page, + }) => { + await loadTestPage(page, "tests/testingMode/exposed-headless.html"); + + // Create an issue + await page.evaluate(() => { + const btn = document.getElementById("button-1"); + if (btn) { + btn.innerText = ""; + } + }); + + // First call with markAsRead=false (or omitted) + const firstCallIssues = await page.evaluate(async () => { + const instance = (window as WindowWithAbleDOMInstance) + .ableDOMInstanceForTesting; + const result = await instance?.idle(false); + return result?.length ?? 0; + }); + + expect(firstCallIssues).toBe(1); + + // Second call should still return the same issues + const secondCallIssues = await page.evaluate(async () => { + const instance = (window as WindowWithAbleDOMInstance) + .ableDOMInstanceForTesting; + const result = await instance?.idle(); + return result?.length ?? 0; + }); + + expect(secondCallIssues).toBe(1); + }); + + test("idle() with timeout should return null when timeout expires before validation completes", async ({ + page, + }) => { + await loadTestPage(page, "tests/testingMode/exposed-headless.html"); + + // Create an issue to trigger validation + await page.evaluate(() => { + const btn = document.getElementById("button-1"); + if (btn) { + btn.innerText = ""; + } + }); + + // Call idle with a very short timeout (1ms) immediately after triggering validation + // This should timeout before validation completes + const result = await page.evaluate(async () => { + const instance = (window as WindowWithAbleDOMInstance) + .ableDOMInstanceForTesting; + + // Trigger another change to ensure validation is pending + const btn = document.getElementById("button-1"); + if (btn) { + btn.setAttribute("data-test", "changed"); + } + + // Call idle with 1ms timeout - should return null if validation is still pending + return await instance?.idle(false, 1); + }); + + // Result should be null (timeout) or an array (validation completed fast enough) + // We accept both outcomes since timing can vary + expect(result === null || Array.isArray(result)).toBe(true); + }); + + test("idle() with sufficient timeout should return issues", async ({ + page, + }) => { + await loadTestPage(page, "tests/testingMode/exposed-headless.html"); + + // Create an issue + await page.evaluate(() => { + const btn = document.getElementById("button-1"); + if (btn) { + btn.innerText = ""; + } + }); + + // Call idle with a long timeout - should return issues + const issues = await page.evaluate(async () => { + const instance = (window as WindowWithAbleDOMInstance) + .ableDOMInstanceForTesting; + const result = await instance?.idle(false, 5000); + return result; + }); + + expect(issues).not.toBeNull(); + expect(Array.isArray(issues)).toBe(true); + expect(issues!.length).toBe(1); + }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 9e20d21..e2bbd66 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -13,7 +13,7 @@ import { Page } from "@playwright/test"; export const issueSelector = "#abledom-report .abledom-issue"; interface WindowWithAbleDOMData extends Window { - __ableDOMIdle?: () => Promise; + __ableDOMIdle?: typeof AbleDOM.prototype.idle; __ableDOMIssuesFromCallbacks?: Map< HTMLElement | null, Map, ValidationIssue> diff --git a/tools/playwright/package-lock.json b/tools/playwright/package-lock.json index 063006a..c30ac55 100644 --- a/tools/playwright/package-lock.json +++ b/tools/playwright/package-lock.json @@ -1,12 +1,12 @@ { "name": "abledom-playwright", - "version": "0.0.11", + "version": "0.0.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "abledom-playwright", - "version": "0.0.11", + "version": "0.0.13", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/tools/playwright/package.json b/tools/playwright/package.json index e18b544..0e395bf 100644 --- a/tools/playwright/package.json +++ b/tools/playwright/package.json @@ -1,6 +1,6 @@ { "name": "abledom-playwright", - "version": "0.0.11", + "version": "0.0.13", "description": "AbleDOM tools for Playwright", "author": "Marat Abdullin ", "license": "MIT", diff --git a/tools/playwright/src/fixtures.ts b/tools/playwright/src/fixtures.ts index b5b5da3..740332b 100644 --- a/tools/playwright/src/fixtures.ts +++ b/tools/playwright/src/fixtures.ts @@ -14,6 +14,7 @@ import { TestInfo, } from "@playwright/test"; import { attachAbleDOMMethodsToPage } from "./page-injector.js"; +import type { AbleDOMIdleOptions } from "./page-injector.js"; /** * Fixtures provided by the AbleDOM test integration. @@ -25,6 +26,7 @@ export interface AbleDOMFixtures { * IMPORTANT: This function is async and MUST be awaited before navigating the page. * * @param page - The Playwright Page object to attach AbleDOM to + * @param options - Optional idle options (markAsRead defaults to true, timeout defaults to 2000ms) * * @example * ```typescript @@ -34,9 +36,11 @@ export interface AbleDOMFixtures { * // Now all locator actions on this page will trigger AbleDOM checks * ``` */ - attachAbleDOM: (page: Page) => Promise; + attachAbleDOM: (page: Page, options?: AbleDOMIdleOptions) => Promise; } +export { AbleDOMIdleOptions }; + /** * Creates an AbleDOM test fixture that can be merged with other Playwright test fixtures. * @@ -89,9 +93,12 @@ export function createAbleDOMTest(): TestType< > { return base.extend({ attachAbleDOM: async ({}, use, testInfo) => { - const attach = async (page: Page): Promise => { + const attach = async ( + page: Page, + options?: AbleDOMIdleOptions, + ): Promise => { try { - await attachAbleDOMMethodsToPage(page, testInfo); + await attachAbleDOMMethodsToPage(page, testInfo, options); console.log("[AbleDOM] Attached to page."); } catch (error) { console.warn(`[AbleDOM] Failed to attach to page: ${error}`); @@ -131,13 +138,13 @@ export function createAbleDOMTest(): TestType< * }); * ``` */ -export function createAbleDOMPageFixture() { +export function createAbleDOMPageFixture(options?: AbleDOMIdleOptions) { return async ( { page }: { page: Page }, use: (page: Page) => Promise, testInfo: TestInfo, ): Promise => { - await attachAbleDOMMethodsToPage(page, testInfo); + await attachAbleDOMMethodsToPage(page, testInfo, options); await use(page); }; } diff --git a/tools/playwright/src/index.ts b/tools/playwright/src/index.ts index dba5e9c..550526c 100644 --- a/tools/playwright/src/index.ts +++ b/tools/playwright/src/index.ts @@ -4,6 +4,7 @@ */ export { attachAbleDOMMethodsToPage } from "./page-injector.js"; +export type { AbleDOMIdleOptions } from "./page-injector.js"; export type { WindowWithAbleDOMInstance } from "./types.js"; export { AbleDOMReporter, diff --git a/tools/playwright/src/page-injector.ts b/tools/playwright/src/page-injector.ts index e9d6135..a3b68c2 100644 --- a/tools/playwright/src/page-injector.ts +++ b/tools/playwright/src/page-injector.ts @@ -7,6 +7,23 @@ import type { Page, Locator, TestInfo } from "@playwright/test"; import type { WindowWithAbleDOMInstance } from "./types.js"; import { normalizeFilePath } from "./utils.js"; +/** + * Options for AbleDOM idle() behavior. + */ +export interface AbleDOMIdleOptions { + /** + * Whether to mark returned issues as read so they won't be returned again. + * @default true + */ + markAsRead?: boolean; + /** + * Timeout in milliseconds to wait for validation to complete. + * If validation doesn't complete within the timeout, returns null. + * @default 2000 + */ + timeout?: number; +} + interface LocatorMonkeyPatchedWithAbleDOM extends Locator { __locatorIsMonkeyPatchedWithAbleDOM?: boolean; } @@ -75,6 +92,7 @@ function getCallerLocation( * * @param page - The Playwright Page object to attach methods to * @param testInfo - Optional TestInfo object for reporting issues to the custom reporter + * @param options - Optional idle options (markAsRead defaults to true, timeout defaults to 2000ms) * * @example * ```typescript @@ -93,12 +111,16 @@ function getCallerLocation( export async function attachAbleDOMMethodsToPage( page: Page, testInfo?: TestInfo, + options: AbleDOMIdleOptions = {}, ): Promise { + const { markAsRead = true, timeout = 2000 } = options; const attachAbleDOMMethodsToPageWithCachedLocatorProto: FunctionWithCachedLocatorProto = attachAbleDOMMethodsToPage; - // Store testInfo on the page object so each page has its own testInfo + // Store testInfo and options on the page object so each page has its own config (page as unknown as Record).__abledomTestInfo = testInfo; + (page as unknown as Record).__abledomMarkAsRead = markAsRead; + (page as unknown as Record).__abledomTimeout = timeout; let locatorProto: LocatorMonkeyPatchedWithAbleDOM | undefined = attachAbleDOMMethodsToPageWithCachedLocatorProto.__cachedLocatorProto; @@ -128,28 +150,40 @@ export async function attachAbleDOMMethodsToPage( const ret = await origWaitFor.apply(this, args); const currentPage = this.page(); - const result = await currentPage.evaluate(async () => { - const win = window as unknown as WindowWithAbleDOMInstance; - const hasInstance = !!win.ableDOMInstanceForTesting; - const issues = await win.ableDOMInstanceForTesting?.idle(); - const el = issues?.[0]?.element; + // Get options from the page object + const pageMarkAsRead = (currentPage as unknown as Record) + .__abledomMarkAsRead as boolean | undefined; + const pageTimeout = (currentPage as unknown as Record) + .__abledomTimeout as number | undefined; - if (el) { - // TODO: Make highlighting flag-dependent. - // win.ableDOMInstanceForTesting?.highlightElement(el, true); - } + const result = await currentPage.evaluate( + async ({ markAsRead, timeout }) => { + const win = window as unknown as WindowWithAbleDOMInstance; + const hasInstance = !!win.ableDOMInstanceForTesting; + const issues = await win.ableDOMInstanceForTesting?.idle( + markAsRead, + timeout, + ); + const el = issues?.[0]?.element; - return { - hasInstance, - issues: issues?.map((issue) => ({ - id: issue.id, - message: issue.message, - element: issue.element?.outerHTML, - parentParent: - issue.element?.parentElement?.parentElement?.outerHTML, - })), - }; - }); + if (el) { + // TODO: Make highlighting flag-dependent. + // win.ableDOMInstanceForTesting?.highlightElement(el, true); + } + + return { + hasInstance, + issues: issues?.map((issue) => ({ + id: issue.id, + message: issue.message, + element: issue.element?.outerHTML, + parentParent: + issue.element?.parentElement?.parentElement?.outerHTML, + })), + }; + }, + { markAsRead: pageMarkAsRead, timeout: pageTimeout }, + ); const { hasInstance, issues } = result; diff --git a/tools/playwright/src/types.ts b/tools/playwright/src/types.ts index fe16541..c7849c4 100644 --- a/tools/playwright/src/types.ts +++ b/tools/playwright/src/types.ts @@ -8,8 +8,11 @@ */ export interface WindowWithAbleDOMInstance extends Window { ableDOMInstanceForTesting?: { - idle: () => Promise< - { id: string; message: string; element: HTMLElement | null }[] + idle: ( + markAsRead?: boolean, + timeout?: number, + ) => Promise< + { id: string; message: string; element: HTMLElement | null }[] | null >; highlightElement: (element: HTMLElement, scrollIntoView: boolean) => void; }; diff --git a/tools/playwright/tests/create-abledom-test.test.spec.ts b/tools/playwright/tests/create-abledom-test.test.spec.ts index 397bd2c..fcc94b8 100644 --- a/tools/playwright/tests/create-abledom-test.test.spec.ts +++ b/tools/playwright/tests/create-abledom-test.test.spec.ts @@ -205,3 +205,182 @@ test.describe("createAbleDOMTest fixture", () => { await context.close(); }); }); + +test.describe("attachAbleDOM with custom options", () => { + test("should accept markAsRead option", async ({ + attachAbleDOM, + browser, + }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto( + "data:text/html,", + ); + + // Attach with markAsRead=false + await attachAbleDOM(page, { markAsRead: false }); + + // Track idle() calls + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __idleOpts: unknown[] }).__idleOpts = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + (window as unknown as { __idleOpts: unknown[] }).__idleOpts.push({ + markAsRead, + timeout, + }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + await page.locator("button").waitFor(); + + const opts = await page.evaluate(() => { + return (window as unknown as { __idleOpts: unknown[] }).__idleOpts; + }); + + expect(opts).toHaveLength(1); + expect(opts[0]).toEqual({ markAsRead: false, timeout: 2000 }); + + await context.close(); + }); + + test("should accept timeout option", async ({ attachAbleDOM, browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto( + "data:text/html,", + ); + + // Attach with custom timeout + await attachAbleDOM(page, { timeout: 5000 }); + + // Track idle() calls + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __idleOpts: unknown[] }).__idleOpts = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + (window as unknown as { __idleOpts: unknown[] }).__idleOpts.push({ + markAsRead, + timeout, + }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + await page.locator("button").waitFor(); + + const opts = await page.evaluate(() => { + return (window as unknown as { __idleOpts: unknown[] }).__idleOpts; + }); + + expect(opts).toHaveLength(1); + expect(opts[0]).toEqual({ markAsRead: true, timeout: 5000 }); + + await context.close(); + }); + + test("should accept both markAsRead and timeout options", async ({ + attachAbleDOM, + browser, + }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto( + "data:text/html,", + ); + + // Attach with both custom options + await attachAbleDOM(page, { markAsRead: false, timeout: 3000 }); + + // Track idle() calls + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __idleOpts: unknown[] }).__idleOpts = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + (window as unknown as { __idleOpts: unknown[] }).__idleOpts.push({ + markAsRead, + timeout, + }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + await page.locator("button").waitFor(); + + const opts = await page.evaluate(() => { + return (window as unknown as { __idleOpts: unknown[] }).__idleOpts; + }); + + expect(opts).toHaveLength(1); + expect(opts[0]).toEqual({ markAsRead: false, timeout: 3000 }); + + await context.close(); + }); + + test("should use defaults when no options provided", async ({ + attachAbleDOM, + browser, + }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto( + "data:text/html,", + ); + + // Attach without options (should use defaults) + await attachAbleDOM(page); + + // Track idle() calls + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __idleOpts: unknown[] }).__idleOpts = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + (window as unknown as { __idleOpts: unknown[] }).__idleOpts.push({ + markAsRead, + timeout, + }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + await page.locator("button").waitFor(); + + const opts = await page.evaluate(() => { + return (window as unknown as { __idleOpts: unknown[] }).__idleOpts; + }); + + expect(opts).toHaveLength(1); + expect(opts[0]).toEqual({ markAsRead: true, timeout: 2000 }); + + await context.close(); + }); +}); diff --git a/tools/playwright/tests/page-fixture-options.test.spec.ts b/tools/playwright/tests/page-fixture-options.test.spec.ts new file mode 100644 index 0000000..b973b40 --- /dev/null +++ b/tools/playwright/tests/page-fixture-options.test.spec.ts @@ -0,0 +1,148 @@ +/*! + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ +import { test as baseTest, expect } from "@playwright/test"; +import { createAbleDOMPageFixture } from "../src/index"; +import type { WindowWithAbleDOMInstance } from "../src/types.js"; + +// Test with custom markAsRead=false option +const testWithMarkAsReadFalse = baseTest.extend({ + page: createAbleDOMPageFixture({ markAsRead: false }), +}); + +// Test with custom timeout option +const testWithCustomTimeout = baseTest.extend({ + page: createAbleDOMPageFixture({ timeout: 5000 }), +}); + +// Test with both custom options +const testWithBothOptions = baseTest.extend({ + page: createAbleDOMPageFixture({ markAsRead: false, timeout: 3000 }), +}); + +testWithMarkAsReadFalse.describe( + "createAbleDOMPageFixture with markAsRead=false", + () => { + testWithMarkAsReadFalse( + "should pass markAsRead=false to idle()", + async ({ page }) => { + await page.goto( + "data:text/html,", + ); + + // Track idle() calls + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __idleOpts: unknown[] }).__idleOpts = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + (window as unknown as { __idleOpts: unknown[] }).__idleOpts.push({ + markAsRead, + timeout, + }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + await page.locator("button").waitFor(); + + const opts = await page.evaluate(() => { + return (window as unknown as { __idleOpts: unknown[] }).__idleOpts; + }); + + expect(opts).toHaveLength(1); + expect(opts[0]).toEqual({ markAsRead: false, timeout: 2000 }); + }, + ); + }, +); + +testWithCustomTimeout.describe( + "createAbleDOMPageFixture with custom timeout", + () => { + testWithCustomTimeout( + "should pass custom timeout to idle()", + async ({ page }) => { + await page.goto( + "data:text/html,", + ); + + // Track idle() calls + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __idleOpts: unknown[] }).__idleOpts = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + (window as unknown as { __idleOpts: unknown[] }).__idleOpts.push({ + markAsRead, + timeout, + }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + await page.locator("button").waitFor(); + + const opts = await page.evaluate(() => { + return (window as unknown as { __idleOpts: unknown[] }).__idleOpts; + }); + + expect(opts).toHaveLength(1); + expect(opts[0]).toEqual({ markAsRead: true, timeout: 5000 }); + }, + ); + }, +); + +testWithBothOptions.describe( + "createAbleDOMPageFixture with both custom options", + () => { + testWithBothOptions( + "should pass both custom options to idle()", + async ({ page }) => { + await page.goto( + "data:text/html,", + ); + + // Track idle() calls + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __idleOpts: unknown[] }).__idleOpts = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + (window as unknown as { __idleOpts: unknown[] }).__idleOpts.push({ + markAsRead, + timeout, + }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + await page.locator("button").waitFor(); + + const opts = await page.evaluate(() => { + return (window as unknown as { __idleOpts: unknown[] }).__idleOpts; + }); + + expect(opts).toHaveLength(1); + expect(opts[0]).toEqual({ markAsRead: false, timeout: 3000 }); + }, + ); + }, +); diff --git a/tools/playwright/tests/page-injector.test.spec.ts b/tools/playwright/tests/page-injector.test.spec.ts index 4a38f8c..e1e7ba7 100644 --- a/tools/playwright/tests/page-injector.test.spec.ts +++ b/tools/playwright/tests/page-injector.test.spec.ts @@ -259,6 +259,153 @@ test.describe("page-injector with mocked AbleDOM", () => { }); }); +test.describe("idle options (markAsRead and timeout)", () => { + test("should pass markAsRead and timeout options to idle()", async ({ + page, + }) => { + await page.goto( + "data:text/html,", + ); + + // Mock AbleDOM to capture the arguments passed to idle() + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __idleArgs: unknown[] }).__idleArgs = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + (window as unknown as { __idleArgs: unknown[] }).__idleArgs.push({ + markAsRead, + timeout, + }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + // Trigger an action - should use default options (markAsRead=true, timeout=2000) + await page.locator("button").waitFor(); + + // Verify the options were passed + const idleArgs = await page.evaluate(() => { + return (window as unknown as { __idleArgs: unknown[] }).__idleArgs; + }); + + expect(idleArgs.length).toBe(1); + expect(idleArgs[0]).toEqual({ markAsRead: true, timeout: 2000 }); + }); + + test("should use default markAsRead=true and timeout=2000", async ({ + page, + }) => { + await page.goto( + "data:text/html,", + ); + + // Track idle() calls and their arguments + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __idleCalls: unknown[] }).__idleCalls = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + (window as unknown as { __idleCalls: unknown[] }).__idleCalls.push({ + markAsRead, + timeout, + }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + await page.locator("button").waitFor(); + + const calls = await page.evaluate(() => { + return (window as unknown as { __idleCalls: unknown[] }).__idleCalls; + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ markAsRead: true, timeout: 2000 }); + }); + + test("should handle null return from idle() when timeout expires", async ({ + page, + }, testInfo) => { + await page.goto( + "data:text/html,", + ); + + // Mock AbleDOM to return null (simulating timeout expiration) + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + win.ableDOMInstanceForTesting = { + idle: async () => { + // Return null to simulate timeout + return null; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + // This should not throw even when idle() returns null + await page.locator("button").waitFor(); + + // Verify no issues were reported (since null was returned) + const customDataAttachments = testInfo.attachments.filter( + (att) => att.name === "abledom-test-data", + ); + expect(customDataAttachments.length).toBe(0); + }); + + test("markAsRead=true should cause subsequent idle() calls to receive same option", async ({ + page, + }) => { + await page.goto( + "data:text/html,", + ); + + // Track all idle() calls + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __allIdleCalls: unknown[] }).__allIdleCalls = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + ( + window as unknown as { __allIdleCalls: unknown[] } + ).__allIdleCalls.push({ markAsRead, timeout }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + // Multiple actions should all pass the same options + await page.locator("button").waitFor(); + await page.locator("input").waitFor(); + + const calls = await page.evaluate(() => { + return (window as unknown as { __allIdleCalls: unknown[] }) + .__allIdleCalls; + }); + + expect(calls).toHaveLength(2); + // Both calls should have the same default options + expect(calls[0]).toEqual({ markAsRead: true, timeout: 2000 }); + expect(calls[1]).toEqual({ markAsRead: true, timeout: 2000 }); + }); +}); + // This test uses baseTest (without fixture) to test the case where testInfo is not provided baseTest("should work without testInfo parameter", async ({ page }) => { await page.goto( @@ -293,3 +440,125 @@ baseTest("should work without testInfo parameter", async ({ page }) => { // Test passes - no errors thrown baseTest.expect(true).toBe(true); }); + +baseTest.describe("custom idle options via attachAbleDOMMethodsToPage", () => { + baseTest( + "should pass custom markAsRead=false option", + async ({ page }, testInfo) => { + await page.goto( + "data:text/html,", + ); + + // Attach with custom options + await attachAbleDOMMethodsToPage(page, testInfo, { markAsRead: false }); + + // Track idle() calls + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __idleOpts: unknown[] }).__idleOpts = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + (window as unknown as { __idleOpts: unknown[] }).__idleOpts.push({ + markAsRead, + timeout, + }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + await page.locator("button").waitFor(); + + const opts = await page.evaluate(() => { + return (window as unknown as { __idleOpts: unknown[] }).__idleOpts; + }); + + baseTest.expect(opts).toHaveLength(1); + baseTest.expect(opts[0]).toEqual({ markAsRead: false, timeout: 2000 }); + }, + ); + + baseTest("should pass custom timeout option", async ({ page }, testInfo) => { + await page.goto( + "data:text/html,", + ); + + // Attach with custom timeout + await attachAbleDOMMethodsToPage(page, testInfo, { timeout: 5000 }); + + // Track idle() calls + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __idleOpts: unknown[] }).__idleOpts = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + (window as unknown as { __idleOpts: unknown[] }).__idleOpts.push({ + markAsRead, + timeout, + }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + await page.locator("button").waitFor(); + + const opts = await page.evaluate(() => { + return (window as unknown as { __idleOpts: unknown[] }).__idleOpts; + }); + + baseTest.expect(opts).toHaveLength(1); + baseTest.expect(opts[0]).toEqual({ markAsRead: true, timeout: 5000 }); + }); + + baseTest( + "should pass both custom markAsRead and timeout options", + async ({ page }, testInfo) => { + await page.goto( + "data:text/html,", + ); + + // Attach with both custom options + await attachAbleDOMMethodsToPage(page, testInfo, { + markAsRead: false, + timeout: 10000, + }); + + // Track idle() calls + await page.evaluate(() => { + const win = window as WindowWithAbleDOMInstance; + (window as unknown as { __idleOpts: unknown[] }).__idleOpts = []; + + win.ableDOMInstanceForTesting = { + idle: async (markAsRead?: boolean, timeout?: number) => { + (window as unknown as { __idleOpts: unknown[] }).__idleOpts.push({ + markAsRead, + timeout, + }); + return []; + }, + highlightElement: () => { + /* noop */ + }, + }; + }); + + await page.locator("button").waitFor(); + + const opts = await page.evaluate(() => { + return (window as unknown as { __idleOpts: unknown[] }).__idleOpts; + }); + + baseTest.expect(opts).toHaveLength(1); + baseTest.expect(opts[0]).toEqual({ markAsRead: false, timeout: 10000 }); + }, + ); +});