Skip to content
Open
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
56 changes: 56 additions & 0 deletions docs/requirements/e2e-test-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,60 @@ page.locator(".mx-name-myForm").getByRole("button", { name: "Save" });
page.locator(".mx-name-myWidget").getByLabel("Start date");
```

## Page Object Model

For widgets with more than a handful of specs — or any spec that repeats the same locators/actions — encapsulate the widget's locators and interactions in a Page Object instead of scattering raw selectors across tests. This keeps specs reading as behavior and gives one place to update when the DOM changes.

**Where it lives:** widget-local, at `e2e/pages/<Widget>Page.js`. Keep it per-widget — do not add shared POMs to `@mendix/run-e2e` (widgets are isolated; see repo-layout). Reference: `packages/pluggableWidgets/datagrid-web/e2e/pages/DataGridPage.js`.

**Conventions:**

- **Scope to one widget instance by `mx-name`.** The constructor takes `(page, name)` and builds a `root` locator (`.mx-name-<name>`); default the `name` to the most common instance. All locators derive from `root` — the POM stores no reference to `page`.
- **Navigation stays in `beforeEach`, not the POM.** Call `page.goto(path)` directly in the spec's `beforeEach`. The custom fixture auto-waits for the app after every `goto` — do **not** add `waitForMendixApp` (see Imports & Setup).
- **Page-level selectors stay in the spec.** When a widget renders outside the grid's DOM subtree (e.g. a sibling filter widget), use `page.locator(...)` inline in the test. Do not add a `this.page` property to the POM to accommodate these — it breaks instance isolation.
- **Expose locators as getters/methods; keep assertions in the spec.** The POM returns locators (e.g. `get rows()`, `columnCells(n)`); the test does the `expect(...)`. This preserves auto-retrying web-first assertions and keeps the POM assertion-free.
- **Actions are verbs** (`sortByColumn(n)`, `openColumnSelector()`); **locators are nouns** (`columnHeaders`, `cells`). Prefer `.mx-name-*` and role/label composition inside the POM, following Locator Patterns above.

```javascript
// e2e/pages/MyWidgetPage.js
export class MyWidgetPage {
constructor(page, name = "myWidget1") {
// page is used only to build root; not stored — keeps POM dom-subtree-scoped.
this.root = page.locator(`.mx-name-${name}`);
}

get rows() {
return this.root.locator('[role="row"]');
}

async submit() {
await this.root.getByRole("button", { name: "Save" }).click();
}
}
```

```javascript
// e2e/MyWidget.spec.js
import { test, expect } from "@mendix/run-e2e/fixtures";
import { MyWidgetPage } from "./pages/MyWidgetPage";

test.describe("MyWidget", () => {
/** @type {MyWidgetPage} */
let widget;

// Navigation and page-level setup belong here, not in the POM.
test.beforeEach(async ({ page }) => {
widget = new MyWidgetPage(page);
await page.goto("/"); // fixture auto-waits for Mendix readiness
});

test("submits the form @smoke", async () => {
await widget.submit();
await expect(widget.rows).toHaveCount(3); // assertion stays in the spec
});
});
```

## Screenshot Testing

- No per-test `{ threshold: N }` or `{ maxDiffPixels: N }` overrides — use global config (`threshold: 0.1`)
Expand All @@ -85,6 +139,8 @@ playwright/prefer-web-first-assertions: warn

## Spec File Template

Minimal template for a small widget. For widgets with many specs or repeated locators, use a Page Object instead (see Page Object Model above).

```javascript
import { test, expect } from "@mendix/run-e2e/fixtures";

Expand Down
127 changes: 58 additions & 69 deletions packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import AxeBuilder from "@axe-core/playwright";
import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers";
import { expect, test } from "@mendix/run-e2e/fixtures";
import path from "path";
import * as XLSX from "xlsx";
import { DataGridPage } from "./pages/DataGridPage";

