diff --git a/integration/index.test.ts b/integration/index.test.ts index d88c7ca..b89fd43 100644 --- a/integration/index.test.ts +++ b/integration/index.test.ts @@ -76,7 +76,7 @@ testClient.describe("integration", () => { const home = await website.iNavigateTo(homePage); await website.iWantToBeOnPage(homePage); await website.iExpectTitleIs("Integration playground"); - await Assertions.toHaveText(home, "title", "Welcome to the demo site"); + await Assertions.toHaveText(home, ["title", "first"], "Welcome to the demo site"); await website.waitForHydration(); const newsletter = await home.iWantToSeeComponent("newsletter"); diff --git a/scripts/componentInteraction.ts b/scripts/componentInteraction.ts index 4178ac1..c604225 100644 --- a/scripts/componentInteraction.ts +++ b/scripts/componentInteraction.ts @@ -1,7 +1,7 @@ import test, { type Locator as PlaywrightLocator } from "playwright/test"; import type { Component, ComponentElements } from "./component"; -import { kindHeritage } from "@duplojs/utils"; +import { justExec, kindHeritage } from "@duplojs/utils"; import { createDuplojsPlaywrightKind } from "./kind"; interface ContextStepEmbedded { @@ -18,6 +18,13 @@ interface MissingComponentElementErrorParams { availableElements: string[]; } +export type ElementsSelector< + GenericElementKey extends string, +> = [ + element: GenericElementKey, + target: number | "first" | "last", +]; + const missingComponentElementErrorKind = createDuplojsPlaywrightKind("missing-component-element-error"); export class MissingComponentElementError extends kindHeritage( @@ -53,26 +60,43 @@ export function createComponentInteraction< ) { return < GenericComponent extends Component, any, any>, - GenericElementKey extends Extract, + GenericElementKey extends ( + | Extract + | ElementsSelector> + ), >( component: GenericComponent, - elementKey: GenericElementKey, + elementSelector: GenericElementKey, ...args: Parameters extends [any, ...infer InferredRest] ? InferredRest : never ) => { - const element = component.elements?.[elementKey]; + const [elementKey, elementDesignation] = typeof elementSelector === "string" + ? [elementSelector, elementSelector] + : [elementSelector[0], `${elementSelector[0]}::${elementSelector[1]}`]; + + const element = justExec(() => { + const selectedElement = component.elements?.[elementKey]; - if (!element) { - throw new MissingComponentElementError({ - componentName: component.name, - elementKey: elementKey.toString(), - availableElements: Object.keys(component.elements ?? {}), - }); - } + if (!selectedElement) { + throw new MissingComponentElementError({ + componentName: component.name, + elementKey: elementKey.toString(), + availableElements: Object.keys(component.elements ?? {}), + }); + } else if (typeof elementSelector === "string") { + return selectedElement; + } else if (elementSelector[1] === "first") { + return selectedElement.first(); + } else if (elementSelector[1] === "last") { + return selectedElement.last(); + } else { + return selectedElement.nth(elementSelector[1]); + } + }); return test.step( stepName .replace("$component", component.name) - .replace("$element", elementKey.toString()), + .replace("$element", elementDesignation), () => step( { element, diff --git a/tests/componentInteraction.test.ts b/tests/componentInteraction.test.ts index 8c5909e..c365d31 100644 --- a/tests/componentInteraction.test.ts +++ b/tests/componentInteraction.test.ts @@ -38,6 +38,102 @@ describe("createComponentInteraction", () => { ); }); + it("uses the first matching element", async() => { + const firstElement = { click: vi.fn().mockResolvedValue("first-clicked") }; + const element = { + first: vi.fn(() => firstElement), + last: vi.fn(), + nth: vi.fn(), + }; + const interaction = createComponentInteraction( + "$component: I click on $element.", + ({ element }) => element.click(), + ); + + const result = await interaction( + { + name: "search-form", + elements: { submit: element }, + } as never, + ["submit", "first"], + ); + + expect(result).toBe("first-clicked"); + expect(element.first).toHaveBeenCalledTimes(1); + expect(element.last).not.toHaveBeenCalled(); + expect(element.nth).not.toHaveBeenCalled(); + expect(firstElement.click).toHaveBeenCalledTimes(1); + expect(stepMock).toHaveBeenCalledWith( + "search-form: I click on submit::first.", + expect.any(Function), + ); + }); + + it("uses the last matching element", async() => { + const lastElement = { click: vi.fn().mockResolvedValue("last-clicked") }; + const element = { + first: vi.fn(), + last: vi.fn(() => lastElement), + nth: vi.fn(), + }; + const interaction = createComponentInteraction( + "$component: I click on $element.", + ({ element }) => element.click(), + ); + + const result = await interaction( + { + name: "search-form", + elements: { submit: element }, + } as never, + ["submit", "last"], + ); + + expect(result).toBe("last-clicked"); + expect(element.first).not.toHaveBeenCalled(); + expect(element.last).toHaveBeenCalledTimes(1); + expect(element.nth).not.toHaveBeenCalled(); + expect(lastElement.click).toHaveBeenCalledTimes(1); + expect(stepMock).toHaveBeenCalledWith( + "search-form: I click on submit::last.", + expect.any(Function), + ); + }); + + it("uses the nth matching element", async() => { + const nthElement = { click: vi.fn().mockResolvedValue("nth-clicked") }; + const element = { + first: vi.fn(), + last: vi.fn(), + nth: vi.fn((index: number) => { + expect(index).toBe(2); + return nthElement; + }), + }; + const interaction = createComponentInteraction( + "$component: I click on $element.", + ({ element }) => element.click(), + ); + + const result = await interaction( + { + name: "search-form", + elements: { submit: element }, + } as never, + ["submit", 2], + ); + + expect(result).toBe("nth-clicked"); + expect(element.first).not.toHaveBeenCalled(); + expect(element.last).not.toHaveBeenCalled(); + expect(element.nth).toHaveBeenCalledWith(2); + expect(nthElement.click).toHaveBeenCalledTimes(1); + expect(stepMock).toHaveBeenCalledWith( + "search-form: I click on submit::2.", + expect.any(Function), + ); + }); + it("throws when the element is missing", () => { const interaction = createComponentInteraction( "$component: I click on $element.", @@ -71,6 +167,23 @@ describe("createComponentInteraction", () => { "Missing element \"submit\" on component \"search-form\". Available elements: none.", ); }); + + it("lists available elements when the selected element is missing", () => { + const interaction = createComponentInteraction( + "$component: I click on $element.", + () => undefined, + ); + + expect(() => (interaction as (...args: any[]) => unknown)( + { + name: "search-form", + elements: { reset: {} }, + }, + "submit", + )).toThrowError( + "Missing element \"submit\" on component \"search-form\". Available elements: reset.", + ); + }); }); describe("createStepWrapper", () => {