diff --git a/docs/requirements/e2e-test-guidelines.md b/docs/requirements/e2e-test-guidelines.md index efbdfcc3d6..53af40402d 100644 --- a/docs/requirements/e2e-test-guidelines.md +++ b/docs/requirements/e2e-test-guidelines.md @@ -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/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-`); 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`) @@ -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"; diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js index 8caa1f8bad..705f821462 100644 --- a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js +++ b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js @@ -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. @@ -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"); @@ -130,51 +119,51 @@ 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`); }); }); @@ -182,27 +171,27 @@ 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") diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js index d4e3e14eca..94857c3bd0 100644 --- a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js +++ b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js @@ -1,71 +1,60 @@ -import { expect, test } from "@mendix/run-e2e/fixtures"; -import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers"; import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@mendix/run-e2e/fixtures"; +import { DataGridPage } from "./pages/DataGridPage"; test.describe("datagrid-web selection", async () => { test("applies checkbox single selection checkbox", async ({ page }) => { - const singleSelectionCheckbox = page.locator(".mx-name-dgSingleSelectionCheckbox"); + const grid = new DataGridPage(page, "dgSingleSelectionCheckbox"); await page.goto("/p/single-selection"); - await waitForMendixApp(page); - await expect(singleSelectionCheckbox).toBeVisible(); - await singleSelectionCheckbox.locator("input").first().click(); + await expect(grid.root).toBeVisible(); + await grid.root.locator("input").first().click(); await expect(page).toHaveScreenshot(`datagridSingleSelectionCheckbox.png`); }); test("applies checkbox single selection row click", async ({ page }) => { - const singleSelectionRowClick = page.locator(".mx-name-dgSingleSelectionRowClick"); + const grid = new DataGridPage(page, "dgSingleSelectionRowClick"); await page.goto("/p/single-selection"); - await waitForMendixApp(page); - await expect(singleSelectionRowClick).toBeVisible(); - await singleSelectionRowClick - .locator(".td") - .first() - .click({ modifiers: ["Shift"] }); + await expect(grid.root).toBeVisible(); + await grid.cells.first().click({ modifiers: ["Shift"] }); await expect(page).toHaveScreenshot(`datagridSingleSelectionRowClick.png`); }); test("applies checkbox multi selection checkbox", async ({ page }) => { - const multiSelectionCheckbox = page.locator(".mx-name-dgMultiSelectionCheckbox"); + const grid = new DataGridPage(page, "dgMultiSelectionCheckbox"); await page.goto("/p/multi-selection"); - await waitForMendixApp(page); - await expect(multiSelectionCheckbox).toBeVisible(); - await multiSelectionCheckbox.locator("input").first().click(); - await multiSelectionCheckbox.locator("input").nth(1).click(); + await expect(grid.root).toBeVisible(); + await grid.root.locator("input").first().click(); + await grid.root.locator("input").nth(1).click(); await expect(page).toHaveScreenshot(`datagridMultiSelectionCheckbox.png`); }); test("applies checkbox multi selection row click", async ({ page }) => { - const multiSelectionRowClick = page.locator(".mx-name-dgMultiSelectionRowClick"); + const grid = new DataGridPage(page, "dgMultiSelectionRowClick"); await page.goto("/p/multi-selection"); - await waitForMendixApp(page); - await expect(multiSelectionRowClick).toBeVisible(); - await multiSelectionRowClick.locator(".td").first().click({ force: true }); - await multiSelectionRowClick - .locator(".td") - .nth(4) - .click({ modifiers: ["Shift"] }); + await expect(grid.root).toBeVisible(); + await grid.cells.first().click({ force: true }); + await grid.cells.nth(4).click({ modifiers: ["Shift"] }); await expect(page).toHaveScreenshot(`datagridMultiSelectionRowClick.png`); }); test("checks single selection accessibility with sr-only text", async ({ page }) => { + const grid = new DataGridPage(page, "dgSingleSelectionCheckbox"); await page.goto("/p/single-selection"); - await waitForMendixApp(page); - const singleSelectionCheckbox = page.locator(".mx-name-dgSingleSelectionCheckbox"); - await singleSelectionCheckbox.waitFor(); + await grid.root.waitFor(); // Verify sr-only text is present in the selection column header - const srOnlyText = singleSelectionCheckbox.locator(".widget-datagrid-col-select .sr-only"); + const srOnlyText = grid.root.locator(".widget-datagrid-col-select .sr-only"); await expect(srOnlyText).toHaveText(/Select single row/i); // Verify sr-only text is not visible but accessible await expect(srOnlyText).toBeAttached(); const isHidden = await srOnlyText.evaluate(el => { - const style = window.getComputedStyle(el); + const style = globalThis.getComputedStyle(el); return ( style.position === "absolute" && (style.width === "1px" || style.clip === "rect(0px, 0px, 0px, 0px)") ); @@ -83,10 +72,10 @@ test.describe("datagrid-web selection", async () => { }); test("checks accessibility violations", async ({ page }) => { + const grid = new DataGridPage(page, "dgMultiSelectionCheckbox"); await page.goto("/p/multi-selection"); - await waitForMendixApp(page); - await page.locator(".mx-name-dgMultiSelectionCheckbox").waitFor(); + await grid.root.waitFor(); const accessibilityScanResults = await new AxeBuilder({ page }) .withTags(["wcag21aa"]) .include(".mx-name-dgMultiSelectionCheckbox") diff --git a/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringEmptyString.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringEmptyString.spec.js index e33a2cdb21..175d304712 100644 --- a/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringEmptyString.spec.js +++ b/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringEmptyString.spec.js @@ -1,34 +1,31 @@ import { expect, test } from "@mendix/run-e2e/fixtures"; -import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers"; +import { DataGridPage } from "../pages/DataGridPage"; test.describe("datagrid-web filtering empty strings", () => { + /** @type {DataGridPage} */ + let grid; + test.beforeEach(async ({ page }) => { + grid = new DataGridPage(page, "dataGrid2_1"); await page.goto("/p/filtering-empty-string"); - await waitForMendixApp(page); }); - test("filter rows by Empty and Not empty", async ({ page }) => { - const column = n => page.locator(`[role="gridcell"]:nth-child(${n})`); - const filter = n => page.locator(`[role="columnheader"]:nth-child(${n})`).locator(".filter-container"); - const filterSelectorButton = n => filter(n).getByRole("combobox"); - const filterSelectorOption = (n, name) => - filter(n).getByRole("listbox").getByRole("option", { name, exact: true }); - + test("filter rows by Empty and Not empty", async () => { // all 3 records are shown - await expect(column(1)).toHaveText(["User 1 (with value)", 'User 3 ("")', "User 3 (empty)"]); + await expect(grid.columnCells(1)).toHaveText(["User 1 (with value)", 'User 3 ("")', "User 3 (empty)"]); // select Empty option - await filterSelectorButton(2).click({ delay: 20 }); - await filterSelectorOption(2, "Empty").click({ delay: 20 }); + await grid.headerFilterButton(2).click({ delay: 20 }); + await grid.headerFilterOption(2, "Empty").click({ delay: 20 }); // both, `empty` and `""` records are visible. Record with text is filtered out. - await expect(column(1)).toHaveText(['User 3 ("")', "User 3 (empty)"]); + await expect(grid.columnCells(1)).toHaveText(['User 3 ("")', "User 3 (empty)"]); // select "Not empty" option - await filterSelectorButton(2).click({ delay: 20 }); - await filterSelectorOption(2, "Not empty").click({ delay: 20 }); + await grid.headerFilterButton(2).click({ delay: 20 }); + await grid.headerFilterOption(2, "Not empty").click({ delay: 20 }); // Record with text is visible, `empty` and `""` records are filtered out. - await expect(column(1)).toHaveText(["User 1 (with value)"]); + await expect(grid.columnCells(1)).toHaveText(["User 1 (with value)"]); }); }); diff --git a/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringIntegration.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringIntegration.spec.js index c6ff286e19..b472edf348 100644 --- a/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringIntegration.spec.js +++ b/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringIntegration.spec.js @@ -1,49 +1,37 @@ import { test, expect } from "@mendix/run-e2e/fixtures"; -import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers"; +import { DataGridPage } from "../pages/DataGridPage"; test("datagrid-web filtering integration", async ({ page }) => { - const rows = async () => { - return page.locator('.mx-name-dataGrid21 [role="row"]'); - }; - - const rowCount = await rows(); + const grid = new DataGridPage(page, "dataGrid21"); await page.goto("/p/filtering-integration"); - await waitForMendixApp(page); - await expect(rowCount).toHaveCount(51); + await expect(grid.rows).toHaveCount(51); - await page.getByRole("columnheader", { name: "First name" }).getByRole("textbox").fill("a"); - //await select("First name").fill("a"); - await expect(await rows()).toHaveCount(30); + await grid.headerTextbox("First name").fill("a"); + await expect(grid.rows).toHaveCount(30); - await page.getByRole("columnheader", { name: "Birth date" }).getByRole("textbox").fill("1/1/1990"); - //await select("Birth date").fill("1/1/1990"); - await page.getByRole("columnheader", { name: "First name" }).getByRole("textbox").click(); - await expect(await rows()).toHaveCount(14); + await grid.headerTextbox("Birth date").fill("1/1/1990"); + await grid.headerTextbox("First name").click(); + await expect(grid.rows).toHaveCount(14); - await page.getByRole("columnheader", { name: "Birth year" }).getByRole("textbox").fill("1995"); - await expect(await rows()).toHaveCount(9); + await grid.headerTextbox("Birth year").fill("1995"); + await expect(grid.rows).toHaveCount(9); - await page.getByRole("columnheader", { name: "Color (enum)" }).getByRole("combobox").click(); - //await select("Color (enum)").click(); - await page.locator(`[role="option"]:has-text("Black")`).click({ delay: 1 }); - //await option("Black").click(); - await expect(await rows()).toHaveCount(4); + // option() is page-scoped: the listbox is rendered by a sibling filter widget, not inside the grid root. + await grid.headerCombobox("Color (enum)").click(); + await page.getByRole("option", { name: "Black", exact: true }).click({ delay: 1 }); + await expect(grid.rows).toHaveCount(4); - await page.getByRole("columnheader", { name: "Roles (ref set)" }).getByRole("combobox").click(); - //await select("Roles (ref set)").click(); - await page.locator(`[role="option"]:has-text("Careers adviser")`).click({ delay: 1 }); - //await option("Careers adviser").click(); - await expect(await rows()).toHaveCount(3); + await grid.headerCombobox("Roles (ref set)").click(); + await page.getByRole("option", { name: "Careers adviser", exact: true }).click({ delay: 1 }); + await expect(grid.rows).toHaveCount(3); - await page.getByRole("columnheader", { name: "Company" }).getByRole("combobox").click(); - //await select("Company").click(); - await page.locator(`[role="option"]:has-text("Sierra Health Services Inc")`).click({ delay: 20 }); - //await option("Sierra Health Services Inc").click(); - await expect(await rows()).toHaveCount(2); + await grid.headerCombobox("Company").click(); + await page.getByRole("option", { name: "Sierra Health Services Inc", exact: true }).click({ delay: 20 }); + await expect(grid.rows).toHaveCount(2); - const row = (await rows()).nth(1); + const row = grid.rows.nth(1); await expect(row).toHaveText( "Lina3/3/20042004BlackEnvironmental scientistCareers adviserPrison officerMarket research analystSierra Health Services Inc" ); diff --git a/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringMulti.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringMulti.spec.js index f818cee362..83c9c4e5dc 100644 --- a/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringMulti.spec.js +++ b/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringMulti.spec.js @@ -1,36 +1,30 @@ import { test, expect } from "@mendix/run-e2e/fixtures"; -import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers"; +import { DataGridPage } from "../pages/DataGridPage"; test.describe("datagrid-web filtering multi select", () => { - test("filter rows where enum attribute equal to one of selected values", async ({ page }) => { - const rows = async () => { - return page.locator('.mx-name-dataGrid21 [role="row"]'); - }; - const column = n => page.locator(`[role="gridcell"]:nth-child(${n})`); - const option = label => page.locator(`[role="option"]:has-text("${label}")`); - const enumSelect = () => page.locator(".mx-name-drop_downFilter1[role=combobox]"); - const rowCount = await rows(); + /** @type {DataGridPage} */ + let grid; + + test.beforeEach(async ({ page }) => { + grid = new DataGridPage(page, "dataGrid21"); await page.goto("/p/filtering-multi"); - await waitForMendixApp(page); - await expect(rowCount).toHaveCount(11); - await expect(await column(2).first()).toHaveText("Black"); - await expect(await column(2).last()).toHaveText("Blue"); - await enumSelect().click(); - await option("Pink").click({ delay: 20 }); - await expect(await rows()).toHaveCount(6); - await option("Blush").click({ delay: 20 }); - await expect(await rows()).toHaveCount(8); - await page.getByRole("columnheader", { name: "Color (enum)" }).getByRole("combobox").click({ delay: 20 }); - await expect(column(2)).toContainText(["Pink", "Pink", "Pink", "Blush", "Blush", "Pink", "Pink"]); + }); + + test("filter rows where enum attribute equal to one of selected values", async ({ page }) => { + await expect(grid.rows).toHaveCount(11); + await expect(grid.columnCells(2).first()).toHaveText("Black"); + await expect(grid.columnCells(2).last()).toHaveText("Blue"); + // drop_downFilter widgets are siblings of the grid, not children — page-scoped selector is required. + await page.locator('.mx-name-drop_downFilter1[role="combobox"]').click(); + await page.getByRole("option", { name: "Pink", exact: true }).click({ delay: 20 }); + await expect(grid.rows).toHaveCount(6); + await page.getByRole("option", { name: "Blush", exact: true }).click({ delay: 20 }); + await expect(grid.rows).toHaveCount(8); + await grid.headerCombobox("Color (enum)").click({ delay: 20 }); + await expect(grid.columnCells(2)).toContainText(["Pink", "Pink", "Pink", "Blush", "Blush", "Pink", "Pink"]); }); test("filter rows where ReferenceSet contains at least one of selected objects", async ({ page }) => { - const rows = async () => { - return page.locator('.mx-name-dataGrid21 [role="row"]'); - }; - const column = n => page.locator(`[role="gridcell"]:nth-child(${n})`); - const option = label => page.locator(`[role="option"]:has-text("${label}")`); - const roleSelect = () => page.locator(".mx-name-drop_downFilter3[role=combobox]"); const expectedColumnText = [ "EconomistArmed forces officerTraderHealth service manager", "EconomistArmed forces officerTrader", @@ -42,39 +36,29 @@ test.describe("datagrid-web filtering multi select", () => { "Homeless workerEditorial assistantPublic librarian", "Environmental scientistPublic librarianMaterials specialist" ]; - await page.goto("/p/filtering-multi"); - await waitForMendixApp(page); - await expect(await column(3).first()).toHaveText(expectedColumnText[0]); - await roleSelect().click(); - await option("Economist").click({ delay: 20 }); - await expect(await rows()).toHaveCount(6); - await option("Public librarian").click({ delay: 20 }); - await expect(await rows()).toHaveCount(10); - await roleSelect().click({ delay: 20 }); - await expect(column(3)).toHaveText(expectedColumnText); + await expect(grid.columnCells(3).first()).toHaveText(expectedColumnText[0]); + // drop_downFilter widgets are siblings of the grid, not children — page-scoped selector is required. + await page.locator('.mx-name-drop_downFilter3[role="combobox"]').click(); + await page.getByRole("option", { name: "Economist", exact: true }).click({ delay: 20 }); + await expect(grid.rows).toHaveCount(6); + await page.getByRole("option", { name: "Public librarian", exact: true }).click({ delay: 20 }); + await expect(grid.rows).toHaveCount(10); + await page.locator('.mx-name-drop_downFilter3[role="combobox"]').click({ delay: 20 }); + await expect(grid.columnCells(3)).toHaveText(expectedColumnText); }); test("filter rows where Reference equal to one of selected objects", async ({ page }) => { - const rows = async () => { - return page.locator('.mx-name-dataGrid21 [role="row"]'); - }; - const column = n => page.locator(`[role="gridcell"]:nth-child(${n})`); - const option = label => page.locator(`[role="option"]:has-text("${label}")`); - const companySelect = () => page.locator(".mx-name-drop_downFilter4[role=combobox]"); - - const rowCount = await rows(); - await page.goto("/p/filtering-multi"); - await waitForMendixApp(page); - await expect(rowCount).toHaveCount(11); - await expect(await column(4).first()).toHaveText("W.R. Berkley Corporation"); - await expect(await column(4).last()).toHaveText("PETsMART Inc"); - await companySelect().click({ delay: 20 }); - await option("FMC Corp").click({ delay: 20 }); - await expect(await rows()).toHaveCount(2); - await option("ALLETE, Inc.").click({ delay: 20 }); - await expect(await rows()).toHaveCount(6); - await page.getByRole("columnheader", { name: "Company" }).getByRole("combobox").click({ delay: 20 }); - await expect(column(4)).toContainText([ + await expect(grid.rows).toHaveCount(11); + await expect(grid.columnCells(4).first()).toHaveText("W.R. Berkley Corporation"); + await expect(grid.columnCells(4).last()).toHaveText("PETsMART Inc"); + // drop_downFilter widgets are siblings of the grid, not children — page-scoped selector is required. + await page.locator('.mx-name-drop_downFilter4[role="combobox"]').click({ delay: 20 }); + await page.getByRole("option", { name: "FMC Corp", exact: true }).click({ delay: 20 }); + await expect(grid.rows).toHaveCount(2); + await page.getByRole("option", { name: "ALLETE, Inc.", exact: true }).click({ delay: 20 }); + await expect(grid.rows).toHaveCount(6); + await grid.headerCombobox("Company").click({ delay: 20 }); + await expect(grid.columnCells(4)).toContainText([ "ALLETE, Inc.", "FMC Corp", "ALLETE, Inc.", diff --git a/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringSingle.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringSingle.spec.js index 508f48e657..ffadb7af51 100644 --- a/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringSingle.spec.js +++ b/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringSingle.spec.js @@ -1,124 +1,118 @@ import { expect, test } from "@mendix/run-e2e/fixtures"; -import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers"; +import { DataGridPage } from "../pages/DataGridPage"; test.describe("datagrid-web filtering single select", () => { + /** @type {DataGridPage} */ + let grid; + test.beforeEach(async ({ page }) => { + grid = new DataGridPage(page, "dataGrid21"); await page.goto("/p/filtering-single"); - await waitForMendixApp(page); }); test("compares with a screenshot baseline and checks if all datagrid and filter elements are rendered as expected", async ({ page }) => { - await expect(page.locator(".mx-name-dataGrid21")).toBeVisible(); + await expect(grid.root).toBeVisible(); await expect(page).toHaveScreenshot(`datagridFilteringSingle.png`); }); test("filter rows that have Yes in Pets column", async ({ page }) => { - const column = n => page.locator(`[role="gridcell"]:nth-child(${n})`); - const option = label => page.locator(`[role="option"]:has-text("${label}")`); - const booleanSelect = () => page.locator('.mx-name-drop_downFilter2[role="combobox"]'); - - await booleanSelect().click(); - await option("Yes").click({ delay: 1 }); - await expect(column(3)).toHaveText(["Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes"]); + // drop_downFilter widgets are siblings of the grid, not children — page-scoped selector is required. + await page.locator('.mx-name-drop_downFilter2[role="combobox"]').click(); + await page.getByRole("option", { name: "Yes", exact: true }).click({ delay: 1 }); + await expect(grid.columnCells(3)).toHaveText([ + "Yes", + "Yes", + "Yes", + "Yes", + "Yes", + "Yes", + "Yes", + "Yes", + "Yes", + "Yes" + ]); }); test("filter rows that have No in Pets column", async ({ page }) => { - const column = n => page.locator(`[role="gridcell"]:nth-child(${n})`); - const booleanSelect = () => page.locator('.mx-name-drop_downFilter2[role="combobox"]'); - - await booleanSelect().click(); + // drop_downFilter widgets are siblings of the grid, not children — page-scoped selector is required. + await page.locator('.mx-name-drop_downFilter2[role="combobox"]').click(); await page.getByRole("option", { name: "No", exact: true }).click(); - await expect(column(3).first()).toHaveText("No"); - const columnTexts = await column(3).allTextContents(); + await expect(grid.columnCells(3).first()).toHaveText("No"); + const columnTexts = await grid.columnCells(3).allTextContents(); columnTexts.forEach(text => expect(text).toBe("No")); }); test("reset filter state when empty option is clicked", async ({ page }) => { - const rows = async () => { - return page.locator('.mx-name-dataGrid21 [role="row"]'); - }; - const column = n => page.locator(`[role="gridcell"]:nth-child(${n})`); - const option = label => page.locator(`[role="option"]:has-text("${label}")`); - const booleanSelect = () => page.locator('.mx-name-drop_downFilter2[role="combobox"]'); - - await booleanSelect().click(); - await option("Yes").click({ delay: 20 }); - const rowCount = await rows(); - await expect(rowCount).toHaveCount(11); - await expect(column(3)).toHaveText(["Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes"]); - await booleanSelect().click({ delay: 20 }); + // drop_downFilter widgets are siblings of the grid, not children — page-scoped selector is required. + await page.locator('.mx-name-drop_downFilter2[role="combobox"]').click(); + await page.getByRole("option", { name: "Yes", exact: true }).click({ delay: 20 }); + await expect(grid.rows).toHaveCount(11); + await expect(grid.columnCells(3)).toHaveText([ + "Yes", + "Yes", + "Yes", + "Yes", + "Yes", + "Yes", + "Yes", + "Yes", + "Yes", + "Yes" + ]); + await page.locator('.mx-name-drop_downFilter2[role="combobox"]').click({ delay: 20 }); await page.getByRole("row", { name: "Pets (bool)" }).getByRole("option").first().click(); - await expect(column(3)).toHaveText(["Yes", "Yes", "Yes", "No", "Yes", "No", "No", "Yes", "No", "Yes"]); + await expect(grid.columnCells(3)).toHaveText([ + "Yes", + "Yes", + "Yes", + "No", + "Yes", + "No", + "No", + "Yes", + "No", + "Yes" + ]); }); test("filter rows that have Cyan in Color column", async ({ page }) => { - const rows = async () => { - return page.locator('.mx-name-dataGrid21 [role="row"]'); - }; - const column = n => page.locator(`[role="gridcell"]:nth-child(${n})`); - const option = label => page.locator(`[role="option"]:has-text("${label}")`); - const enumSelect = () => page.locator('.mx-name-drop_downFilter1[role="combobox"]'); - - await enumSelect().click(); - await option("Cyan").click({ delay: 1 }); - const rowCount = await rows(); - await expect(rowCount).toHaveCount(6); - const columnTexts = await column(2).allTextContents(); + // drop_downFilter widgets are siblings of the grid, not children — page-scoped selector is required. + await page.locator('.mx-name-drop_downFilter1[role="combobox"]').click(); + await page.getByRole("option", { name: "Cyan", exact: true }).click({ delay: 1 }); + await expect(grid.rows).toHaveCount(6); + const columnTexts = await grid.columnCells(2).allTextContents(); columnTexts.forEach(text => expect(text).toBe("Cyan")); }); test("filter rows that have Black in Color column", async ({ page }) => { - const rows = async () => { - return page.locator('.mx-name-dataGrid21 [role="row"]'); - }; - const column = n => page.locator(`[role="gridcell"]:nth-child(${n})`); - const option = label => page.locator(`[role="option"]:has-text("${label}")`); - const enumSelect = () => page.locator('.mx-name-drop_downFilter1[role="combobox"]'); - - await enumSelect().click(); - await option("Black").click({ delay: 1 }); - const rowCount = await rows(); - await expect(rowCount).toHaveCount(9); - const columnTexts = await column(2).allTextContents(); + // drop_downFilter widgets are siblings of the grid, not children — page-scoped selector is required. + await page.locator('.mx-name-drop_downFilter1[role="combobox"]').click(); + await page.getByRole("option", { name: "Black", exact: true }).click({ delay: 1 }); + await expect(grid.rows).toHaveCount(9); + const columnTexts = await grid.columnCells(2).allTextContents(); columnTexts.forEach(text => expect(text).toBe("Black")); }); test("filter rows that match selected role", async ({ page }) => { - const rows = async () => { - return page.locator('.mx-name-dataGrid21 [role="row"]'); - }; - const column = n => page.locator(`[role="gridcell"]:nth-child(${n})`); - const option = label => page.locator(`[role="option"]:has-text("${label}")`); - const roleSelect = () => page.locator('.mx-name-drop_downFilter3[role="combobox"]'); - - const rowCount = await rows(); - await expect(rowCount).toHaveCount(11); - await roleSelect().click(); - await option("Trader").click({ delay: 1 }); - const rowCount2 = await rows(); - await expect(rowCount2).toHaveCount(8); - const columnTexts = await column(4).allTextContents(); + await expect(grid.rows).toHaveCount(11); + // drop_downFilter widgets are siblings of the grid, not children — page-scoped selector is required. + await page.locator('.mx-name-drop_downFilter3[role="combobox"]').click(); + await page.getByRole("option", { name: "Trader", exact: true }).click({ delay: 1 }); + await expect(grid.rows).toHaveCount(8); + const columnTexts = await grid.columnCells(4).allTextContents(); columnTexts.forEach(text => expect(text).toContain("Trader")); }); test("filter rows that match selected company", async ({ page }) => { - const rows = async () => { - return page.locator('.mx-name-dataGrid21 [role="row"]'); - }; - const column = n => page.locator(`[role="gridcell"]:nth-child(${n})`); - const option = label => page.locator(`[role="option"]:has-text("${label}")`); - const companySelect = () => page.locator('.mx-name-drop_downFilter4[role="combobox"]'); - - const rowCount = await rows(); - await expect(rowCount).toHaveCount(11); - await companySelect().click(); - await option("PETsMART Inc").click({ delay: 1 }); - const rowCount2 = await rows(); - await expect(rowCount2).toHaveCount(9); - const columnTexts = await column(5).allTextContents(); + await expect(grid.rows).toHaveCount(11); + // drop_downFilter widgets are siblings of the grid, not children — page-scoped selector is required. + await page.locator('.mx-name-drop_downFilter4[role="combobox"]').click(); + await page.getByRole("option", { name: "PETsMART Inc", exact: true }).click({ delay: 1 }); + await expect(grid.rows).toHaveCount(9); + const columnTexts = await grid.columnCells(5).allTextContents(); columnTexts.forEach(text => expect(text).toBe("PETsMART Inc")); }); }); diff --git a/packages/pluggableWidgets/datagrid-web/e2e/pages/DataGridPage.js b/packages/pluggableWidgets/datagrid-web/e2e/pages/DataGridPage.js new file mode 100644 index 0000000000..8493faf079 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/e2e/pages/DataGridPage.js @@ -0,0 +1,101 @@ +/** + * Page Object Model for the datagrid-web widget E2E tests. + * + * A `DataGridPage` wraps a single Data Grid instance (identified by its + * Mendix `mx-name`) and exposes locators and actions scoped to that widget's + * DOM subtree. Navigation (`page.goto`) and page-level selectors (e.g. + * drop-down filter widgets that are siblings of the grid, not children) stay + * in the spec's `beforeEach` / test body — this class has no `this.page`. + */ +export class DataGridPage { + /** + * @param {import("@playwright/test").Page} page + * @param {string} [name="datagrid1"] the grid's mx-name (without the `.mx-name-` prefix) + */ + constructor(page, name = "datagrid1") { + this.root = page.locator(`.mx-name-${name}`); + } + + // --- Columns & headers ------------------------------------------------- + + get columnHeaders() { + return this.root.locator(".column-header"); + } + + columnHeader(n) { + return this.columnHeaders.nth(n); + } + + /** Sort indicator icon inside a column header. */ + sortIcon(n) { + return this.columnHeader(n).locator("svg"); + } + + /** Click a column header to cycle its sort order. */ + async sortByColumn(n) { + await this.columnHeader(n).click(); + } + + // --- Cells & rows ------------------------------------------------------ + + get cells() { + return this.root.locator(".td"); + } + + cell(n) { + return this.cells.nth(n); + } + + get rows() { + return this.root.locator('[role="row"]'); + } + + /** All gridcells in the nth column (1-based, matches `:nth-child`). */ + columnCells(n) { + return this.root.locator(`[role="gridcell"]:nth-child(${n})`); + } + + // --- Column selector (hide/show) --------------------------------------- + + async openColumnSelector() { + await this.root.locator(".column-selector-button").click(); + } + + // The column-selector popover is rendered inline inside the grid root + // (Floating UI without a portal), so scope its items to `this.root`. + get columnSelectorItems() { + return this.root.locator(".column-selectors > li"); + } + + columnSelectorItem(n) { + return this.columnSelectorItems.nth(n); + } + + get checkedColumns() { + return this.root.locator(".column-selectors input:checked"); + } + + // --- Filters ----------------------------------------------------------- + + /** Column-header-scoped filter controls (used by header-embedded filters). */ + headerCombobox(name) { + return this.root.getByRole("columnheader", { name }).getByRole("combobox"); + } + + headerTextbox(name) { + return this.root.getByRole("columnheader", { name }).getByRole("textbox"); + } + + /** The `.filter-container` inside the nth column header (1-based). */ + headerFilterContainer(n) { + return this.root.locator(`[role="columnheader"]:nth-child(${n})`).locator(".filter-container"); + } + + headerFilterButton(n) { + return this.headerFilterContainer(n).getByRole("combobox"); + } + + headerFilterOption(n, name) { + return this.headerFilterContainer(n).getByRole("listbox").getByRole("option", { name, exact: true }); + } +}