test.describe("datagrid-web export to Excel", () => {
test("check if export to Excel generates correct output", async ({ page }) => {
const downloadedFilename = path.join("./e2e/downloads/", "testFilename.xlsx");

const grid = new DataGridPage(page, "dataGridExportExcel");
await page.goto("/p/export-excel");
await waitForMendixApp(page);
await page.locator(".mx-name-dataGridExportExcel").waitFor({ state: "visible", timeout: 15000 });
await grid.root.waitFor({ state: "visible", timeout: 15000 });
// Start waiting for download before clicking.
const downloadPromise = page.waitForEvent("download");
// Export button lives outside the grid widget boundary, so it stays a page-level selector.
await page.locator(".mx-name-exportButton").click({ force: true });
const download = await downloadPromise;
// Wait for the download process to complete and save the downloaded file.
Expand Down Expand Up @@ -50,66 +51,54 @@ test.describe("datagrid-web export to Excel", () => {

test.describe("capabilities: sorting", () => {
test("applies the default sort order from the data source option", async ({ page }) => {
const grid = new DataGridPage(page);
await page.goto("/");
await waitForMendixApp(page);
await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1)).toHaveText("First Name");
await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1).locator("svg")).toHaveAttribute(
"data-icon",
"arrows-alt-v"
);
await expect(grid.columnHeader(1)).toHaveText("First Name");
await expect(grid.sortIcon(1)).toHaveAttribute("data-icon", "arrows-alt-v");
await expect(page.getByRole("gridcell", { name: "12" }).first()).toHaveText("12");
});

test("changes order of data to ASC when clicking sort option", async ({ page }) => {
const grid = new DataGridPage(page);
await page.goto("/");
await waitForMendixApp(page);
await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1)).toHaveText("First Name");
await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1).locator("svg")).toHaveAttribute(
"data-icon",
"arrows-alt-v"
);
await page.locator(".mx-name-datagrid1 .column-header").nth(1).click();
await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1).locator("svg")).toHaveAttribute(
"data-icon",
"long-arrow-alt-up"
);
await expect(grid.columnHeader(1)).toHaveText("First Name");
await expect(grid.sortIcon(1)).toHaveAttribute("data-icon", "arrows-alt-v");
await grid.sortByColumn(1);
await expect(grid.sortIcon(1)).toHaveAttribute("data-icon", "long-arrow-alt-up");
await expect(page.getByRole("gridcell", { name: "10" }).first()).toHaveText("10");
});

test("changes order of data to DESC when clicking sort option", async ({ page }) => {
const grid = new DataGridPage(page);
await page.goto("/");
await waitForMendixApp(page);
await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1)).toHaveText("First Name");
await page.locator(".mx-name-datagrid1 .column-header").nth(1).click();
await page.locator(".mx-name-datagrid1 .column-header").nth(1).click();
await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1).locator("svg")).toHaveAttribute(
"data-icon",
"long-arrow-alt-down"
);
await expect(grid.columnHeader(1)).toHaveText("First Name");
await grid.sortByColumn(1);
await grid.sortByColumn(1);
await expect(grid.sortIcon(1)).toHaveAttribute("data-icon", "long-arrow-alt-down");
await expect(page.getByRole("gridcell", { name: "12" }).first()).toHaveText("12");
});
});

test.describe("capabilities: hiding", () => {
test("hides a selected column", async ({ page }) => {
const grid = new DataGridPage(page);
await page.goto("/");
await waitForMendixApp(page);
await expect(page.locator(".mx-name-datagrid1 .column-header").first()).toHaveText("Age");
await page.locator(".mx-name-datagrid1 .column-selector-button").click();
await page.locator(".column-selectors > li").first().click();
await expect(page.locator(".mx-name-datagrid1 .column-header").first()).toHaveText("First Name");
await expect(grid.columnHeaders.first()).toHaveText("Age");
await grid.openColumnSelector();
await grid.columnSelectorItems.first().click();
await expect(grid.columnHeaders.first()).toHaveText("First Name");
});

test("hide column saved on configuration attribute capability", async ({ page }) => {
const grid = new DataGridPage(page, "datagrid5");
await page.goto("/");
await waitForMendixApp(page);

// hide first column
await page.locator(".mx-name-datagrid5 .column-selector-button").click();
await page.locator(".column-selectors > li").first().click();
await grid.openColumnSelector();
await grid.columnSelectorItems.first().click();

// check if it is really hidden
await expect(page.locator(".mx-name-datagrid5 .column-header").first()).toHaveText("Last Name");
await expect(grid.columnHeaders.first()).toHaveText("Last Name");

// check config saved to the attribute and visible in the text area
const textArea = page.locator(".mx-name-textArea1 textarea");
Expand All @@ -130,79 +119,79 @@ test.describe("capabilities: hiding", () => {
});
});
test("hide column by default enabled", async ({ page }) => {
const grid = new DataGridPage(page, "datagrid6");
await page.goto("/");
await waitForMendixApp(page);
await expect(page.locator(".mx-name-datagrid6 .column-header").first()).toHaveText("First Name");
await page.locator(".mx-name-datagrid6 .column-selector-button").click();
await page.locator(".column-selectors > li").first().click();
await expect(page.locator(".mx-name-datagrid6 .column-header").first()).toHaveText("Id");
await expect(grid.columnHeaders.first()).toHaveText("First Name");
await grid.openColumnSelector();
await grid.columnSelectorItems.first().click();
await expect(grid.columnHeaders.first()).toHaveText("Id");
});

test("do not allow to hide last visible column", async ({ page }) => {
const grid = new DataGridPage(page);
await page.goto("/");
await waitForMendixApp(page);
await expect(page.locator(".mx-name-datagrid1 .column-header").first()).toBeVisible();
await page.locator(".mx-name-datagrid1 .column-selector-button").click();
await expect(page.locator(".column-selectors input:checked")).toHaveCount(4);
await page.locator(".column-selectors > li").nth(3).click();
await page.locator(".column-selectors > li").nth(2).click();
await page.locator(".column-selectors > li").nth(1).click();
await expect(page.locator(".column-selectors input:checked")).toHaveCount(1);
await page.locator(".column-selectors > li").nth(0).click({ force: true });
await expect(page.locator(".column-selectors input:checked")).toHaveCount(1);
await expect(grid.columnHeaders.first()).toBeVisible();
await grid.openColumnSelector();
await expect(grid.checkedColumns).toHaveCount(4);
await grid.columnSelectorItem(3).click();
await grid.columnSelectorItem(2).click();
await grid.columnSelectorItem(1).click();
await expect(grid.checkedColumns).toHaveCount(1);
await grid.columnSelectorItem(0).click({ force: true });
await expect(grid.checkedColumns).toHaveCount(1);
// Trigger Enter keypress
await page.locator(".column-selectors > li").nth(0).press("Enter");
await expect(page.locator(".column-selectors input:checked")).toHaveCount(1);
await grid.columnSelectorItem(0).press("Enter");
await expect(grid.checkedColumns).toHaveCount(1);
// Trigger Space keypress
await page.locator(".column-selectors > li").nth(0).press("Space");
await expect(page.locator(".column-selectors input:checked")).toHaveCount(1);
await grid.columnSelectorItem(0).press("Space");
await expect(grid.checkedColumns).toHaveCount(1);
});
});

