Skip to content

Commit 5c2bdd4

Browse files
authored
Merge pull request #2 from duplojs/feat/1
feat(1): support selector on multi element
2 parents 3dfeded + d8f83c5 commit 5c2bdd4

3 files changed

Lines changed: 150 additions & 13 deletions

File tree

integration/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ testClient.describe("integration", () => {
7676
const home = await website.iNavigateTo(homePage);
7777
await website.iWantToBeOnPage(homePage);
7878
await website.iExpectTitleIs("Integration playground");
79-
await Assertions.toHaveText(home, "title", "Welcome to the demo site");
79+
await Assertions.toHaveText(home, ["title", "first"], "Welcome to the demo site");
8080
await website.waitForHydration();
8181

8282
const newsletter = await home.iWantToSeeComponent("newsletter");

scripts/componentInteraction.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
import test, { type Locator as PlaywrightLocator } from "playwright/test";
33
import type { Component, ComponentElements } from "./component";
4-
import { kindHeritage } from "@duplojs/utils";
4+
import { justExec, kindHeritage } from "@duplojs/utils";
55
import { createDuplojsPlaywrightKind } from "./kind";
66

77
interface ContextStepEmbedded {
@@ -18,6 +18,13 @@ interface MissingComponentElementErrorParams {
1818
availableElements: string[];
1919
}
2020

21+
export type ElementsSelector<
22+
GenericElementKey extends string,
23+
> = [
24+
element: GenericElementKey,
25+
target: number | "first" | "last",
26+
];
27+
2128
const missingComponentElementErrorKind = createDuplojsPlaywrightKind("missing-component-element-error");
2229

2330
export class MissingComponentElementError extends kindHeritage(
@@ -53,26 +60,43 @@ export function createComponentInteraction<
5360
) {
5461
return <
5562
GenericComponent extends Component<string, Record<string, PlaywrightLocator>, any, any>,
56-
GenericElementKey extends Extract<keyof GenericComponent["elements"], string>,
63+
GenericElementKey extends (
64+
| Extract<keyof GenericComponent["elements"], string>
65+
| ElementsSelector<Extract<keyof GenericComponent["elements"], string>>
66+
),
5767
>(
5868
component: GenericComponent,
59-
elementKey: GenericElementKey,
69+
elementSelector: GenericElementKey,
6070
...args: Parameters<GenericStepEmbeddedFunction> extends [any, ...infer InferredRest] ? InferredRest : never
6171
) => {
62-
const element = component.elements?.[elementKey];
72+
const [elementKey, elementDesignation] = typeof elementSelector === "string"
73+
? [elementSelector, elementSelector]
74+
: [elementSelector[0], `${elementSelector[0]}::${elementSelector[1]}`];
75+
76+
const element = justExec(() => {
77+
const selectedElement = component.elements?.[elementKey];
6378

64-
if (!element) {
65-
throw new MissingComponentElementError({
66-
componentName: component.name,
67-
elementKey: elementKey.toString(),
68-
availableElements: Object.keys(component.elements ?? {}),
69-
});
70-
}
79+
if (!selectedElement) {
80+
throw new MissingComponentElementError({
81+
componentName: component.name,
82+
elementKey: elementKey.toString(),
83+
availableElements: Object.keys(component.elements ?? {}),
84+
});
85+
} else if (typeof elementSelector === "string") {
86+
return selectedElement;
87+
} else if (elementSelector[1] === "first") {
88+
return selectedElement.first();
89+
} else if (elementSelector[1] === "last") {
90+
return selectedElement.last();
91+
} else {
92+
return selectedElement.nth(elementSelector[1]);
93+
}
94+
});
7195

7296
return test.step(
7397
stepName
7498
.replace("$component", component.name)
75-
.replace("$element", elementKey.toString()),
99+
.replace("$element", elementDesignation),
76100
() => step(
77101
{
78102
element,

tests/componentInteraction.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,102 @@ describe("createComponentInteraction", () => {
3838
);
3939
});
4040

41+
it("uses the first matching element", async() => {
42+
const firstElement = { click: vi.fn().mockResolvedValue("first-clicked") };
43+
const element = {
44+
first: vi.fn(() => firstElement),
45+
last: vi.fn(),
46+
nth: vi.fn(),
47+
};
48+
const interaction = createComponentInteraction(
49+
"$component: I click on $element.",
50+
({ element }) => element.click(),
51+
);
52+
53+
const result = await interaction(
54+
{
55+
name: "search-form",
56+
elements: { submit: element },
57+
} as never,
58+
["submit", "first"],
59+
);
60+
61+
expect(result).toBe("first-clicked");
62+
expect(element.first).toHaveBeenCalledTimes(1);
63+
expect(element.last).not.toHaveBeenCalled();
64+
expect(element.nth).not.toHaveBeenCalled();
65+
expect(firstElement.click).toHaveBeenCalledTimes(1);
66+
expect(stepMock).toHaveBeenCalledWith(
67+
"search-form: I click on submit::first.",
68+
expect.any(Function),
69+
);
70+
});
71+
72+
it("uses the last matching element", async() => {
73+
const lastElement = { click: vi.fn().mockResolvedValue("last-clicked") };
74+
const element = {
75+
first: vi.fn(),
76+
last: vi.fn(() => lastElement),
77+
nth: vi.fn(),
78+
};
79+
const interaction = createComponentInteraction(
80+
"$component: I click on $element.",
81+
({ element }) => element.click(),
82+
);
83+
84+
const result = await interaction(
85+
{
86+
name: "search-form",
87+
elements: { submit: element },
88+
} as never,
89+
["submit", "last"],
90+
);
91+
92+
expect(result).toBe("last-clicked");
93+
expect(element.first).not.toHaveBeenCalled();
94+
expect(element.last).toHaveBeenCalledTimes(1);
95+
expect(element.nth).not.toHaveBeenCalled();
96+
expect(lastElement.click).toHaveBeenCalledTimes(1);
97+
expect(stepMock).toHaveBeenCalledWith(
98+
"search-form: I click on submit::last.",
99+
expect.any(Function),
100+
);
101+
});
102+
103+
it("uses the nth matching element", async() => {
104+
const nthElement = { click: vi.fn().mockResolvedValue("nth-clicked") };
105+
const element = {
106+
first: vi.fn(),
107+
last: vi.fn(),
108+
nth: vi.fn((index: number) => {
109+
expect(index).toBe(2);
110+
return nthElement;
111+
}),
112+
};
113+
const interaction = createComponentInteraction(
114+
"$component: I click on $element.",
115+
({ element }) => element.click(),
116+
);
117+
118+
const result = await interaction(
119+
{
120+
name: "search-form",
121+
elements: { submit: element },
122+
} as never,
123+
["submit", 2],
124+
);
125+
126+
expect(result).toBe("nth-clicked");
127+
expect(element.first).not.toHaveBeenCalled();
128+
expect(element.last).not.toHaveBeenCalled();
129+
expect(element.nth).toHaveBeenCalledWith(2);
130+
expect(nthElement.click).toHaveBeenCalledTimes(1);
131+
expect(stepMock).toHaveBeenCalledWith(
132+
"search-form: I click on submit::2.",
133+
expect.any(Function),
134+
);
135+
});
136+
41137
it("throws when the element is missing", () => {
42138
const interaction = createComponentInteraction(
43139
"$component: I click on $element.",
@@ -71,6 +167,23 @@ describe("createComponentInteraction", () => {
71167
"Missing element \"submit\" on component \"search-form\". Available elements: none.",
72168
);
73169
});
170+
171+
it("lists available elements when the selected element is missing", () => {
172+
const interaction = createComponentInteraction(
173+
"$component: I click on $element.",
174+
() => undefined,
175+
);
176+
177+
expect(() => (interaction as (...args: any[]) => unknown)(
178+
{
179+
name: "search-form",
180+
elements: { reset: {} },
181+
},
182+
"submit",
183+
)).toThrowError(
184+
"Missing element \"submit\" on component \"search-form\". Available elements: reset.",
185+
);
186+
});
74187
});
75188

76189
describe("createStepWrapper", () => {

0 commit comments

Comments
 (0)