@vuu-ui/vuu-playwright-helpers 4.0.0-beta.6 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -2
- package/package.json +2 -2
- package/src/PwChart.js +77 -0
- package/src/PwChartDataPoint.js +61 -0
- package/src/PwColumnPicker.js +44 -0
- package/src/PwComponent.js +29 -0
- package/src/PwContextMenu.js +53 -0
- package/src/PwMenuItem.js +58 -0
- package/src/PwTable.js +105 -34
- package/src/PwTableFooter.js +16 -0
- package/src/PwTableHeaderCell.js +15 -7
- package/src/index.js +7 -0
- package/src/selector-utils.js +7 -0
- package/types/PwChart.d.ts +124 -0
- package/types/PwChartDataPoint.d.ts +116 -0
- package/types/PwColumnPicker.d.ts +116 -0
- package/types/PwComponent.d.ts +88 -0
- package/types/PwContextMenu.d.ts +173 -0
- package/types/PwMenuItem.d.ts +196 -0
- package/types/PwTable.d.ts +374 -15
- package/types/PwTableFooter.d.ts +40 -0
- package/types/PwTableHeaderCell.d.ts +51 -4
- package/types/index.d.ts +8 -1
- package/types/selector-utils.d.ts +3 -0
package/README.md
CHANGED
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
Playwright helpers for Vuu components.
|
|
4
4
|
|
|
5
5
|
All helpers extend `PlaywrightPageObject` and are instantiated from the Playwright `page`.
|
|
6
|
-
|
|
7
|
-
overridden at runtime
|
|
6
|
+
Component helpers extend `PwComponent`, which locates the component root element using
|
|
7
|
+
a default CSS selector. This can be overridden at runtime by passing a CSS selector or a
|
|
8
|
+
`Locator`. `PwComponent` provides common helpers:
|
|
9
|
+
|
|
10
|
+
- `locator` - the component root `Locator`, for web-first assertions not covered by a helper
|
|
11
|
+
- `isReady()` - waits until the component is visible, resolves with the component
|
|
12
|
+
- `isVisible()` - is the component currently visible (no waiting)
|
|
13
|
+
- `assertVisible()`, `assertNotAttached()`
|
|
14
|
+
|
|
15
|
+
## PwTable
|
|
8
16
|
|
|
9
17
|
```ts
|
|
10
18
|
// uses the default selector (.vuuTable)
|
|
@@ -23,3 +31,84 @@ await table.editCell("currency", 2, "EUR", "cancel");
|
|
|
23
31
|
Prefer `cell(colName, ariaRowIndex)` for new usage. `ariaRowIndex` is the 1-based
|
|
24
32
|
ARIA row index, including header rows (with one header, the first data row is 2).
|
|
25
33
|
The existing `locateCell` overloads remain available for gradual migration.
|
|
34
|
+
|
|
35
|
+
## PwContextMenu, PwMenuItem
|
|
36
|
+
|
|
37
|
+
`PwTable.showContextMenu(colName, ariaRowIndex)` right clicks a cell and resolves with a
|
|
38
|
+
`PwContextMenu` once the menu is visible.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const menu = await table.showContextMenu("ric", 2);
|
|
42
|
+
|
|
43
|
+
expect(await menu.hasMenuItems()).toBe(true);
|
|
44
|
+
// exact match, in order - a partial list is false
|
|
45
|
+
expect(
|
|
46
|
+
await menu.hasMenuItems(["Copy text", "Filter", "Sort", "Delete row"]),
|
|
47
|
+
).toBe(true);
|
|
48
|
+
expect(await menu.menuItemLabels()).toEqual([
|
|
49
|
+
"Copy text",
|
|
50
|
+
"Filter",
|
|
51
|
+
"Sort",
|
|
52
|
+
"Delete row",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const copy = menu.menuItem("Copy text"); // or by 0 based index, menu.menuItem(0)
|
|
56
|
+
expect(await copy.hasIcon("edit")).toBe(true);
|
|
57
|
+
expect(await copy.isEnabled()).toBe(true);
|
|
58
|
+
expect(await menu.menuItem("Filter").hasChildren()).toBe(true);
|
|
59
|
+
|
|
60
|
+
// sub menus, opened with mouse (default) or keyboard
|
|
61
|
+
const filterMenu = await menu.menuItem("Filter").openSubMenu();
|
|
62
|
+
await filterMenu.menuItem("Include value").click();
|
|
63
|
+
await menu.assertNotAttached();
|
|
64
|
+
|
|
65
|
+
// keyboard
|
|
66
|
+
await menu.press("ArrowDown");
|
|
67
|
+
await menu.assertFocusedMenuItem("Copy text");
|
|
68
|
+
await menu.press("Enter");
|
|
69
|
+
await menu.close(); // Escape
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
To assert that right clicking a cell does not show a context menu, use
|
|
73
|
+
`assertNoContextMenu`. It waits for the page to render after the right click
|
|
74
|
+
before asserting, so it cannot pass before a menu would have appeared.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
await table.assertNoContextMenu("ccy", 2);
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
When a key press depends on the focus change made by the previous key, call
|
|
81
|
+
`assertFocusedMenuItem` in between. Keys pressed before the menu has processed a
|
|
82
|
+
focus change may be delivered to the previously focused item.
|
|
83
|
+
|
|
84
|
+
## PwChart, PwChartDataPoint
|
|
85
|
+
|
|
86
|
+
`PwChart` wraps a Vuu `Chart` (ECharts, svg renderer). Data points are located by
|
|
87
|
+
series name (the Vuu column name) and 0 based data index, or category (x axis)
|
|
88
|
+
value, mirroring `PwTable.cell(colName, ariaRowIndex)`.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
const chart = await new PwChart(page).isReady();
|
|
92
|
+
expect(await chart.seriesNames()).toEqual(["price", "volume"]);
|
|
93
|
+
expect(await chart.categories()).toHaveLength(25);
|
|
94
|
+
|
|
95
|
+
const point = await chart.dataPoint("price", 3); // or chart.dataPoint("price", "2026-04-05")
|
|
96
|
+
expect(point.value).toEqual(103);
|
|
97
|
+
expect(point.isExcluded).toBe(false);
|
|
98
|
+
await point.hover();
|
|
99
|
+
await point.click(); // selects, with a chart selectionModel
|
|
100
|
+
await expect(point.symbol).toHaveAttribute("fill", "purple");
|
|
101
|
+
|
|
102
|
+
const menu = await chart.showContextMenu("price", 3); // or point.showContextMenu()
|
|
103
|
+
await menu.menuItem("Exclude Item").click();
|
|
104
|
+
await chart.assertNoContextMenu("price", 3); // when no menu is configured
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The data point properties (`value`, `x`, `y`, `isExcluded`) are a snapshot, taken
|
|
108
|
+
when the data point is obtained. Mouse actions resolve the position again and wait
|
|
109
|
+
until it is stable, so they work while the chart is resizing. To assert a change to
|
|
110
|
+
the data, obtain the data point again, e.g.
|
|
111
|
+
`await expect.poll(async () => (await chart.dataPoint("price", 3)).isExcluded).toBe(true)`.
|
|
112
|
+
|
|
113
|
+
The Vuu `Chart` exposes its ECharts instance on the chart canvas element
|
|
114
|
+
(`echartsInstance`), which `PwChart` uses to map data points to pixel positions.
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "4.0.0
|
|
2
|
+
"version": "4.0.0",
|
|
3
3
|
"author": "heswell",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "./src/index.js",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"type-defs": "node ../../scripts/build-type-defs.mjs"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@vuu-ui/vuu-protocol-types": "4.0.0
|
|
12
|
+
"@vuu-ui/vuu-protocol-types": "4.0.0"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
15
|
"@playwright/test": "^1.62.1"
|
package/src/PwChart.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { expect } from "@playwright/test";
|
|
2
|
+
import { PwComponent } from "./PwComponent.js";
|
|
3
|
+
import { PwContextMenu } from "./PwContextMenu.js";
|
|
4
|
+
import { PwChartDataPoint } from "./PwChartDataPoint.js";
|
|
5
|
+
class PwChart extends PwComponent {
|
|
6
|
+
defaultCssSelector = ".vuuChart";
|
|
7
|
+
get canvas() {
|
|
8
|
+
return this.locator.locator("[_echarts_instance_]");
|
|
9
|
+
}
|
|
10
|
+
async isReady(options) {
|
|
11
|
+
await super.isReady(options);
|
|
12
|
+
await expect.poll(()=>this.canvas.evaluate((element)=>(element.echartsInstance?.getOption().series ?? []).some((series)=>series.data.length > 0)), options).toBe(true);
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
async seriesNames() {
|
|
16
|
+
await this.isReady();
|
|
17
|
+
return this.canvas.evaluate((element)=>{
|
|
18
|
+
const series = element.echartsInstance?.getOption().series ?? [];
|
|
19
|
+
return Array.from(new Set(series.map(({ name })=>name)));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
async categories() {
|
|
23
|
+
await this.isReady();
|
|
24
|
+
return this.canvas.evaluate((element)=>element.echartsInstance?.getOption().xAxis?.[0]?.data ?? []);
|
|
25
|
+
}
|
|
26
|
+
async dataPoint(seriesName, dataIndexOrCategory) {
|
|
27
|
+
await this.isReady();
|
|
28
|
+
const resolve = ()=>this.resolveDataPoint(seriesName, dataIndexOrCategory);
|
|
29
|
+
return new PwChartDataPoint(this.page, this.canvas, await resolve(), resolve);
|
|
30
|
+
}
|
|
31
|
+
async resolveDataPoint(seriesName, dataIndexOrCategory) {
|
|
32
|
+
const info = await this.canvas.evaluate((element, [seriesName, dataIndexOrCategory])=>{
|
|
33
|
+
const chart = element.echartsInstance;
|
|
34
|
+
if (!chart) return "chart has no echartsInstance";
|
|
35
|
+
const { series = [], xAxis = [] } = chart.getOption();
|
|
36
|
+
const categories = xAxis[0]?.data ?? [];
|
|
37
|
+
const dataIndex = "number" == typeof dataIndexOrCategory ? dataIndexOrCategory : categories.indexOf(dataIndexOrCategory);
|
|
38
|
+
if (-1 === dataIndex) return `no category "${dataIndexOrCategory}"`;
|
|
39
|
+
const namedSeries = series.filter(({ name })=>name === seriesName);
|
|
40
|
+
if (0 === namedSeries.length) return `no series "${seriesName}"`;
|
|
41
|
+
const valueSeries = namedSeries.find(({ data })=>"number" == typeof data[dataIndex]?.value);
|
|
42
|
+
if (!valueSeries) return `series "${seriesName}" has no value at data index ${dataIndex}`;
|
|
43
|
+
const value = valueSeries.data[dataIndex].value;
|
|
44
|
+
const [x, y] = chart.convertToPixel({
|
|
45
|
+
seriesId: valueSeries.id
|
|
46
|
+
}, [
|
|
47
|
+
dataIndex,
|
|
48
|
+
value
|
|
49
|
+
]);
|
|
50
|
+
return {
|
|
51
|
+
seriesName,
|
|
52
|
+
dataIndex,
|
|
53
|
+
category: categories[dataIndex],
|
|
54
|
+
value,
|
|
55
|
+
isExcluded: valueSeries.id !== seriesName,
|
|
56
|
+
x,
|
|
57
|
+
y
|
|
58
|
+
};
|
|
59
|
+
}, [
|
|
60
|
+
seriesName,
|
|
61
|
+
dataIndexOrCategory
|
|
62
|
+
]);
|
|
63
|
+
if ("string" == typeof info) throw Error(`PwChart, dataPoint, ${info}`);
|
|
64
|
+
return info;
|
|
65
|
+
}
|
|
66
|
+
async showContextMenu(seriesName, dataIndexOrCategory) {
|
|
67
|
+
const dataPoint = await this.dataPoint(seriesName, dataIndexOrCategory);
|
|
68
|
+
return dataPoint.showContextMenu();
|
|
69
|
+
}
|
|
70
|
+
async assertNoContextMenu(seriesName, dataIndexOrCategory) {
|
|
71
|
+
const dataPoint = await this.dataPoint(seriesName, dataIndexOrCategory);
|
|
72
|
+
await PwContextMenu.assertNotOpened(this.page, ()=>dataPoint.click({
|
|
73
|
+
button: "right"
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export { PwChart };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { PlaywrightPageObject } from "./PlaywrightPageObject.js";
|
|
2
|
+
import { PwContextMenu } from "./PwContextMenu.js";
|
|
3
|
+
class PwChartDataPoint extends PlaywrightPageObject {
|
|
4
|
+
canvas;
|
|
5
|
+
resolve;
|
|
6
|
+
seriesName;
|
|
7
|
+
dataIndex;
|
|
8
|
+
category;
|
|
9
|
+
value;
|
|
10
|
+
isExcluded;
|
|
11
|
+
x;
|
|
12
|
+
y;
|
|
13
|
+
constructor(page, canvas, info, resolve){
|
|
14
|
+
super(page), this.canvas = canvas, this.resolve = resolve;
|
|
15
|
+
this.seriesName = info.seriesName;
|
|
16
|
+
this.dataIndex = info.dataIndex;
|
|
17
|
+
this.category = info.category;
|
|
18
|
+
this.value = info.value;
|
|
19
|
+
this.isExcluded = info.isExcluded;
|
|
20
|
+
this.x = info.x;
|
|
21
|
+
this.y = info.y;
|
|
22
|
+
}
|
|
23
|
+
get symbol() {
|
|
24
|
+
const position = `,${round(this.x)},${round(this.y)})`;
|
|
25
|
+
return this.canvas.locator(`svg path[transform$="${position}"]`);
|
|
26
|
+
}
|
|
27
|
+
async click(options) {
|
|
28
|
+
const position = await this.stablePosition();
|
|
29
|
+
await this.canvas.click({
|
|
30
|
+
...options,
|
|
31
|
+
position
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async hover() {
|
|
35
|
+
await this.canvas.hover({
|
|
36
|
+
position: await this.stablePosition()
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
showContextMenu() {
|
|
40
|
+
return PwContextMenu.open(this.page, ()=>this.click({
|
|
41
|
+
button: "right"
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
async stablePosition() {
|
|
45
|
+
let { x, y } = await this.resolve();
|
|
46
|
+
for(let attempt = 0; attempt < MAX_STABLE_ATTEMPTS; attempt++){
|
|
47
|
+
await nextAnimationFrames(this.page);
|
|
48
|
+
const current = await this.resolve();
|
|
49
|
+
if (current.x === x && current.y === y) return {
|
|
50
|
+
x,
|
|
51
|
+
y
|
|
52
|
+
};
|
|
53
|
+
({ x, y } = current);
|
|
54
|
+
}
|
|
55
|
+
throw Error(`PwChartDataPoint, ${this.seriesName}[${this.dataIndex}] position is not stable`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const MAX_STABLE_ATTEMPTS = 50;
|
|
59
|
+
const round = (value)=>parseFloat(value.toFixed(4));
|
|
60
|
+
const nextAnimationFrames = (page)=>page.evaluate(()=>new Promise((resolve)=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve()))));
|
|
61
|
+
export { PwChartDataPoint };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { expect } from "@playwright/test";
|
|
2
|
+
import { PwComponent } from "./PwComponent.js";
|
|
3
|
+
import { cssAttributeValue } from "./selector-utils.js";
|
|
4
|
+
class PwColumnPicker extends PwComponent {
|
|
5
|
+
defaultCssSelector = ".vuuColumnPicker";
|
|
6
|
+
get selectedOptions() {
|
|
7
|
+
return this.locator.locator(".vuuItemPicker-selectedList [role='option']");
|
|
8
|
+
}
|
|
9
|
+
get availableOptions() {
|
|
10
|
+
return this.locator.locator(".vuuItemPicker-availableList [role='option']");
|
|
11
|
+
}
|
|
12
|
+
selectedColumns() {
|
|
13
|
+
return this.optionNames(this.selectedOptions);
|
|
14
|
+
}
|
|
15
|
+
availableColumns() {
|
|
16
|
+
return this.optionNames(this.availableOptions);
|
|
17
|
+
}
|
|
18
|
+
async addColumn(columnName) {
|
|
19
|
+
await this.clickAction(this.option(this.availableOptions, columnName));
|
|
20
|
+
}
|
|
21
|
+
async removeColumn(columnName) {
|
|
22
|
+
await this.clickAction(this.option(this.selectedOptions, columnName));
|
|
23
|
+
}
|
|
24
|
+
async search(text) {
|
|
25
|
+
await this.locator.getByRole("search").getByRole("textbox").fill(text);
|
|
26
|
+
}
|
|
27
|
+
async assertSelectedColumns(columnNames) {
|
|
28
|
+
await expect.poll(()=>this.selectedColumns()).toEqual(columnNames);
|
|
29
|
+
}
|
|
30
|
+
async assertAvailableColumns(columnNames) {
|
|
31
|
+
await expect.poll(()=>this.availableColumns()).toEqual(expect.arrayContaining(columnNames));
|
|
32
|
+
}
|
|
33
|
+
async clickAction(option) {
|
|
34
|
+
await option.hover();
|
|
35
|
+
await option.locator(".vuuItemPickerListItem-action").click();
|
|
36
|
+
}
|
|
37
|
+
option(options, columnName) {
|
|
38
|
+
return options.and(this.page.locator(`[data-name="${cssAttributeValue(columnName)}"]`));
|
|
39
|
+
}
|
|
40
|
+
optionNames(options) {
|
|
41
|
+
return options.evaluateAll((elements)=>elements.map((element)=>element.getAttribute("data-name") ?? ""));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export { PwColumnPicker };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { expect } from "@playwright/test";
|
|
2
|
+
import { PlaywrightPageObject } from "./PlaywrightPageObject.js";
|
|
3
|
+
class PwComponent extends PlaywrightPageObject {
|
|
4
|
+
target;
|
|
5
|
+
constructor(page, target){
|
|
6
|
+
super(page), this.target = target;
|
|
7
|
+
}
|
|
8
|
+
get locator() {
|
|
9
|
+
if ("string" == typeof this.target || void 0 === this.target) return this.page.locator(this.target ?? this.defaultCssSelector);
|
|
10
|
+
return this.target;
|
|
11
|
+
}
|
|
12
|
+
async isReady(options) {
|
|
13
|
+
await this.locator.waitFor({
|
|
14
|
+
state: "visible",
|
|
15
|
+
...options
|
|
16
|
+
});
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
isVisible() {
|
|
20
|
+
return this.locator.isVisible();
|
|
21
|
+
}
|
|
22
|
+
async assertVisible() {
|
|
23
|
+
await expect(this.locator).toBeVisible();
|
|
24
|
+
}
|
|
25
|
+
async assertNotAttached() {
|
|
26
|
+
await expect(this.locator).not.toBeAttached();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export { PwComponent };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { expect } from "@playwright/test";
|
|
2
|
+
import { PwComponent } from "./PwComponent.js";
|
|
3
|
+
import { PwMenuItem } from "./PwMenuItem.js";
|
|
4
|
+
import { byId } from "./selector-utils.js";
|
|
5
|
+
class PwContextMenu extends PwComponent {
|
|
6
|
+
defaultCssSelector = '.vuuContextMenuPanel[role="menu"]';
|
|
7
|
+
static async open(page, openAction) {
|
|
8
|
+
await openAction();
|
|
9
|
+
const menu = await new PwContextMenu(page).isReady();
|
|
10
|
+
const menuId = await menu.locator.getAttribute("id");
|
|
11
|
+
const contextMenu = menuId ? new PwContextMenu(page, byId(menuId)) : menu;
|
|
12
|
+
await expect(contextMenu.locator.and(page.locator(":focus-within"))).toHaveCount(1);
|
|
13
|
+
return contextMenu;
|
|
14
|
+
}
|
|
15
|
+
static async assertNotOpened(page, openAction) {
|
|
16
|
+
await openAction();
|
|
17
|
+
await page.evaluate(()=>new Promise((resolve)=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve()))));
|
|
18
|
+
await new PwContextMenu(page).assertNotAttached();
|
|
19
|
+
}
|
|
20
|
+
get menuItems() {
|
|
21
|
+
return this.locator.getByRole("menuitem");
|
|
22
|
+
}
|
|
23
|
+
async menuItemLabels() {
|
|
24
|
+
return (await this.menuItems.allInnerTexts()).map((text)=>text.trim());
|
|
25
|
+
}
|
|
26
|
+
async hasMenuItems(labels) {
|
|
27
|
+
if (void 0 === labels) return await this.menuItems.count() > 0;
|
|
28
|
+
const menuItemLabels = await this.menuItemLabels();
|
|
29
|
+
return menuItemLabels.length === labels.length && menuItemLabels.every((label, i)=>label === labels[i]);
|
|
30
|
+
}
|
|
31
|
+
menuItem(labelOrIndex) {
|
|
32
|
+
const locator = "number" == typeof labelOrIndex ? this.menuItems.nth(labelOrIndex) : this.menuItems.filter({
|
|
33
|
+
has: this.page.getByText(labelOrIndex, {
|
|
34
|
+
exact: true
|
|
35
|
+
})
|
|
36
|
+
});
|
|
37
|
+
return new PwMenuItem(this.page, locator);
|
|
38
|
+
}
|
|
39
|
+
get focusedMenuItem() {
|
|
40
|
+
return new PwMenuItem(this.page, this.menuItems.and(this.page.locator(':focus[tabindex="0"]')));
|
|
41
|
+
}
|
|
42
|
+
async assertFocusedMenuItem(label) {
|
|
43
|
+
await expect(this.focusedMenuItem.locator).toHaveText(label);
|
|
44
|
+
}
|
|
45
|
+
async press(...keys) {
|
|
46
|
+
for (const key of keys)await this.page.keyboard.press(key);
|
|
47
|
+
}
|
|
48
|
+
async close() {
|
|
49
|
+
await this.press("Escape");
|
|
50
|
+
await this.assertNotAttached();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export { PwContextMenu };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { expect } from "@playwright/test";
|
|
2
|
+
import { PwComponent } from "./PwComponent.js";
|
|
3
|
+
import { PwContextMenu } from "./PwContextMenu.js";
|
|
4
|
+
import { byId } from "./selector-utils.js";
|
|
5
|
+
class PwMenuItem extends PwComponent {
|
|
6
|
+
defaultCssSelector = '[role="menuitem"]';
|
|
7
|
+
async label() {
|
|
8
|
+
return (await this.locator.innerText()).trim();
|
|
9
|
+
}
|
|
10
|
+
async hasChildren() {
|
|
11
|
+
return await this.locator.getAttribute("aria-haspopup") === "menu";
|
|
12
|
+
}
|
|
13
|
+
async isDisabled() {
|
|
14
|
+
return await this.locator.getAttribute("aria-disabled") === "true";
|
|
15
|
+
}
|
|
16
|
+
async isEnabled() {
|
|
17
|
+
return !await this.isDisabled();
|
|
18
|
+
}
|
|
19
|
+
async isExpanded() {
|
|
20
|
+
return await this.locator.getAttribute("aria-expanded") === "true";
|
|
21
|
+
}
|
|
22
|
+
isFocused() {
|
|
23
|
+
return this.locator.evaluate((element)=>element === element.ownerDocument.activeElement);
|
|
24
|
+
}
|
|
25
|
+
async hasIcon(iconName) {
|
|
26
|
+
return await this.locator.getAttribute("data-icon") === iconName;
|
|
27
|
+
}
|
|
28
|
+
async click(options) {
|
|
29
|
+
await this.locator.click(options);
|
|
30
|
+
}
|
|
31
|
+
async hover() {
|
|
32
|
+
await this.locator.hover();
|
|
33
|
+
}
|
|
34
|
+
async focus() {
|
|
35
|
+
await this.locator.focus();
|
|
36
|
+
}
|
|
37
|
+
async press(key) {
|
|
38
|
+
await this.locator.press(key);
|
|
39
|
+
}
|
|
40
|
+
async subMenu() {
|
|
41
|
+
const subMenuId = await this.locator.getAttribute("aria-controls");
|
|
42
|
+
if (null === subMenuId) throw Error(`PwMenuItem, subMenu, menu item "${await this.label()}" has no open sub menu`);
|
|
43
|
+
return new PwContextMenu(this.page, byId(subMenuId)).isReady();
|
|
44
|
+
}
|
|
45
|
+
async openSubMenu(using = "mouse") {
|
|
46
|
+
if (!await this.hasChildren()) throw Error(`PwMenuItem, openSubMenu, menu item "${await this.label()}" has no sub menu`);
|
|
47
|
+
if ("mouse" === using) await this.hover();
|
|
48
|
+
else {
|
|
49
|
+
await this.focus();
|
|
50
|
+
await this.press("ArrowRight");
|
|
51
|
+
}
|
|
52
|
+
await this.locator.and(this.page.locator('[aria-expanded="true"]')).waitFor();
|
|
53
|
+
const subMenu = await this.subMenu();
|
|
54
|
+
if ("keyboard" === using) await expect(subMenu.focusedMenuItem.locator).toHaveCount(1);
|
|
55
|
+
return subMenu;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export { PwMenuItem };
|
package/src/PwTable.js
CHANGED
|
@@ -1,34 +1,75 @@
|
|
|
1
1
|
import { expect } from "@playwright/test";
|
|
2
|
-
import {
|
|
2
|
+
import { PwComponent } from "./PwComponent.js";
|
|
3
|
+
import { PwContextMenu } from "./PwContextMenu.js";
|
|
3
4
|
import { PwTableHeaderCell } from "./PwTableHeaderCell.js";
|
|
5
|
+
import { byDataIcon, cssAttributeValue } from "./selector-utils.js";
|
|
6
|
+
const SCROLL_TOLERANCE = 1;
|
|
4
7
|
const byAriaRowIndex = (ariaRowIndex)=>`[aria-rowindex="${ariaRowIndex}"]:scope`;
|
|
5
8
|
const xpathStringLiteral = (value)=>{
|
|
6
9
|
if (!value.includes("'")) return `'${value}'`;
|
|
7
10
|
if (!value.includes('"')) return `"${value}"`;
|
|
8
11
|
return `concat('${value.split("'").join("', \"'\", '")}')`;
|
|
9
12
|
};
|
|
10
|
-
|
|
11
|
-
const code = character.charCodeAt(0);
|
|
12
|
-
return '"' === character || "\\" === character || code < 32 || 127 === code ? `\\${code.toString(16)} ` : character;
|
|
13
|
-
}).join("");
|
|
14
|
-
const byDataIcon = (iconName)=>`[data-icon="${cssAttributeValue(iconName)}"]`;
|
|
15
|
-
class PwTable extends PlaywrightPageObject {
|
|
16
|
-
cssSelector;
|
|
13
|
+
class PwTable extends PwComponent {
|
|
17
14
|
defaultCssSelector = ".vuuTable";
|
|
18
|
-
constructor(page, cssSelector){
|
|
19
|
-
super(page), this.cssSelector = cssSelector;
|
|
20
|
-
}
|
|
21
|
-
get #locator() {
|
|
22
|
-
return this.page.locator(this.cssSelector ?? this.defaultCssSelector);
|
|
23
|
-
}
|
|
24
15
|
get contentContainer() {
|
|
25
|
-
return this
|
|
16
|
+
return this.locator.locator(".vuuTable-contentContainer");
|
|
26
17
|
}
|
|
27
18
|
get scrollbarContainer() {
|
|
28
|
-
return this
|
|
19
|
+
return this.locator.locator(".vuuTable-scrollbarContainer");
|
|
20
|
+
}
|
|
21
|
+
scrollPosition() {
|
|
22
|
+
return this.scrollbarContainer.evaluate(({ scrollLeft, scrollTop })=>({
|
|
23
|
+
scrollLeft,
|
|
24
|
+
scrollTop
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
scrollUp(distance) {
|
|
28
|
+
return this.scroll("vertical", -1, distance);
|
|
29
|
+
}
|
|
30
|
+
scrollDown(distance) {
|
|
31
|
+
return this.scroll("vertical", 1, distance);
|
|
32
|
+
}
|
|
33
|
+
scrollLeft(distance) {
|
|
34
|
+
return this.scroll("horizontal", -1, distance);
|
|
35
|
+
}
|
|
36
|
+
scrollRight(distance) {
|
|
37
|
+
return this.scroll("horizontal", 1, distance);
|
|
38
|
+
}
|
|
39
|
+
async scroll(axis, direction, distance) {
|
|
40
|
+
if ("number" == typeof distance && distance < 0) throw Error("PwTable, scroll, distance must not be negative, use the opposite direction");
|
|
41
|
+
await this.scrollbarContainer.evaluate((el, { axis, direction, distance })=>{
|
|
42
|
+
const vertical = "vertical" === axis;
|
|
43
|
+
const current = vertical ? el.scrollTop : el.scrollLeft;
|
|
44
|
+
const max = vertical ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth;
|
|
45
|
+
const pos = "start" === distance ? 0 : "end" === distance ? max : current + direction * ("page" === distance ? el.clientHeight : distance);
|
|
46
|
+
el.scrollTo({
|
|
47
|
+
[vertical ? "top" : "left"]: Math.min(max, Math.max(0, pos)),
|
|
48
|
+
behavior: "instant"
|
|
49
|
+
});
|
|
50
|
+
}, {
|
|
51
|
+
axis,
|
|
52
|
+
direction,
|
|
53
|
+
distance
|
|
54
|
+
});
|
|
55
|
+
await expect.poll(()=>this.isContentScrollSynced(), {
|
|
56
|
+
message: "PwTable, content container scroll position not synced"
|
|
57
|
+
}).toBe(true);
|
|
58
|
+
}
|
|
59
|
+
isContentScrollSynced() {
|
|
60
|
+
return this.locator.evaluate((table, tolerance)=>{
|
|
61
|
+
const scrollbar = table.querySelector(".vuuTable-scrollbarContainer");
|
|
62
|
+
const content = table.querySelector(".vuuTable-contentContainer");
|
|
63
|
+
if (!scrollbar || !content) return false;
|
|
64
|
+
const isSynced = (sourcePos, sourceMax, targetPos, targetMax)=>{
|
|
65
|
+
const expected = sourceMax > 0 ? sourcePos / sourceMax * targetMax : 0;
|
|
66
|
+
return Math.abs(targetPos - expected) <= tolerance;
|
|
67
|
+
};
|
|
68
|
+
return isSynced(scrollbar.scrollTop, scrollbar.scrollHeight - scrollbar.clientHeight, content.scrollTop, content.scrollHeight - content.clientHeight) && isSynced(scrollbar.scrollLeft, scrollbar.scrollWidth - scrollbar.clientWidth, content.scrollLeft, content.scrollWidth - content.clientWidth);
|
|
69
|
+
}, SCROLL_TOLERANCE);
|
|
29
70
|
}
|
|
30
71
|
row(row) {
|
|
31
|
-
return this
|
|
72
|
+
return this.locator.locator(`[aria-rowindex="${row}"]`);
|
|
32
73
|
}
|
|
33
74
|
cell(colName, ariaRowIndex) {
|
|
34
75
|
return this.locateCell(ariaRowIndex, colName);
|
|
@@ -42,35 +83,68 @@ class PwTable extends PlaywrightPageObject {
|
|
|
42
83
|
await textbox.press("commit" === action ? "Enter" : "Escape");
|
|
43
84
|
}
|
|
44
85
|
async columnNames() {
|
|
45
|
-
return this
|
|
86
|
+
return this.locator.locator(".vuuTableHeaderCell .vuuTableHeaderCell-label").allTextContents();
|
|
87
|
+
}
|
|
88
|
+
headerColumnNames() {
|
|
89
|
+
return this.locator.locator(".vuuTableHeaderCell[data-column-name]").evaluateAll((elements)=>elements.map((element)=>element.getAttribute("data-column-name") ?? ""));
|
|
90
|
+
}
|
|
91
|
+
async assertColumns(columnNames) {
|
|
92
|
+
await expect.poll(()=>this.headerColumnNames()).toEqual(columnNames);
|
|
46
93
|
}
|
|
47
94
|
async rowCount() {
|
|
48
|
-
const ariaRowCount = await this
|
|
95
|
+
const ariaRowCount = await this.locator.getByRole("table").getAttribute("aria-rowcount");
|
|
49
96
|
if (null === ariaRowCount) throw Error("PwTable, rowCount, table does not have an aria-rowcount");
|
|
50
97
|
return parseInt(ariaRowCount, 10);
|
|
51
98
|
}
|
|
99
|
+
get dataLoaded() {
|
|
100
|
+
return expect.poll(()=>this.rowCount()).toBeGreaterThan(0);
|
|
101
|
+
}
|
|
102
|
+
async columnValues(columnName) {
|
|
103
|
+
const headerCell = this.locator.locator(`xpath=.//*[@role='columnheader' and @data-column-name=${xpathStringLiteral(columnName)}]`).first();
|
|
104
|
+
const ariaColIndex = await headerCell.getAttribute("aria-colindex");
|
|
105
|
+
if (null === ariaColIndex) throw Error(`PwTable, columnValues, column "${columnName}" does not have an aria-colindex`);
|
|
106
|
+
const cells = this.locator.locator(`[role="row"] > [role="cell"][aria-colindex="${ariaColIndex}"]`);
|
|
107
|
+
const values = await cells.evaluateAll((elements)=>elements.map((cell)=>({
|
|
108
|
+
rowIndex: Number(cell.parentElement?.getAttribute("aria-rowindex")),
|
|
109
|
+
text: cell.textContent ?? ""
|
|
110
|
+
})));
|
|
111
|
+
return values.sort((v1, v2)=>v1.rowIndex - v2.rowIndex).map(({ text })=>text);
|
|
112
|
+
}
|
|
52
113
|
async cellIcon(columnName, ariaRowIndex, iconName) {
|
|
53
114
|
const cell = await this.cell(columnName, ariaRowIndex);
|
|
54
115
|
return cell.locator(byDataIcon(iconName));
|
|
55
116
|
}
|
|
56
117
|
cellsWithIcon(iconName) {
|
|
57
|
-
return this
|
|
118
|
+
return this.locator.getByRole("cell").filter({
|
|
58
119
|
has: this.page.locator(byDataIcon(iconName))
|
|
59
120
|
});
|
|
60
121
|
}
|
|
61
122
|
headerCell(columnName) {
|
|
62
|
-
return new PwTableHeaderCell(this.page,
|
|
123
|
+
return new PwTableHeaderCell(this.page, this.locator.locator(`.vuuTableHeaderCell[data-column-name="${cssAttributeValue(columnName)}"]`));
|
|
124
|
+
}
|
|
125
|
+
async showContextMenu(columnName, ariaRowIndex) {
|
|
126
|
+
const cell = await this.cell(columnName, ariaRowIndex);
|
|
127
|
+
return PwContextMenu.open(this.page, ()=>cell.click({
|
|
128
|
+
button: "right"
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
async assertNoContextMenu(columnName, ariaRowIndex) {
|
|
132
|
+
const cell = await this.cell(columnName, ariaRowIndex);
|
|
133
|
+
await PwContextMenu.assertNotOpened(this.page, ()=>cell.click({
|
|
134
|
+
button: "right"
|
|
135
|
+
}));
|
|
136
|
+
await expect(cell).not.toContainClass("ContextOpen");
|
|
63
137
|
}
|
|
64
138
|
locateCell(rowOrText, column) {
|
|
65
|
-
if ("number" == typeof rowOrText && "number" == typeof column) return this
|
|
139
|
+
if ("number" == typeof rowOrText && "number" == typeof column) return this.locator.locator(`[aria-rowindex="${rowOrText}"] > [aria-colindex="${column}"]`);
|
|
66
140
|
if ("number" == typeof rowOrText && "string" == typeof column) {
|
|
67
|
-
const columnHeader = this
|
|
141
|
+
const columnHeader = this.locator.locator(`xpath=.//*[@data-column-name=${xpathStringLiteral(column)}]`).first();
|
|
68
142
|
return columnHeader.getAttribute("aria-colindex").then((ariaColIndex)=>{
|
|
69
143
|
if (null === ariaColIndex) throw Error(`PwTable, locateCell, column "${column}" does not have an aria-colindex`);
|
|
70
|
-
return this
|
|
144
|
+
return this.locator.locator(`[aria-rowindex="${rowOrText}"] > [aria-colindex="${ariaColIndex}"]`);
|
|
71
145
|
});
|
|
72
146
|
}
|
|
73
|
-
if ("string" == typeof rowOrText) return this
|
|
147
|
+
if ("string" == typeof rowOrText) return this.locator.getByRole("cell", {
|
|
74
148
|
name: rowOrText
|
|
75
149
|
});
|
|
76
150
|
}
|
|
@@ -88,9 +162,6 @@ class PwTable extends PlaywrightPageObject {
|
|
|
88
162
|
}
|
|
89
163
|
}
|
|
90
164
|
}
|
|
91
|
-
async assertVisible() {
|
|
92
|
-
return expect(this.#locator).toBeVisible();
|
|
93
|
-
}
|
|
94
165
|
getCellText(arg1, arg2, arg3, arg4) {
|
|
95
166
|
if ("number" == typeof arg1 && "number" == typeof arg2) return [
|
|
96
167
|
this.locateCell(arg1, arg2),
|
|
@@ -146,16 +217,16 @@ class PwTable extends PlaywrightPageObject {
|
|
|
146
217
|
const offsetFromEnd = totalRowCount - to;
|
|
147
218
|
const trailingBufferedRows = offsetFromEnd < renderBufferSize ? Math.min(0, offsetFromEnd) : renderBufferSize;
|
|
148
219
|
const renderedRowCount = to - from + leadingBufferedRows + trailingBufferedRows;
|
|
149
|
-
await expect(this
|
|
220
|
+
await expect(this.locator.getByRole("row")).toHaveCount(renderedRowCount + headerCount);
|
|
150
221
|
const firstRenderedRow = from - leadingBufferedRows + headerCount + 1;
|
|
151
222
|
const lastVisibleRow = to + headerCount;
|
|
152
223
|
const lastRenderedRow = to + headerCount + trailingBufferedRows;
|
|
153
|
-
await expect(this
|
|
154
|
-
await expect(this
|
|
224
|
+
await expect(this.locator.getByRole("row").locator(byAriaRowIndex(firstRenderedRow))).toBeVisible();
|
|
225
|
+
await expect(this.locator.getByRole("row").locator(byAriaRowIndex(lastVisibleRow))).toBeVisible();
|
|
155
226
|
if (trailingBufferedRows > 0) {
|
|
156
|
-
await expect(this
|
|
157
|
-
await expect(this
|
|
158
|
-
await expect(this
|
|
227
|
+
await expect(this.locator.getByRole("row").locator(byAriaRowIndex(lastVisibleRow + 1))).not.toBeInViewport();
|
|
228
|
+
await expect(this.locator.getByRole("row").locator(byAriaRowIndex(lastRenderedRow))).not.toBeInViewport();
|
|
229
|
+
await expect(this.locator.getByRole("row").locator(byAriaRowIndex(lastRenderedRow + 1))).not.toBeAttached();
|
|
159
230
|
}
|
|
160
231
|
}
|
|
161
232
|
async assertRenderedColumns({ rendered, visible }) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { PwColumnPicker } from "./PwColumnPicker.js";
|
|
2
|
+
import { PwComponent } from "./PwComponent.js";
|
|
3
|
+
class PwTableFooter extends PwComponent {
|
|
4
|
+
defaultCssSelector = ".vuuTableFooter";
|
|
5
|
+
action(name) {
|
|
6
|
+
return this.locator.getByRole("button", {
|
|
7
|
+
name,
|
|
8
|
+
exact: true
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
async showColumnPicker(name = "Show column picker") {
|
|
12
|
+
await this.action(name).click();
|
|
13
|
+
return new PwColumnPicker(this.page).isReady();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export { PwTableFooter };
|