test.describe("capabilities: onClick action", () => {
test("check the context", async ({ page }) => {
const grid = new DataGridPage(page);
await page.goto("/");
await waitForMendixApp(page);
await expect(page.locator(".mx-name-datagrid1 .td").first()).toHaveText("12");
await page.locator(".mx-name-datagrid1 .td").first().click();
await expect(grid.cells.first()).toHaveText("12");
await grid.cells.first().click();
await expect(page.locator(".mx-name-AgeTextBox input")).toHaveValue("12");
});
});

test.describe("manual column width", () => {
test("compares with a screenshot baseline and checks the column width is with correct size", async ({ page }) => {
const grid = new DataGridPage(page, "datagrid7");
await page.goto("/");
await waitForMendixApp(page);
await page.locator(".mx-name-datagrid7").scrollIntoViewIfNeeded();
await expect(page.locator(".mx-name-datagrid7")).toHaveScreenshot(`dataGridColumnContent.png`);
await grid.root.scrollIntoViewIfNeeded();
await expect(grid.root).toHaveScreenshot(`dataGridColumnContent.png`);
});
});

test.describe("visual testing:", () => {
test("compares with a screenshot baseline and checks if all datagrid and filter elements are rendered as expected", async ({
page
}) => {
const grid = new DataGridPage(page);
await page.goto("/");
await waitForMendixApp(page);
await expect(page.locator(".mx-name-datagrid1")).toBeVisible();
await expect(page.locator(".mx-name-datagrid1")).toHaveScreenshot(`datagrid.png`);
await expect(grid.root).toBeVisible();
await expect(grid.root).toHaveScreenshot(`datagrid.png`);
});

test("compares with a screenshot baseline and checks datagrid using virtual scrolling are rendered as expected", async ({
page
}) => {
const grid = new DataGridPage(page, "dataGrid21");
await page.goto("/p/virtual-scrolling");
await waitForMendixApp(page);
await expect(page.locator(".mx-name-dataGrid21")).toBeVisible();
await page.locator(".mx-name-dataGrid21 .mx-name-textFilter1 .filter-selector-content .btn").click();
await expect(grid.root).toBeVisible();
await grid.root.locator(".mx-name-textFilter1 .filter-selector-content .btn").click();
await expect(page.locator(".mx-page")).toHaveScreenshot(`datagrid-virtual-scrolling.png`);
});
});

test.describe("a11y testing:", () => {
test("checks accessibility violations", async ({ page }) => {
const grid = new DataGridPage(page);
await page.goto("/");
await waitForMendixApp(page);
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(["wcag21aa"])
.exclude(".mx-name-navigationTree3")
Expand Down
Loading
Loading