Skip to content

Commit 71a168b

Browse files
tests: make Table typesafe and decouple Table from assertions (#835)
Signed-off-by: Carlos Feria <2582866+carlosthe19916@users.noreply.github.com>
1 parent 4dd30c3 commit 71a168b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+532
-179
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { expect as baseExpect } from "@playwright/test";
2+
import type { Table, TColumnValue } from "../pages/Table";
3+
import type { MatcherResult } from "./types";
4+
5+
export interface TableMatchers<
6+
TColumn extends Record<string, TColumnValue>,
7+
_TActions extends readonly string[],
8+
TColumnName extends Extract<keyof TColumn, string>,
9+
> {
10+
toBeSortedBy(
11+
columnName: TColumnName,
12+
order: "ascending" | "descending",
13+
): Promise<MatcherResult>;
14+
toHaveColumnWithValue(
15+
columnName: TColumnName,
16+
value: string,
17+
): Promise<MatcherResult>;
18+
toHaveNumberOfRows(expectedRows: {
19+
equal?: number;
20+
greaterThan?: number;
21+
lessThan?: number;
22+
}): Promise<MatcherResult>;
23+
toHaveEmptyState(): Promise<MatcherResult>;
24+
}
25+
26+
type TableMatcherDefinitions = {
27+
readonly [K in keyof TableMatchers<
28+
Record<string, TColumnValue>,
29+
[],
30+
string
31+
>]: <
32+
TColumn extends Record<string, TColumnValue>,
33+
const TActions extends readonly string[],
34+
TColumnName extends Extract<keyof TColumn, string>,
35+
>(
36+
receiver: Table<TColumn, TActions, TColumnName>,
37+
...args: Parameters<TableMatchers<TColumn, TActions, TColumnName>[K]>
38+
) => Promise<MatcherResult>;
39+
};
40+
41+
export const tableAssertions = baseExpect.extend<TableMatcherDefinitions>({
42+
toBeSortedBy: async <
43+
TColumn extends Record<string, TColumnValue>,
44+
const TActions extends readonly string[],
45+
TColumnName extends Extract<keyof TColumn, string>,
46+
>(
47+
table: Table<TColumn, TActions, TColumnName>,
48+
columnName: TColumnName,
49+
order: "ascending" | "descending",
50+
) => {
51+
try {
52+
const columnHeader = await table.getColumnHeader(columnName);
53+
await baseExpect(columnHeader).toHaveAttribute("aria-sort", order);
54+
55+
return {
56+
pass: true,
57+
message: () => `Table is sorted by ${columnName} in ${order} order`,
58+
};
59+
} catch (error) {
60+
return {
61+
pass: false,
62+
message: () => (error instanceof Error ? error.message : String(error)),
63+
};
64+
}
65+
},
66+
toHaveColumnWithValue: async <
67+
TColumn extends Record<string, TColumnValue>,
68+
const TActions extends readonly string[],
69+
TColumnName extends Extract<keyof TColumn, string>,
70+
>(
71+
table: Table<TColumn, TActions, TColumnName>,
72+
columnName: TColumnName,
73+
value: string,
74+
) => {
75+
try {
76+
await baseExpect(
77+
table._table
78+
.locator(`td[data-label="${columnName}"]`, {
79+
hasText: value,
80+
})
81+
.first(),
82+
).toBeVisible();
83+
84+
return {
85+
pass: true,
86+
message: () => `Table contains ${value} in column ${columnName}`,
87+
};
88+
} catch (error) {
89+
return {
90+
pass: false,
91+
message: () => (error instanceof Error ? error.message : String(error)),
92+
};
93+
}
94+
},
95+
toHaveNumberOfRows: async <
96+
TColumn extends Record<string, TColumnValue>,
97+
const TActions extends readonly string[],
98+
TColumnName extends Extract<keyof TColumn, string>,
99+
>(
100+
table: Table<TColumn, TActions, TColumnName>,
101+
expectedRows: { equal?: number; greaterThan?: number; lessThan?: number },
102+
) => {
103+
try {
104+
const rows = table._table.locator(
105+
`td[data-label="${Object.keys(table._columns)[0]}"]`,
106+
);
107+
108+
if (expectedRows.equal) {
109+
await baseExpect.poll(() => rows.count()).toBe(expectedRows.equal);
110+
}
111+
if (expectedRows.greaterThan) {
112+
await baseExpect
113+
.poll(() => rows.count())
114+
.toBeGreaterThan(expectedRows.greaterThan);
115+
}
116+
if (expectedRows.lessThan) {
117+
await baseExpect
118+
.poll(() => rows.count())
119+
.toBeLessThan(expectedRows.lessThan);
120+
}
121+
122+
return {
123+
pass: true,
124+
message: () => "Table contains expected rows",
125+
};
126+
} catch (error) {
127+
return {
128+
pass: false,
129+
message: () => (error instanceof Error ? error.message : String(error)),
130+
};
131+
}
132+
},
133+
toHaveEmptyState: async <
134+
TColumn extends Record<string, TColumnValue>,
135+
const TActions extends readonly string[],
136+
TColumnName extends Extract<keyof TColumn, string>,
137+
>(
138+
table: Table<TColumn, TActions, TColumnName>,
139+
): Promise<MatcherResult> => {
140+
try {
141+
await baseExpect(
142+
table._table.locator(`tbody[aria-label="Table empty"]`),
143+
).toBeVisible();
144+
145+
return {
146+
pass: true,
147+
message: () => "Table has empty state",
148+
};
149+
} catch (error) {
150+
return {
151+
pass: false,
152+
message: () => (error instanceof Error ? error.message : String(error)),
153+
};
154+
}
155+
},
156+
});

e2e/tests/ui/assertions/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
11
import { mergeExpects } from "@playwright/test";
22

3+
import type { Table, TColumnValue } from "../pages/Table";
4+
import { tableAssertions, type TableMatchers } from "./TableMatchers";
5+
36
import type { Pagination } from "../pages/Pagination";
47
import {
58
paginationAssertions,
69
type PaginationMatchers,
710
} from "./PaginationMatchers";
811

912
const merged = mergeExpects(
13+
tableAssertions,
1014
paginationAssertions,
1115
// Add more custom assertions here
1216
);
1317

1418
// Create overloaded expect that preserves types for all custom matchers
1519

20+
/**
21+
* Overload from TableMatchers.ts
22+
*/
23+
function typedExpect<
24+
TColumn extends Record<string, TColumnValue>,
25+
const TActions extends readonly string[],
26+
TColumnName extends Extract<keyof TColumn, string>,
27+
>(
28+
value: Table<TColumn, TActions, TColumnName>,
29+
): Omit<
30+
ReturnType<typeof merged<Table<TColumn, TActions, TColumnName>>>,
31+
keyof TableMatchers<TColumn, TActions, TColumnName>
32+
> &
33+
TableMatchers<TColumn, TActions, TColumnName>;
34+
1635
/**
1736
* Overload from PaginationMatchers.ts
1837
*/

e2e/tests/ui/features/@sbom-explorer/sbom-explorer.step.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { createBdd } from "playwright-bdd";
2-
import { expect } from "playwright/test";
2+
33
import { DetailsPage } from "../../helpers/DetailsPage";
44
import { ToolbarTable } from "../../helpers/ToolbarTable";
55
import { SbomListPage } from "../../pages/sbom-list/SbomListPage";
66
import { test } from "../../fixtures";
7+
import { expect } from "../../assertions";
78

89
export const { Given, When, Then } = createBdd(test);
910

@@ -18,7 +19,7 @@ Given("An ingested SBOM {string} is available", async ({ page }, sbomName) => {
1819

1920
await toolbar.applyFilter({ "Filter text": sbomName });
2021
await table.waitUntilDataIsLoaded();
21-
await table.verifyColumnContainsText("Name", sbomName);
22+
await expect(table).toHaveColumnWithValue("Name", sbomName);
2223
});
2324

2425
When(

e2e/tests/ui/features/@sbom-scan/scan-sbom.step.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,22 @@ Then(
102102

103103
Then(
104104
"Tooltip on the {string} column should display {string}",
105-
async ({ page }, column: string, tooltipMessage: string) => {
106-
const table = await Table.build(page, "Vulnerability table");
105+
// biome-ignore lint/suspicious/noExplicitAny: allowed
106+
async ({ page }, column: any, tooltipMessage: string) => {
107+
const table = await Table.build(
108+
page,
109+
"Vulnerability table",
110+
{
111+
"Vulnerability ID": { isSortable: true },
112+
Description: { isSortable: false },
113+
Severity: { isSortable: true },
114+
Status: { isSortable: false },
115+
"Affected packages": { isSortable: true },
116+
Published: { isSortable: true },
117+
Updated: { isSortable: true },
118+
},
119+
[],
120+
);
107121
const tooltipButton = table.getColumnTooltipButton(column, tooltipMessage);
108122
await tooltipButton.hover();
109123
await page.waitForTimeout(500);

e2e/tests/ui/features/@sbom-search/sbom-search.step.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { createBdd } from "playwright-bdd";
2-
import { expect } from "playwright/test";
32

43
import { test } from "../../fixtures";
54
import { DetailsPage } from "../../helpers/DetailsPage";
65
import { ToolbarTable } from "../../helpers/ToolbarTable";
76
import { SbomListPage } from "../../pages/sbom-list/SbomListPage";
7+
import { expect } from "../../assertions";
88

99
export const { Given, When, Then } = createBdd(test);
1010

@@ -18,7 +18,7 @@ Given("An ingested SBOM {string} is available", async ({ page }, sbomName) => {
1818

1919
await toolbar.applyFilter({ "Filter text": sbomName });
2020
await table.waitUntilDataIsLoaded();
21-
await table.verifyColumnContainsText("Name", sbomName);
21+
await expect(table).toHaveColumnWithValue("Name", sbomName);
2222
});
2323

2424
Given(

e2e/tests/ui/pages/Helpers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export const sortArray = (arr: string[], asc: boolean) => {
1919
};
2020
};
2121

22+
/**
23+
* @deprecated replace it by expect(table).toBeSortedBy("columnName")
24+
*/
2225
export const expectSort = (arr: string[], asc: boolean) => {
2326
const { isSorted, sorted } = sortArray(arr, asc);
2427
expect(

e2e/tests/ui/pages/Pagination.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { expect, type Locator, type Page } from "@playwright/test";
2-
import type { Table } from "./Table";
32

43
export class Pagination {
54
private readonly _page: Page;
@@ -46,18 +45,4 @@ export class Pagination {
4645

4746
await expect(this._pagination.locator("input")).toHaveValue("1");
4847
}
49-
50-
// TODO: This seems not belonging here. This matches two entities: Pagination and Table so cannot moved to fixtures/PaginationMatchers.ts. Needs refactoring.
51-
async validateItemsPerPage(columnName: string, table: Table) {
52-
// Verify that only 10 items are displayed
53-
await this.selectItemsPerPage(10);
54-
await table.validateNumberOfRows({ equal: 10 }, columnName);
55-
56-
// Verify that items less than or equal to 20 and greater than 10 are displayed
57-
await this.selectItemsPerPage(20);
58-
await table.validateNumberOfRows(
59-
{ greaterThan: 10, lessThan: 21 },
60-
columnName,
61-
);
62-
}
6348
}

0 commit comments

Comments
 (0)