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
2 changes: 1 addition & 1 deletion integration/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
48 changes: 36 additions & 12 deletions scripts/componentInteraction.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -53,26 +60,43 @@ export function createComponentInteraction<
) {
return <
GenericComponent extends Component<string, Record<string, PlaywrightLocator>, any, any>,
GenericElementKey extends Extract<keyof GenericComponent["elements"], string>,
GenericElementKey extends (
| Extract<keyof GenericComponent["elements"], string>
| ElementsSelector<Extract<keyof GenericComponent["elements"], string>>
),
>(
component: GenericComponent,
elementKey: GenericElementKey,
elementSelector: GenericElementKey,
...args: Parameters<GenericStepEmbeddedFunction> 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,
Expand Down
113 changes: 113 additions & 0 deletions tests/componentInteraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading