diff --git a/README.md b/README.md index a131d18..0646607 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ A [Model Context Protocol server](https://modelcontextprotocol.io/quickstart/use Set up in Cursor - Open Cursor `Settings` (button at the top right corner of the screen), find `MCP` section, click on the `Add new global MCP server` button, edit the config to include Testplane MCP as seen below. + Open Cursor `Settings` (button at the top right corner of the screen), find `Tools & Integrations` section, click on the `New MCP Server` button, edit the config to include Testplane MCP as seen below. ```json { @@ -173,9 +173,12 @@ Close a specific browser tab by its number (1-based), or close the current tab i ### `clickOnElement` Click an element on the page using semantic queries (`testing-library`-style) or CSS selectors. -- Semantic Queries: - - **Parameters:** - - `queryType` (string, optional): Semantic query type. One of: +- **Parameters:** + - `locator` (object, required): Element location strategy + - `strategy` (string, required): Either `"testing-library"` or `"webdriverio"` + + For **testing-library strategy**: + - `queryType` (string, required): Semantic query type. One of: - `"role"` - Find by ARIA role (e.g., "button", "link", "heading") - `"text"` - Find by visible text content - `"labelText"` - Find form inputs by their label text @@ -184,37 +187,69 @@ Click an element on the page using semantic queries (`testing-library`-style) or - `"testId"` - Find by data-testid attribute - `"title"` - Find by title attribute - `"displayValue"` - Find inputs by their current value - - `queryValue` (string, required when using queryType): The value to search for + - `queryValue` (string, required): The value to search for - `queryOptions` (object, optional): Additional options: - `name` (string): Accessible name for role queries - `exact` (boolean): Whether to match exact text (default: true) - `hidden` (boolean): Include hidden elements (default: false) - `level` (number): Heading level for role="heading" (1-6) - -- CSS Selectors: - - **Parameters:** - - `selector` (string, optional): CSS selector or XPath when semantic queries cannot locate the element + + For **webdriverio strategy**: + - `selector` (string, required): CSS selector, XPath or WebdriverIO locator **Examples:** ```javascript -// Semantic queries (preferred) -{ queryType: "role", queryValue: "button", queryOptions: { name: "Submit" } } -{ queryType: "text", queryValue: "Click here" } -{ queryType: "labelText", queryValue: "Email Address" } - -// CSS selector fallback -{ selector: ".submit-btn" } -{ selector: "#unique-element" } +// Testing Library strategy +{ + locator: { + strategy: "testing-library", + queryType: "role", + queryValue: "button", + queryOptions: { name: "Submit" } + } +} + +{ + locator: { + strategy: "testing-library", + queryType: "text", + queryValue: "Click here" + } +} + +{ + locator: { + strategy: "testing-library", + queryType: "labelText", + queryValue: "Email Address" + } +} + +// WebdriverIO strategy +{ + locator: { + strategy: "webdriverio", + selector: ".submit-btn" + } +} + +{ + locator: { + strategy: "webdriverio", + selector: "button*=Submit" + } +} ``` -**Note:** Provide either semantic query parameters OR selector, not both. - ### `typeIntoElement` Type text into an input element on the page using semantic queries (`testing-library`-style) or CSS selectors. -- Semantic Queries: - - **Parameters:** - - `queryType` (string, optional): Semantic query type. One of: +- **Parameters:** + - `locator` (object, required): Element location strategy + - `strategy` (string, required): Either `"testing-library"` or `"webdriverio"` + + For **testing-library strategy**: + - `queryType` (string, required): Semantic query type. One of: - `"role"` - Find by ARIA role (e.g., "textbox", "searchbox") - `"text"` - Find by visible text content - `"labelText"` - Find form inputs by their label text @@ -223,31 +258,55 @@ Type text into an input element on the page using semantic queries (`testing-lib - `"testId"` - Find by data-testid attribute - `"title"` - Find by title attribute - `"displayValue"` - Find inputs by their current value - - `queryValue` (string, required when using queryType): The value to search for - - `text` (string, required): The text to type into the element + - `queryValue` (string, required): The value to search for - `queryOptions` (object, optional): Additional options: - `name` (string): Accessible name for role queries - `exact` (boolean): Whether to match exact text (default: true) - `hidden` (boolean): Include hidden elements (default: false) + + For **webdriverio strategy**: + - `selector` (string, required): CSS selector or XPath + + - `text` (string, required): The text to type into the element + +**Examples:** -- CSS Selectors: - - **Parameters:** - - `selector` (string, optional): CSS selector or XPath when semantic queries cannot locate the element - - `text` (string, required): The text to type into the element +See above in the `clickOnElement` tool. + +### `waitForElement` +Wait for an element to appear or disappear on the page. Useful for waiting until page loads fully or loading spinners disappear. + +- **Parameters:** + - `locator` (object, required): Element location strategy + - `strategy` (string, required): Either `"testing-library"` or `"webdriverio"` + + For **testing-library strategy**: + - `queryType` (string, required): Semantic query type. One of: + - `"role"` - Find by ARIA role (e.g., "button", "link", "heading") + - `"text"` - Find by visible text content + - `"labelText"` - Find form inputs by their label text + - `"placeholderText"` - Find inputs by placeholder text + - `"altText"` - Find images by alt text + - `"testId"` - Find by data-testid attribute + - `"title"` - Find by title attribute + - `"displayValue"` - Find inputs by their current value + - `queryValue` (string, required): The value to search for + - `queryOptions` (object, optional): Additional options: + - `name` (string): Accessible name for role queries + - `exact` (boolean): Whether to match exact text (default: true) + - `hidden` (boolean): Include hidden elements (default: false) + - `level` (number): Heading level for role="heading" (1-6) + + For **webdriverio strategy**: + - `selector` (string, required): CSS selector or XPath + + - `disappear` (boolean, optional): Whether to wait for element to disappear. Default: false (wait for element to appear) + - `timeout` (number, optional): Maximum time to wait in milliseconds. Default: 3000 + - `includeSnapshotInResponse` (boolean, optional): Whether to include page snapshot in response. Default: true **Examples:** -```javascript -// Semantic queries (preferred) -{ queryType: "labelText", queryValue: "Email Address", text: "test@example.com" } -{ queryType: "placeholderText", queryValue: "Enter your name", text: "John Smith" } -{ queryType: "role", queryValue: "textbox", queryOptions: { name: "Username" }, text: "john_doe" } - -// CSS selector fallback -{ selector: "#username", text: "john_doe" } -{ selector: "input[name='email']", text: "user@domain.com" } -``` -**Note:** Provide either semantic query parameters OR selector, not both. +See above in the `clickOnElement` tool. diff --git a/src/tools/click-on-element.ts b/src/tools/click-on-element.ts index cecce28..be3498a 100644 --- a/src/tools/click-on-element.ts +++ b/src/tools/click-on-element.ts @@ -11,7 +11,7 @@ const clickOnElementCb: ToolCallback = async args => const context = contextProvider.getContext(); const browser = await context.browser.get(); - const { element, queryDescription, testplaneCode } = await findElement(browser, args, `await element.click();`); + const { element, queryDescription, testplaneCode } = await findElement(browser, args.locator); await element.click(); @@ -19,8 +19,9 @@ const clickOnElementCb: ToolCallback = async args => return await createElementStateResponse(element, { action: `Successfully clicked element found by ${queryDescription}`, - testplaneCode, - additionalInfo: `Element selection strategy: ${args.queryType ? `Semantic query (${args.queryType})` : "CSS selector (fallback)"}`, + testplaneCode: testplaneCode.startsWith("await") + ? `await (${testplaneCode}).click();` + : `await ${testplaneCode}.click();`, }); } catch (error) { console.error("Error clicking element:", error); @@ -37,18 +38,7 @@ const clickOnElementCb: ToolCallback = async args => export const clickOnElement: ToolDefinition = { name: "clickOnElement", - description: `Click an element on the page. - -PREFERRED APPROACH (for AI agents): Use semantic queries (queryType + queryValue) which are more robust and accessibility-focused: -- queryType="role" + queryValue="button" + queryOptions.name="Submit" → finds submit button -- queryType="text" + queryValue="Click here" → finds element containing that text -- queryType="labelText" + queryValue="Email" → finds input with Email label - -FALLBACK APPROACH: Use selector only when semantic queries cannot locate the element: -- selector="button.submit-btn" → CSS selector -- selector="//button[text()='Submit']" → XPath - -AI agents should prioritize semantic queries for better accessibility and test maintainability.`, + description: "Click an element on the page.", schema: elementClickSchema, cb: clickOnElementCb, }; diff --git a/src/tools/index.ts b/src/tools/index.ts index a874caf..ffb1358 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -8,6 +8,7 @@ import { listTabs } from "./list-tabs.js"; import { switchToTab } from "./switch-to-tab.js"; import { openNewTab } from "./open-new-tab.js"; import { closeTab } from "./close-tab.js"; +import { waitForElement } from "./wait-for-element.js"; export const tools = [ navigate, @@ -19,4 +20,5 @@ export const tools = [ switchToTab, openNewTab, closeTab, + waitForElement, ] as const satisfies ToolDefinition[]; // eslint-disable-line @typescript-eslint/no-explicit-any diff --git a/src/tools/navigate.ts b/src/tools/navigate.ts index 00939b0..5b2ddc8 100644 --- a/src/tools/navigate.ts +++ b/src/tools/navigate.ts @@ -16,11 +16,11 @@ const navigateCb: ToolCallback = async args => { const browser = await context.browser.get(); console.error(`Navigating to: ${url}`); - await browser.url(url); + await browser.openAndWait(url); return await createBrowserStateResponse(browser, { action: `Successfully navigated to ${url}`, - testplaneCode: `await browser.url("${url}");`, + testplaneCode: `await browser.openAndWait("${url}");`, }); } catch (error) { console.error("Error navigating to URL:", error); diff --git a/src/tools/take-page-snapshot.ts b/src/tools/take-page-snapshot.ts index 5c58db7..674c254 100644 --- a/src/tools/take-page-snapshot.ts +++ b/src/tools/take-page-snapshot.ts @@ -46,7 +46,7 @@ const takePageSnapshotCb: ToolCallback = async ar export const takePageSnapshot: ToolDefinition = { name: "takePageSnapshot", description: - "Capture a DOM snapshot of the current page. Note: by default, only useful tags and attributes are included. Prefer to use defaults. Response contains info as to what was omitted. If you need more info, request a snapshot with more tags and attributes.", + "Capture a DOM snapshot of the current page. Note: by default, not useful tags and attributes are excluded (e.g. script, style, etc.). Prefer to use defaults. Response contains info as to what was omitted. If you need more info, request a snapshot with more tags and attributes.", schema: takePageSnapshotSchema, cb: takePageSnapshotCb, }; diff --git a/src/tools/type-into-element.ts b/src/tools/type-into-element.ts index a94e8e7..fe73fb4 100644 --- a/src/tools/type-into-element.ts +++ b/src/tools/type-into-element.ts @@ -12,16 +12,12 @@ export const typeIntoElementSchema = { const typeIntoElementCb: ToolCallback = async args => { try { - const { text, ...selectorArgs } = args; + const { text } = args; const context = contextProvider.getContext(); const browser = await context.browser.get(); - const { element, queryDescription, testplaneCode } = await findElement( - browser, - selectorArgs, - `await element.setValue("${text}");`, - ); + const { element, queryDescription, testplaneCode } = await findElement(browser, args.locator); await element.setValue(text); @@ -29,8 +25,9 @@ const typeIntoElementCb: ToolCallback = async args return await createElementStateResponse(element, { action: `Successfully typed "${text}" into element found by ${queryDescription}`, - testplaneCode, - additionalInfo: `Element selection strategy: ${selectorArgs.queryType ? `Semantic query (${selectorArgs.queryType})` : "CSS selector (fallback)"}`, + testplaneCode: testplaneCode.startsWith("await") + ? `await (${testplaneCode}).setValue("${text}");` + : `await ${testplaneCode}.setValue("${text}");`, }); } catch (error) { console.error("Error typing into element:", error); @@ -47,18 +44,7 @@ const typeIntoElementCb: ToolCallback = async args export const typeIntoElement: ToolDefinition = { name: "typeIntoElement", - description: `Type text into an element on the page. The API is very similar to clickOnElement for consistency. - -PREFERRED APPROACH (for AI agents): Use semantic queries (queryType + queryValue) which are more robust and accessibility-focused: -- queryType="role" + queryValue="textbox" + queryOptions.name="Email" → finds email input -- queryType="labelText" + queryValue="Password" → finds input with Password label -- queryType="placeholderText" + queryValue="Enter your name" → finds input with specific placeholder - -FALLBACK APPROACH: Use selector only when semantic queries cannot locate the element: -- selector="input[name='email']" → CSS selector -- selector="//input[@placeholder='Search...']" → XPath - -AI agents should prioritize semantic queries for better accessibility and test maintainability.`, + description: "Type text into an element on the page.", schema: typeIntoElementSchema, cb: typeIntoElementCb, }; diff --git a/src/tools/utils/element-selector.ts b/src/tools/utils/element-selector.ts index ccdc159..79990dd 100644 --- a/src/tools/utils/element-selector.ts +++ b/src/tools/utils/element-selector.ts @@ -2,49 +2,68 @@ import { z } from "zod"; import { WdioBrowser } from "testplane"; import { setupBrowser } from "@testing-library/webdriverio"; +export enum LocatorStrategy { + Wdio = "webdriverio", + TestingLibrary = "testing-library", +} + export const elementSelectorSchema = { - queryType: z - .enum(["role", "text", "labelText", "placeholderText", "displayValue", "altText", "title", "testId"]) - .optional() - .describe( - "Semantic query type (PREFERRED). Use this whenever possible for better accessibility and robustness.", - ), - - queryValue: z - .string() - .optional() - .describe("The value to search for with the specified queryType (e.g., 'button' for role, 'Submit' for text)."), - - queryOptions: z - .object({ - name: z + locator: z.discriminatedUnion("strategy", [ + z.object({ + strategy: z.literal(LocatorStrategy.Wdio), + selector: z.string().describe("CSS selector, XPath or WebdriverIO locator."), + }), + z.object({ + strategy: z.literal(LocatorStrategy.TestingLibrary), + queryType: z + .enum(["role", "text", "labelText", "placeholderText", "displayValue", "altText", "title", "testId"]) + .describe( + "Semantic query type (PREFERRED). Use this whenever possible for better accessibility and robustness.", + ), + queryValue: z .string() + .describe( + "The value to search for with the specified queryType (e.g., 'button' for role, 'Submit' for text).", + ), + queryOptions: z + .object({ + name: z + .string() + .optional() + .describe("Accessible name for role queries (e.g., getByRole('button', {name: 'Submit'}))"), + exact: z.boolean().optional().describe("Whether to match exact text (default: true)"), + hidden: z + .boolean() + .optional() + .describe("Include elements hidden from accessibility tree (default: false)"), + level: z.number().optional().describe("Heading level for role='heading' (1-6)"), + }) .optional() - .describe("Accessible name for role queries (e.g., getByRole('button', {name: 'Submit'}))"), - exact: z.boolean().optional().describe("Whether to match exact text (default: true)"), - hidden: z.boolean().optional().describe("Include elements hidden from accessibility tree (default: false)"), - level: z.number().optional().describe("Heading level for role='heading' (1-6)"), - }) - .optional() - .describe("Additional options for semantic queries"), - - selector: z - .string() - .optional() - .describe("CSS selector or XPath. Use only when semantic queries cannot locate the element."), + .describe("Additional options for semantic queries"), + }), + ]) + .describe(`Element location strategy, an object, properties of which depend on the strategy. Available strategies: wdio or testing-library. + + - wdio strategy: CSS selectors, XPath expressions or wdio locators. Examples: + - CSS selector: {"strategy": "wdio", "selector": "button.submit-btn"} + - XPath: {"strategy": "wdio", "selector": "//button[text()='Submit']"} + - wdio locator: {"strategy": "wdio", "selector": "button*=Submit"} + + - testing-library strategy: semantic queries like getByRole, getByText, getByTestId, etc. Examples: + - {"strategy": "testing-library", "queryType": "role", "queryValue": "button", "queryOptions": {"name": "submit", "exact": false}} + - {"strategy": "testing-library", "queryType": "text", "queryValue": "Submit"} + - {"strategy": "testing-library", "queryType": "labelText", "queryValue": "Email"} + - {"strategy": "testing-library", "queryType": "placeholderText", "queryValue": "Enter your name"} + - {"strategy": "testing-library", "queryType": "displayValue", "queryValue": "123456"} + - {"strategy": "testing-library", "queryType": "altText", "queryValue": "Submit"} + - {"strategy": "testing-library", "queryType": "title", "queryValue": "Submit"} + - {"strategy": "testing-library", "queryType": "testId", "queryValue": "submit-btn"} + +Match user's code style - use testing-library queries if user asks to write tests and has testing-library installed. +`), }; -export interface ElementSelectorArgs { - queryType?: "role" | "text" | "labelText" | "placeholderText" | "displayValue" | "altText" | "title" | "testId"; - queryValue?: string; - queryOptions?: { - name?: string; - exact?: boolean; - hidden?: boolean; - level?: number; - }; - selector?: string; -} +export type ElementSelectorArgs = z.infer; export interface ElementResult { element: WebdriverIO.Element; @@ -52,97 +71,98 @@ export interface ElementResult { testplaneCode: string; } -export async function findElement( +export interface NullableElementResult { + element: WebdriverIO.Element | null; + queryDescription: string; + testplaneCode: string; +} + +export async function findElementByWdioSelector( browser: WdioBrowser, - args: ElementSelectorArgs, - actionCode: string, + locator: Extract, ): Promise { - const { queryType, queryValue, queryOptions, selector } = args; - - const hasSemanticQuery = queryType && queryValue; - const hasSelector = selector; + const { selector } = locator; + const element = await browser.$(selector); + const queryDescription = `CSS selector "${selector}"`; + const testplaneCode = `browser.$("${selector}")`; - if (!hasSemanticQuery && !hasSelector) { - throw new Error("Provide either semantic query (queryType + queryValue) or selector"); - } + return { + element, + queryDescription, + testplaneCode, + }; +} - if (hasSemanticQuery && hasSelector) { - throw new Error( - "Provide EITHER semantic query (queryType + queryValue) OR selector, not both. Prefer semantic queries for better accessibility.", - ); - } +export async function findElementByTestingLibraryQuery( + browser: WdioBrowser, + locator: Extract, +): Promise { + const { + queryByRole, + queryByText, + queryByLabelText, + queryByPlaceholderText, + queryByDisplayValue, + queryByAltText, + queryByTitle, + queryByTestId, + } = setupBrowser(browser as any); // eslint-disable-line @typescript-eslint/no-explicit-any + const { queryType, queryValue, queryOptions } = locator; let element; - let testplaneCode = ""; let queryDescription = ""; + let testplaneCode = ""; - if (queryType && queryValue) { - const { - getByRole, - getByText, - getByLabelText, - getByPlaceholderText, - getByDisplayValue, - getByAltText, - getByTitle, - getByTestId, - } = setupBrowser(browser as any); // eslint-disable-line @typescript-eslint/no-explicit-any - + try { switch (queryType) { case "role": - element = await getByRole(queryValue, queryOptions); + element = await queryByRole(queryValue, queryOptions); queryDescription = `role "${queryValue}"${queryOptions?.name ? ` with name "${queryOptions.name}"` : ""}`; - testplaneCode = `const element = await browser.getByRole("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""});\n${actionCode}`; + testplaneCode = `await browser.findByRole("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""})`; break; case "text": - element = await getByText(queryValue, queryOptions); + element = await queryByText(queryValue, queryOptions); queryDescription = `text "${queryValue}"`; - testplaneCode = `const element = await browser.getByText("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""});\n${actionCode}`; + testplaneCode = `await browser.findByText("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""})`; break; case "labelText": - element = await getByLabelText(queryValue, queryOptions); + element = await queryByLabelText(queryValue, queryOptions); queryDescription = `label text "${queryValue}"`; - testplaneCode = `const element = await browser.getByLabelText("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""});\n${actionCode}`; + testplaneCode = `await browser.findByLabelText("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""})`; break; case "placeholderText": - element = await getByPlaceholderText(queryValue, queryOptions); + element = await queryByPlaceholderText(queryValue, queryOptions); queryDescription = `placeholder text "${queryValue}"`; - testplaneCode = `const element = await browser.getByPlaceholderText("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""});\n${actionCode}`; + testplaneCode = `await browser.findByPlaceholderText("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""})`; break; case "displayValue": - element = await getByDisplayValue(queryValue, queryOptions); + element = await queryByDisplayValue(queryValue, queryOptions); queryDescription = `display value "${queryValue}"`; - testplaneCode = `const element = await browser.getByDisplayValue("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""});\n${actionCode}`; + testplaneCode = `await browser.findByDisplayValue("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""})`; break; case "altText": - element = await getByAltText(queryValue, queryOptions); + element = await queryByAltText(queryValue, queryOptions); queryDescription = `alt text "${queryValue}"`; - testplaneCode = `const element = await browser.getByAltText("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""});\n${actionCode}`; + testplaneCode = `await browser.findByAltText("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""})`; break; case "title": - element = await getByTitle(queryValue, queryOptions); + element = await queryByTitle(queryValue, queryOptions); queryDescription = `title "${queryValue}"`; - testplaneCode = `const element = await browser.getByTitle("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""});\n${actionCode}`; + testplaneCode = `await browser.findByTitle("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""})`; break; case "testId": - element = await getByTestId(queryValue, queryOptions); + element = await queryByTestId(queryValue, queryOptions); queryDescription = `test ID "${queryValue}"`; - testplaneCode = `const element = await browser.getByTestId("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""});\n${actionCode}`; + testplaneCode = `await browser.findByTestId("${queryValue}"${queryOptions ? `, ${JSON.stringify(queryOptions)}` : ""})`; break; default: throw new Error(`Unsupported queryType: ${queryType}`); } - testplaneCode += `\n// IMPORTANT for AI agents: if user asks to write tests, prefer to use semantic queries over CSS selectors, if user tests also use testing-library queries.`; - } else if (selector) { - element = await browser.$(selector); - queryDescription = `CSS selector "${selector}"`; - testplaneCode = `const element = await browser.$("${selector}");\n${actionCode}`; - } else { - throw new Error("No valid element selection method provided"); - } - - if (!element) { - throw new Error("Element not found"); + } catch (e) { + if (e instanceof Error && e.message.includes("Found multiple elements")) { + throw new Error(e.message.split("\n")[0]); + } + throw e; } return { @@ -151,3 +171,22 @@ export async function findElement( testplaneCode, }; } + +export async function findElement(browser: WdioBrowser, locator: ElementSelectorArgs): Promise { + let result; + if (locator.strategy === LocatorStrategy.TestingLibrary) { + result = await findElementByTestingLibraryQuery(browser, locator); + } else if (locator.strategy === LocatorStrategy.Wdio) { + result = await findElementByWdioSelector(browser, locator); + } else { + throw new Error( + `Provided locator.strategy is not supported. Please pass either ${LocatorStrategy.Wdio} or ${LocatorStrategy.TestingLibrary} as locator.strategy.`, + ); + } + + if (!result.element) { + throw new Error(`Unable to find element with ${result.queryDescription}`); + } + + return result as ElementResult; +} diff --git a/src/tools/wait-for-element.ts b/src/tools/wait-for-element.ts new file mode 100644 index 0000000..cd196fe --- /dev/null +++ b/src/tools/wait-for-element.ts @@ -0,0 +1,118 @@ +import { z } from "zod"; +import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ToolDefinition } from "../types.js"; +import { contextProvider } from "../context-provider.js"; +import { createSimpleResponse, createErrorResponse, createBrowserStateResponse } from "../responses/index.js"; +import { + elementSelectorSchema, + findElementByTestingLibraryQuery, + findElementByWdioSelector, + LocatorStrategy, +} from "./utils/element-selector.js"; + +export const waitForElementSchema = { + ...elementSelectorSchema, + disappear: z + .boolean() + .optional() + .default(false) + .describe("Whether to wait for element to disappear. Default: false (wait for element to appear)"), + timeout: z.number().optional().default(3000).describe("Maximum time to wait in milliseconds. Default: 3000"), + includeSnapshotInResponse: z + .boolean() + .optional() + .default(true) + .describe("Whether to include page snapshot in response. Default: true"), +}; + +const waitForElementCb: ToolCallback = async args => { + try { + const context = contextProvider.getContext(); + const browser = await context.browser.get(); + + const { disappear = false, timeout, includeSnapshotInResponse = true } = args; + + const waitOptions: { reverse?: boolean; timeout?: number } = { reverse: disappear }; + + if (timeout !== undefined) { + waitOptions.timeout = timeout; + } + + const actionDescription = disappear ? "disappear" : "appear"; + let queryDescription = ""; + let testplaneCode = ""; + + if (args.locator.strategy === LocatorStrategy.Wdio) { + const result = await findElementByWdioSelector(browser, args.locator); + + await result.element.waitForDisplayed(waitOptions); + + console.error(`Element with ${result.queryDescription} ${actionDescription}ed successfully`); + + queryDescription = result.queryDescription; + testplaneCode = `await ${result.testplaneCode}.waitForDisplayed(${Object.keys(waitOptions).length > 0 ? JSON.stringify(waitOptions) : ""});`; + } else if (args.locator.strategy === LocatorStrategy.TestingLibrary) { + const testingLibraryLocator = args.locator; + + await browser.waitUntil( + async () => { + const result = await findElementByTestingLibraryQuery(browser, testingLibraryLocator); + queryDescription = result.queryDescription; + + return (await result.element?.isDisplayed()) === !disappear; + }, + { timeoutMsg: `Timeout waiting for element to ${actionDescription}`, ...waitOptions }, + ); + + console.error(`Element with ${queryDescription} ${actionDescription}ed successfully`); + const queryName = `queryBy${testingLibraryLocator.queryType.charAt(0).toUpperCase() + testingLibraryLocator.queryType.slice(1)}`; + testplaneCode = `await browser.waitUntil(async () => { + const result = await browser.${queryName}("${testingLibraryLocator.queryValue}"${testingLibraryLocator.queryOptions ? `, ${JSON.stringify(testingLibraryLocator.queryOptions)}` : ""}); + return await result.isDisplayed() === ${!disappear}; +}, ${waitOptions.timeout ? `{ timeout: ${waitOptions.timeout} }` : ""});`; + } + const successMessage = `Successfully waited for element found by ${queryDescription} to ${actionDescription}`; + + if (includeSnapshotInResponse) { + return await createBrowserStateResponse(browser, { + action: successMessage, + testplaneCode, + }); + } else { + return createSimpleResponse( + `✅ ${successMessage}\n\n## Testplane Code\n\`\`\`javascript\n${testplaneCode}\n\`\`\``, + ); + } + } catch (error) { + console.error("Error waiting for element:", error); + + if (error instanceof Error && error.message.includes("Unable to find")) { + return createErrorResponse( + `Element not found by provided locator:\n${JSON.stringify(args.locator, null, 2)}.\nTry using a different query strategy or check if the element exists on the page.`, + ); + } + + if ( + error instanceof Error && + (error.message.includes("timeout") || + error.message.includes("still not displayed") || + error.message.includes("still displayed")) + ) { + const actionDescription = args.disappear ? "disappear" : "appear"; + return createErrorResponse( + `Timeout waiting for element to ${actionDescription}. Consider increasing the timeout value or checking if the element behavior is as expected.`, + ); + } + + return createErrorResponse( + `Error waiting for element with locator:\n${JSON.stringify(args.locator, null, 2)}.\nError message: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +}; + +export const waitForElement: ToolDefinition = { + name: "waitForElement", + description: `Wait for an element to appear or disappear on the page. Useful for waiting until page loads fully or loading spinners disappear.`, + schema: waitForElementSchema, + cb: waitForElementCb, +}; diff --git a/test/playground/slow-loading.html b/test/playground/slow-loading.html new file mode 100644 index 0000000..6008a2a --- /dev/null +++ b/test/playground/slow-loading.html @@ -0,0 +1,323 @@ + + + + + + Slow Loading Test Playground + + + + Slow Loading Test Playground + This page simulates content loading at different speeds for testing the waitForElement tool. + + + + Immediate Loading (0ms) + + Immediate Content + This content loads immediately when the page loads. + Immediate Button + ✓ Loaded immediately + + + + + + Medium Loading (3 seconds) + + + Loading content in 3 seconds... + + + Medium Speed Content + This content appears after 3 seconds. + Medium Button + ✓ Loaded after 3 seconds + + + + + + Slow Loading (5 seconds) + + + Loading content in 5 seconds... + + + Slow Loading Content + This content appears after 5 seconds. + Slow Button + ✓ Loaded after 5 seconds + + + + + + Very Slow Loading (10 seconds) + + + Loading content in 10 seconds... + + + Very Slow Loading Content + This content appears after 10 seconds. + Very Slow Button + ✓ Loaded after 10 seconds + + + + + + Disappearing Content + + Temporary Content + This content will disappear after 3 seconds. + Temporary Button + Will disappear soon... + + + Content has disappeared! + + + + + + Dynamic Content Controls + Show Dynamic Content + Hide Dynamic Content + Reset All Loading States + + + Dynamic Content + This content can be shown/hidden on demand. + Dynamic Button + ✓ Dynamic content is visible + + + + + + Error Simulation + + + Attempting to load content... (will fail after 4 seconds) + + + Error State + ❌ Failed to load content! + Error Button + ✗ Loading failed + + + + + + Loading Status + + Page loaded - immediate content available + + + + + + diff --git a/test/tools/click-on-element.test.ts b/test/tools/click-on-element.test.ts index 8683aca..e827a64 100644 --- a/test/tools/click-on-element.test.ts +++ b/test/tools/click-on-element.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from import { startClient } from "../utils"; import { INTEGRATION_TEST_TIMEOUT } from "../constants"; import { PlaygroundServer } from "../test-server"; +import { LocatorStrategy } from "../../src/tools/utils/element-selector"; describe( "tools/clickOnElement", @@ -48,9 +49,12 @@ describe( const result = await client.callTool({ name: "clickOnElement", arguments: { - queryType: "role", - queryValue: "button", - queryOptions: { name: "Submit Form" }, + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "role", + queryValue: "button", + queryOptions: { name: "Submit Form" }, + }, }, }); @@ -64,7 +68,10 @@ describe( const result = await client.callTool({ name: "clickOnElement", arguments: { - selector: "#unique-element", + locator: { + strategy: LocatorStrategy.Wdio, + selector: "#unique-element", + }, }, }); @@ -78,16 +85,20 @@ describe( const result = await client.callTool({ name: "clickOnElement", arguments: { - queryType: "role", - queryValue: "button", - queryOptions: { name: "Submit Form" }, + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "role", + queryValue: "button", + queryOptions: { name: "Submit Form" }, + }, }, }); expect(result.isError).toBe(false); const content = result.content as Array<{ type: string; text: string }>; - expect(content[0].text).toContain('browser.getByRole("button"'); - expect(content[0].text).toContain("await element.click();"); + expect(content[0].text).toContain( + 'await (await browser.findByRole("button", {"name":"Submit Form"})).click()', + ); }); }); @@ -96,9 +107,12 @@ describe( const result = await client.callTool({ name: "clickOnElement", arguments: { - queryType: "role", - queryValue: "button", - queryOptions: { name: "Non-existent Button" }, + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "role", + queryValue: "button", + queryOptions: { name: "Non-existent Button" }, + }, }, }); diff --git a/test/tools/navigate.test.ts b/test/tools/navigate.test.ts index 9f22a07..9bd8416 100644 --- a/test/tools/navigate.test.ts +++ b/test/tools/navigate.test.ts @@ -44,7 +44,7 @@ describe( expect(content[0].type).toBe("text"); expect(content[0].text).toContain(`Successfully navigated to ${url}`); expect(content[0].text).toContain("## Testplane Code"); - expect(content[0].text).toContain(`await browser.url("${url}");`); + expect(content[0].text).toContain(`await browser.openAndWait("${url}");`); }); it("should include browser state information in response", async () => { diff --git a/test/tools/type-into-element.test.ts b/test/tools/type-into-element.test.ts index f934055..bd6e95c 100644 --- a/test/tools/type-into-element.test.ts +++ b/test/tools/type-into-element.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from import { startClient } from "../utils"; import { INTEGRATION_TEST_TIMEOUT } from "../constants"; import { PlaygroundServer } from "../test-server"; +import { LocatorStrategy } from "../../src/tools/utils/element-selector"; describe( "tools/typeIntoElement", @@ -48,8 +49,11 @@ describe( const result = await client.callTool({ name: "typeIntoElement", arguments: { - queryType: "labelText", - queryValue: "Email Address", + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "labelText", + queryValue: "Email Address", + }, text: "test@example.com", }, }); @@ -65,7 +69,10 @@ describe( const result = await client.callTool({ name: "typeIntoElement", arguments: { - selector: "#username", + locator: { + strategy: LocatorStrategy.Wdio, + selector: "#username", + }, text: "john_doe", }, }); @@ -80,16 +87,20 @@ describe( const result = await client.callTool({ name: "typeIntoElement", arguments: { - queryType: "placeholderText", - queryValue: "Enter your name", + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "placeholderText", + queryValue: "Enter your name", + }, text: "John Smith", }, }); expect(result.isError).toBe(false); const content = result.content as Array<{ type: string; text: string }>; - expect(content[0].text).toContain('browser.getByPlaceholderText("Enter your name"'); - expect(content[0].text).toContain('await element.setValue("John Smith");'); + expect(content[0].text).toContain( + 'await (await browser.findByPlaceholderText("Enter your name")).setValue("John Smith")', + ); }); }); @@ -98,8 +109,11 @@ describe( const result = await client.callTool({ name: "typeIntoElement", arguments: { - queryType: "labelText", - queryValue: "Non-existent Field", + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "labelText", + queryValue: "Non-existent Field", + }, text: "test", }, }); diff --git a/test/tools/utils/element-selector.test.ts b/test/tools/utils/element-selector.test.ts index 865483f..6ea5004 100644 --- a/test/tools/utils/element-selector.test.ts +++ b/test/tools/utils/element-selector.test.ts @@ -2,7 +2,7 @@ import { WdioBrowser } from "testplane"; import { launchBrowser } from "testplane/unstable"; import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; -import { findElement } from "../../../src/tools/utils/element-selector"; +import { findElement, LocatorStrategy } from "../../../src/tools/utils/element-selector"; import { PlaygroundServer } from "../../test-server"; describe("tools/utils/element-selector", () => { @@ -39,246 +39,197 @@ describe("tools/utils/element-selector", () => { describe("semantic queries", () => { describe("getByRole queries", () => { it("should find button by role with name", async () => { - const result = await findElement( - browser, - { - queryType: "role", - queryValue: "button", - queryOptions: { name: "Submit Form" }, - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "role", + queryValue: "button", + queryOptions: { name: "Submit Form" }, + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('role "button" with name "Submit Form"'); - expect(result.testplaneCode).toContain('browser.getByRole("button"'); - expect(result.testplaneCode).toContain('{"name":"Submit Form"}'); - expect(result.testplaneCode).toContain("await element.click();"); + expect(result.testplaneCode).toContain('await browser.findByRole("button", {"name":"Submit Form"})'); }); it("should find link by role with name", async () => { - const result = await findElement( - browser, - { - queryType: "role", - queryValue: "link", - queryOptions: { name: "Home" }, - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "role", + queryValue: "link", + queryOptions: { name: "Home" }, + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('role "link" with name "Home"'); - expect(result.testplaneCode).toContain('browser.getByRole("link"'); + expect(result.testplaneCode).toContain('browser.findByRole("link"'); }); it("should find heading by role with level", async () => { - const result = await findElement( - browser, - { - queryType: "role", - queryValue: "heading", - queryOptions: { level: 3, name: "Click this heading" }, - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "role", + queryValue: "heading", + queryOptions: { level: 3, name: "Click this heading" }, + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('role "heading" with name "Click this heading"'); - expect(result.testplaneCode).toContain('browser.getByRole("heading"'); - expect(result.testplaneCode).toContain('"level":3'); + expect(result.testplaneCode).toContain( + 'await browser.findByRole("heading", {"level":3,"name":"Click this heading"})', + ); }); - it("should handle role without options", async () => { - const result = await findElement( - browser, - { + it("should throw error with short message when multiple elements are found", async () => { + await expect( + findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, queryType: "role", queryValue: "button", - queryOptions: { name: "Submit Form" }, - }, - "await element.click();", - ); - - expect(result.element).toBeDefined(); - expect(result.queryDescription).toBe('role "button" with name "Submit Form"'); - expect(result.testplaneCode).toContain('browser.getByRole("button"'); - expect(result.testplaneCode).toContain('{"name":"Submit Form"}'); + }), + ).rejects.toThrow('Found multiple elements with the role "button"'); }); }); describe("getByText queries", () => { it("should find element by exact text content", async () => { - const result = await findElement( - browser, - { - queryType: "text", - queryValue: "Click here to test text selection", - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "text", + queryValue: "Click here to test text selection", + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('text "Click here to test text selection"'); - expect(result.testplaneCode).toContain('browser.getByText("Click here to test text selection")'); + expect(result.testplaneCode).toContain('await browser.findByText("Click here to test text selection")'); }); it("should find element by partial text with exact: false", async () => { - const result = await findElement( - browser, - { - queryType: "text", - queryValue: "Download", - queryOptions: { exact: false }, - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "text", + queryValue: "Download", + queryOptions: { exact: false }, + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('text "Download"'); - expect(result.testplaneCode).toContain('browser.getByText("Download"'); - expect(result.testplaneCode).toContain('"exact":false'); + expect(result.testplaneCode).toContain('await browser.findByText("Download", {"exact":false})'); }); }); describe("getByLabelText queries", () => { it("should find input by label text", async () => { - const result = await findElement( - browser, - { - queryType: "labelText", - queryValue: "Email Address", - }, - "await element.setValue('test');", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "labelText", + queryValue: "Email Address", + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('label text "Email Address"'); - expect(result.testplaneCode).toContain('browser.getByLabelText("Email Address")'); + expect(result.testplaneCode).toContain('await browser.findByLabelText("Email Address")'); }); it("should find textarea by label text", async () => { - const result = await findElement( - browser, - { - queryType: "labelText", - queryValue: "Message", - }, - "await element.setValue('test');", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "labelText", + queryValue: "Message", + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('label text "Message"'); - expect(result.testplaneCode).toContain('browser.getByLabelText("Message")'); + expect(result.testplaneCode).toContain('await browser.findByLabelText("Message")'); }); }); describe("getByPlaceholderText queries", () => { it("should find input by placeholder text", async () => { - const result = await findElement( - browser, - { - queryType: "placeholderText", - queryValue: "Enter your name", - }, - "await element.setValue('test');", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "placeholderText", + queryValue: "Enter your name", + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('placeholder text "Enter your name"'); - expect(result.testplaneCode).toContain('browser.getByPlaceholderText("Enter your name")'); + expect(result.testplaneCode).toContain('await browser.findByPlaceholderText("Enter your name")'); }); it("should find textarea by placeholder text", async () => { - const result = await findElement( - browser, - { - queryType: "placeholderText", - queryValue: "Type your feedback here...", - }, - "await element.setValue('test');", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "placeholderText", + queryValue: "Type your feedback here...", + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('placeholder text "Type your feedback here..."'); - expect(result.testplaneCode).toContain('browser.getByPlaceholderText("Type your feedback here...")'); + expect(result.testplaneCode).toContain( + 'await browser.findByPlaceholderText("Type your feedback here...")', + ); }); }); describe("getByAltText queries", () => { it("should find image by alt text", async () => { - const result = await findElement( - browser, - { - queryType: "altText", - queryValue: "Company Logo", - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "altText", + queryValue: "Company Logo", + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('alt text "Company Logo"'); - expect(result.testplaneCode).toContain('browser.getByAltText("Company Logo")'); + expect(result.testplaneCode).toContain('await browser.findByAltText("Company Logo")'); }); it("should find another image by alt text", async () => { - const result = await findElement( - browser, - { - queryType: "altText", - queryValue: "Success icon", - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "altText", + queryValue: "Success icon", + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('alt text "Success icon"'); - expect(result.testplaneCode).toContain('browser.getByAltText("Success icon")'); + expect(result.testplaneCode).toContain('await browser.findByAltText("Success icon")'); }); }); describe("getByTestId queries", () => { it("should find element by test id", async () => { - const result = await findElement( - browser, - { - queryType: "testId", - queryValue: "action-button", - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "testId", + queryValue: "action-button", + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('test ID "action-button"'); - expect(result.testplaneCode).toContain('browser.getByTestId("action-button")'); + expect(result.testplaneCode).toContain('await browser.findByTestId("action-button")'); }); it("should find container by test id", async () => { - const result = await findElement( - browser, - { - queryType: "testId", - queryValue: "widget-container", - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "testId", + queryValue: "widget-container", + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('test ID "widget-container"'); - expect(result.testplaneCode).toContain('browser.getByTestId("widget-container")'); + expect(result.testplaneCode).toContain('await browser.findByTestId("widget-container")'); }); }); }); describe("CSS selector fallback", () => { it("should find element by CSS class selector", async () => { - const result = await findElement( - browser, - { - selector: ".custom-class-btn", - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.Wdio, + selector: ".custom-class-btn", + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('CSS selector ".custom-class-btn"'); @@ -286,13 +237,10 @@ describe("tools/utils/element-selector", () => { }); it("should find element by ID selector", async () => { - const result = await findElement( - browser, - { - selector: "#unique-element", - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.Wdio, + selector: "#unique-element", + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('CSS selector "#unique-element"'); @@ -300,13 +248,10 @@ describe("tools/utils/element-selector", () => { }); it("should find element by complex CSS selector", async () => { - const result = await findElement( - browser, - { - selector: "button.success-btn", - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.Wdio, + selector: "button.success-btn", + }); expect(result.element).toBeDefined(); expect(result.queryDescription).toBe('CSS selector "button.success-btn"'); @@ -315,48 +260,40 @@ describe("tools/utils/element-selector", () => { }); describe("error handling", () => { - it("should reject when both semantic query and selector are provided", async () => { + it("should reject when invalid strategy is provided", async () => { await expect( findElement( browser, { + strategy: "invalid-strategy", queryType: "role", queryValue: "button", - selector: "#some-button", - }, - "await element.click();", + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any ), - ).rejects.toThrow("Provide EITHER semantic query"); + ).rejects.toThrow(/Provided locator.strategy is not supported/); }); it("should reject when neither semantic query nor selector is provided", async () => { - await expect(findElement(browser, {}, "await element.click();")).rejects.toThrow( - "Provide either semantic query", - ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await expect(findElement(browser, {} as any)).rejects.toThrow(/Provided locator.strategy is not supported/); }); it("should handle element not found gracefully", async () => { await expect( - findElement( - browser, - { - queryType: "role", - queryValue: "button", - queryOptions: { name: "Non-existent Button" }, - }, - "await element.click();", - ), - ).rejects.toThrow("Unable to find an accessible element"); + findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "role", + queryValue: "button", + queryOptions: { name: "Non-existent Button" }, + }), + ).rejects.toThrow("Unable to find element"); }); it("should handle invalid CSS selector gracefully", async () => { - const result = await findElement( - browser, - { - selector: ".non-existent-class", - }, - "await element.click();", - ); + const result = await findElement(browser, { + strategy: LocatorStrategy.Wdio, + selector: ".non-existent-class", + }); expect(result.element).toBeDefined(); expect((result.element as any).error).toBeDefined(); // eslint-disable-line @typescript-eslint/no-explicit-any @@ -365,14 +302,11 @@ describe("tools/utils/element-selector", () => { it("should reject unsupported queryType", async () => { await expect( - findElement( - browser, - { - queryType: "invalidType" as "role", - queryValue: "button", - }, - "await element.click();", - ), + findElement(browser, { + strategy: LocatorStrategy.TestingLibrary, + queryType: "invalidType" as "role", + queryValue: "button", + }), ).rejects.toThrow("Unsupported queryType"); }); }); diff --git a/test/tools/wait-for-element.test.ts b/test/tools/wait-for-element.test.ts new file mode 100644 index 0000000..17569cb --- /dev/null +++ b/test/tools/wait-for-element.test.ts @@ -0,0 +1,295 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; +import { startClient } from "../utils"; +import { INTEGRATION_TEST_TIMEOUT } from "../constants"; +import { PlaygroundServer } from "../test-server"; +import { LocatorStrategy } from "../../src/tools/utils/element-selector"; + +describe( + "tools/waitForElement", + () => { + let client: Client; + let slowLoadingUrl: string; + let testServer: PlaygroundServer; + + beforeAll(async () => { + testServer = new PlaygroundServer(); + const baseUrl = await testServer.start(); + slowLoadingUrl = `${baseUrl}/slow-loading.html`; + }, 20000); + + afterAll(async () => { + if (testServer) { + await testServer.stop(); + } + }); + + let navigateResponse: string; + + beforeEach(async () => { + client = await startClient(); + const navigateResult = await client.callTool({ name: "navigate", arguments: { url: slowLoadingUrl } }); + navigateResponse = (navigateResult.content as Array<{ type: string; text: string }>)[0].text; + }); + + afterEach(async () => { + if (client) { + await client.close(); + } + }); + + describe("tool availability", () => { + it("should list waitForElement tool in available tools", async () => { + const tools = await client.listTools(); + + const waitForElementTool = tools.tools.find(tool => tool.name === "waitForElement"); + + expect(waitForElementTool).toBeDefined(); + }); + }); + + describe("waiting for elements to appear", () => { + it("should handle already visible elements", async () => { + const result = await client.callTool({ + name: "waitForElement", + arguments: { + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "testId", + queryValue: "immediate-btn", + }, + }, + }); + + expect(result.isError).toBe(false); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain("Successfully waited for element"); + expect(content[0].text).toContain("[data-testid=immediate-btn]"); + }); + + it("should wait for content to appear using CSS selector", async () => { + expect(navigateResponse).not.toContain("[data-testid=medium-btn]"); + + const result = await client.callTool({ + name: "waitForElement", + arguments: { + locator: { + strategy: LocatorStrategy.Wdio, + selector: "#medium-button", + }, + timeout: 5000, + }, + }); + + expect(result.isError).toBe(false); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain("Successfully waited for element"); + expect(content[0].text).toContain("to appear"); + expect(content[0].text).toContain("#medium-button"); + }); + + it("should wait for content to appear using testing-library testId query", async () => { + expect(navigateResponse).not.toContain("[data-testid=medium-btn]"); + + const result = await client.callTool({ + name: "waitForElement", + arguments: { + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "testId", + queryValue: "medium-btn", + }, + timeout: 5000, + }, + }); + + expect(result.isError).toBe(false); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain("Successfully waited for element"); + expect(content[0].text).toContain("to appear"); + expect(content[0].text).toContain('await browser.queryByTestId("medium-btn")'); + expect(content[0].text).toContain("[data-testid=medium-btn]"); + }); + }); + + describe("waiting for elements to disappear", () => { + it("should wait for disappearing content using CSS selector", async () => { + expect(navigateResponse).toContain("[data-testid=disappearing-btn]"); + + const result = await client.callTool({ + name: "waitForElement", + arguments: { + locator: { + strategy: LocatorStrategy.Wdio, + selector: "#disappearing-content", + }, + disappear: true, + timeout: 5000, + }, + }); + + expect(result.isError).toBe(false); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain("Successfully waited for element"); + expect(content[0].text).toContain("to disappear"); + expect(content[0].text).not.toContain("[data-testid=disappearing-btn]"); + }); + + it("should wait for disappearing content using testId", async () => { + expect(navigateResponse).toContain("[data-testid=disappearing-btn]"); + + const result = await client.callTool({ + name: "waitForElement", + arguments: { + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "testId", + queryValue: "disappearing-btn", + }, + disappear: true, + timeout: 5000, + }, + }); + + expect(result.isError).toBe(false); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain("Successfully waited for element"); + expect(content[0].text).toContain("to disappear"); + expect(content[0].text).toContain('await browser.queryByTestId("disappearing-btn")'); + expect(content[0].text).not.toContain("[data-testid=disappearing-btn]"); + }); + + it("should wait for text content to disappear", async () => { + expect(navigateResponse).toContain('p "This content will disappear after 3 seconds'); + + const result = await client.callTool({ + name: "waitForElement", + arguments: { + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "text", + queryValue: "This content will disappear after 3 seconds", + queryOptions: { exact: false }, + }, + disappear: true, + timeout: 5000, + }, + }); + + expect(result.isError).toBe(false); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain("Successfully waited for element"); + expect(content[0].text).toContain("to disappear"); + expect(content[0].text).toContain( + 'await browser.queryByText("This content will disappear after 3 seconds", {"exact":false})', + ); + expect(content[0].text).toContain('p[@hidden] "This content will disappear after 3 seconds'); + }); + }); + + describe("timeout behavior", () => { + it("should respect custom timeout values for appearing elements when using wdio selector", async () => { + const startTime = Date.now(); + + const result = await client.callTool({ + name: "waitForElement", + arguments: { + locator: { + strategy: LocatorStrategy.Wdio, + selector: "#never-appears", + }, + disappear: false, + timeout: 1500, + }, + }); + + const elapsedTime = Date.now() - startTime; + + expect(result.isError).toBe(true); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain("Timeout waiting for element to appear"); + + expect(elapsedTime).toBeGreaterThan(1400); + expect(elapsedTime).toBeLessThan(2500); + }); + + it("should respect custom timeout values for appearing elements when using testing-library query", async () => { + const startTime = Date.now(); + + const result = await client.callTool({ + name: "waitForElement", + arguments: { + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "testId", + queryValue: "never-appears", + }, + disappear: false, + timeout: 1500, + }, + }); + + const elapsedTime = Date.now() - startTime; + + expect(result.isError).toBe(true); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain("Timeout waiting for element to appear"); + + expect(elapsedTime).toBeGreaterThan(1400); + expect(elapsedTime).toBeLessThan(2500); + }); + + it("should handle timeout when waiting for element to disappear", async () => { + const startTime = Date.now(); + + const result = await client.callTool({ + name: "waitForElement", + arguments: { + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "testId", + queryValue: "immediate-btn", + }, + disappear: true, + timeout: 1000, + }, + }); + + const elapsedTime = Date.now() - startTime; + + expect(result.isError).toBe(true); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain("Timeout waiting for element to disappear"); + + expect(elapsedTime).toBeGreaterThan(900); + expect(elapsedTime).toBeLessThan(1500); + }); + }); + + describe("configuration options", () => { + it("should not include page snapshot when includeSnapshotInResponse is false", async () => { + const result = await client.callTool({ + name: "waitForElement", + arguments: { + locator: { + strategy: LocatorStrategy.TestingLibrary, + queryType: "testId", + queryValue: "immediate-btn", + }, + disappear: false, + timeout: 1000, + includeSnapshotInResponse: false, + }, + }); + + expect(result.isError).toBe(false); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toContain("Successfully waited for element"); + + expect(content[0].text).not.toContain("Current Tab Snapshot"); + expect(content[0].text).not.toContain("```yaml"); + }); + }); + }, + INTEGRATION_TEST_TIMEOUT, +);
This page simulates content loading at different speeds for testing the waitForElement tool.
This content loads immediately when the page loads.
This content appears after 3 seconds.
This content appears after 5 seconds.
This content appears after 10 seconds.
This content will disappear after 3 seconds.
Content has disappeared!
This content can be shown/hidden on demand.
❌ Failed to load content!