Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 53 additions & 8 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export class AbleDOM {
Map<ValidationRule, ValidationIssue>
> = new Map();
private _currentNotAnchoredIssues: ValidationIssue[] = [];
private _readIssues: WeakSet<ValidationIssue> = new Set();

constructor(win: Window, props: AbleDOMProps = {}) {
this._win = win;
Expand Down Expand Up @@ -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<ValidationIssue[]> {
idle(
markAsRead?: boolean,
timeout?: number,
): Promise<ValidationIssue[] | null> {
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<null>((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 {
Expand Down
130 changes: 129 additions & 1 deletion tests/testingMode/testingMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { loadTestPage, issueSelector } from "../utils";

interface WindowWithAbleDOMInstance extends Window {
ableDOMInstanceForTesting?: {
idle: () => Promise<unknown[]>;
idle: (markAsRead?: boolean, timeout?: number) => Promise<unknown[] | null>;
highlightElement: (element: HTMLElement, scrollIntoView: boolean) => void;
};
}
Expand Down Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Page } from "@playwright/test";
export const issueSelector = "#abledom-report .abledom-issue";

interface WindowWithAbleDOMData extends Window {
__ableDOMIdle?: () => Promise<ValidationIssue[]>;
__ableDOMIdle?: typeof AbleDOM.prototype.idle;
__ableDOMIssuesFromCallbacks?: Map<
HTMLElement | null,
Map<ValidationRule<ValidationIssue>, ValidationIssue>
Expand Down
4 changes: 2 additions & 2 deletions tools/playwright/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tools/playwright/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "abledom-playwright",
"version": "0.0.11",
"version": "0.0.13",
"description": "AbleDOM tools for Playwright",
"author": "Marat Abdullin <marata@microsoft.com>",
"license": "MIT",
Expand Down
17 changes: 12 additions & 5 deletions tools/playwright/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -34,9 +36,11 @@ export interface AbleDOMFixtures {
* // Now all locator actions on this page will trigger AbleDOM checks
* ```
*/
attachAbleDOM: (page: Page) => Promise<void>;
attachAbleDOM: (page: Page, options?: AbleDOMIdleOptions) => Promise<void>;
}

export { AbleDOMIdleOptions };

/**
* Creates an AbleDOM test fixture that can be merged with other Playwright test fixtures.
*
Expand Down Expand Up @@ -89,9 +93,12 @@ export function createAbleDOMTest(): TestType<
> {
return base.extend<AbleDOMFixtures>({
attachAbleDOM: async ({}, use, testInfo) => {
const attach = async (page: Page): Promise<void> => {
const attach = async (
page: Page,
options?: AbleDOMIdleOptions,
): Promise<void> => {
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}`);
Expand Down Expand Up @@ -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<void>,
testInfo: TestInfo,
): Promise<void> => {
await attachAbleDOMMethodsToPage(page, testInfo);
await attachAbleDOMMethodsToPage(page, testInfo, options);
await use(page);
};
}
1 change: 1 addition & 0 deletions tools/playwright/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading