From 0b1c1e18a60723b20d272412c2c2bf956b4ec9ff Mon Sep 17 00:00:00 2001 From: Klein Date: Tue, 26 May 2026 14:29:34 +0200 Subject: [PATCH 1/4] feat: initital playwright tests --- .github/workflows/linting.yml | 4 + .github/workflows/verify-new-release.yml | 9 + package-lock.json | 131 +++++----- package.json | 6 +- playwright.config.ts | 22 ++ src/helpers/ast-to-query.test.ts | 63 +++++ src/helpers/human-readable.test.ts | 84 +++++++ src/stores/query.test.ts | 114 +++++++++ src/stores/response.test.ts | 29 +++ test-results/.last-run.json | 4 + tests/e2e/catalogue.spec.ts | 102 ++++++++ tests/e2e/helpers.ts | 164 +++++++++++++ tests/e2e/language.spec.ts | 53 +++++ tests/e2e/negotiate.spec.ts | 78 ++++++ tests/e2e/query-explain.spec.ts | 95 ++++++++ tests/e2e/results.spec.ts | 122 ++++++++++ tests/e2e/search-bar.spec.ts | 290 +++++++++++++++++++++++ tests/e2e/toast.spec.ts | 33 +++ vite.config.ts | 2 + 19 files changed, 1338 insertions(+), 67 deletions(-) create mode 100644 playwright.config.ts create mode 100644 src/helpers/human-readable.test.ts create mode 100644 src/stores/query.test.ts create mode 100644 test-results/.last-run.json create mode 100644 tests/e2e/catalogue.spec.ts create mode 100644 tests/e2e/helpers.ts create mode 100644 tests/e2e/language.spec.ts create mode 100644 tests/e2e/negotiate.spec.ts create mode 100644 tests/e2e/query-explain.spec.ts create mode 100644 tests/e2e/results.spec.ts create mode 100644 tests/e2e/search-bar.spec.ts create mode 100644 tests/e2e/toast.spec.ts diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index b3b625ab..10167bc9 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -22,6 +22,10 @@ jobs: - run: npx eslint . - run: npx vite build - run: npx vitest + - run: npx vite build --config vite.config.demo.js + - run: npx playwright install chromium --with-deps + - run: npx playwright test + - run: bash scripts/generate-json-schema.bash --check - run: npx typedoc src/index.ts - name: Install latest mdbook diff --git a/.github/workflows/verify-new-release.yml b/.github/workflows/verify-new-release.yml index cd5b1132..e0874ce9 100644 --- a/.github/workflows/verify-new-release.yml +++ b/.github/workflows/verify-new-release.yml @@ -20,3 +20,12 @@ jobs: echo "::error:: Please run `npm run version` before merging to main!"; \ exit 1; \ fi + - uses: actions/setup-node@v6 + with: + node-version: latest + - run: npm ci + - run: npx playwright install chromium --with-deps + - name: "E2E tests against live demo" + run: npx playwright test + env: + DEMO_URL: https://samply.github.io/lens/demo/ diff --git a/package-lock.json b/package-lock.json index 18315889..3ae92de2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@samply/lens", - "version": "0.6.7", + "version": "0.6.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@samply/lens", - "version": "0.6.7", + "version": "0.6.8", "license": "Apache-2.0", "dependencies": { "@tailwindcss/vite": "^4.3.0", @@ -19,6 +19,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.50.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@types/node": "^25.6.0", "@types/uuid": "^11.0.0", @@ -1098,6 +1099,22 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -1206,9 +1223,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1222,9 +1236,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1238,9 +1249,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1254,9 +1262,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1270,9 +1275,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1286,9 +1288,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1302,9 +1301,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1318,9 +1314,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1334,9 +1327,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1350,9 +1340,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1366,9 +1353,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1382,9 +1366,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1398,9 +1379,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1838,9 +1816,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1857,9 +1832,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1876,9 +1848,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1895,9 +1864,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4445,9 +4411,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4468,9 +4431,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4491,9 +4451,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4514,9 +4471,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5148,6 +5102,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/package.json b/package.json index f606308c..36ddfc0d 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "schemagen": "bash scripts/generate-json-schema.bash", "book": "mdbook serve book --open", "typedoc": "typedoc src/index.ts --watch", - "test": "vitest" + "test": "vitest", + "test:e2e": "playwright test" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -56,7 +57,8 @@ "typescript-eslint": "^8.59.1", "vite": "^7.3.1", "vite-plugin-dts": "^4.5.4", - "vitest": "^4.1.5" + "vitest": "^4.1.5", + "@playwright/test": "^1.50.0" }, "dependencies": { "@tailwindcss/vite": "^4.3.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..aa1f4823 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "@playwright/test"; + +const DEMO_URL = process.env.DEMO_URL ?? "http://localhost:4173"; + +export default defineConfig({ + testDir: "./tests/e2e", + use: { + baseURL: DEMO_URL, + headless: true, + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + // When DEMO_URL is set (e.g. live GH Pages), no local server is needed. + // Otherwise start vite preview (assumes demo is already built). + webServer: process.env.DEMO_URL + ? undefined + : { + command: "npm run preview:demo", + url: "http://localhost:4173", + reuseExistingServer: true, + }, +}); diff --git a/src/helpers/ast-to-query.test.ts b/src/helpers/ast-to-query.test.ts index b80ff988..de4583ff 100644 --- a/src/helpers/ast-to-query.test.ts +++ b/src/helpers/ast-to-query.test.ts @@ -181,3 +181,66 @@ test("setQueryStoreFromAst: string value", () => { ], }); }); + +test("getAst: empty queryStore returns top-level OR with no children", () => { + setQueryStoreFromAst({ operand: "OR", children: [] }); + expect(getAst()).toEqual({ operand: "OR", children: [] }); +}); + +test("getAst: 2-group OR query produces two AND children at top level", () => { + const ast: AstTopLayer = { + operand: "OR", + children: [ + { + operand: "AND", + children: [ + { + key: "gender", + operand: "OR", + children: [{ key: "gender", type: "EQUALS", value: "male" }], + }, + ], + }, + { + operand: "AND", + children: [ + { + key: "gender", + operand: "OR", + children: [{ key: "gender", type: "EQUALS", value: "female" }], + }, + ], + }, + ], + }; + testConversion(ast); + expect(getAst().children).toHaveLength(2); +}); + +test("getAst: AND within a group produces multiple children under a single AND node", () => { + const ast: AstTopLayer = { + operand: "OR", + children: [ + { + operand: "AND", + children: [ + { + key: "gender", + operand: "OR", + children: [{ key: "gender", type: "EQUALS", value: "male" }], + }, + { + key: "sample-id", + operand: "OR", + children: [{ key: "sample-id", type: "EQUALS", value: "S1" }], + }, + ], + }, + ], + }; + testConversion(ast); + const top = getAst(); + expect(top.children).toHaveLength(1); + const andNode = top.children[0]; + expect("children" in andNode && andNode.children).toHaveLength(2); +}); diff --git a/src/helpers/human-readable.test.ts b/src/helpers/human-readable.test.ts new file mode 100644 index 00000000..c9d37d44 --- /dev/null +++ b/src/helpers/human-readable.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { get } from "svelte/store"; +import { + getHumanReadableQueryAsFormattedString, + getParsedStringItem, +} from "../stores/datarequests"; +import { queryStore, setQueryStore } from "../stores/query"; +import type { QueryItem } from "../types/queryData"; + +function makeItem(name: string, values: { name: string; value: string }[]): QueryItem { + return { + id: "test-id", + key: name.toLowerCase().replace(/\s/g, "-"), + name, + type: "EQUALS", + values: values.map((v, i) => ({ + ...v, + queryBindId: `qb-${i}`, + })), + }; +} + +beforeEach(() => { + setQueryStore([[]]); +}); + +describe("getParsedStringItem", () => { + test("formats a single string value as 'Name: value'", () => { + const item = makeItem("First name", [{ name: "Olaf", value: "Olaf" }]); + expect(getParsedStringItem(item, false)).toBe("First name: Olaf"); + }); + + test("joins multiple string values with a comma", () => { + const item = makeItem("Gender", [ + { name: "male", value: "male" }, + { name: "female", value: "female" }, + ]); + expect(getParsedStringItem(item, false)).toBe("Gender: male, female"); + }); +}); + +describe("getHumanReadableQueryAsFormattedString", () => { + test("returns empty string for an empty query", () => { + expect(getHumanReadableQueryAsFormattedString()).toBe(""); + }); + + test("1-group query contains the group header and the filter", () => { + setQueryStore([ + [makeItem("First name", [{ name: "Olaf", value: "Olaf" }])], + ]); + const result = getHumanReadableQueryAsFormattedString(); + // translate("query_info_header") → "Search ANY of the following groups" + expect(result).toContain("Search ANY of the following groups"); + // translate("query_info_group_header") + " 1" → "Group 1" + expect(result).toContain("Group 1"); + expect(result).toContain("First name: Olaf"); + }); + + test("2-group OR query contains both group headers and their filters", () => { + setQueryStore([ + [makeItem("First name", [{ name: "Olaf", value: "Olaf" }])], + [makeItem("Gender", [{ name: "male", value: "male" }])], + ]); + const result = getHumanReadableQueryAsFormattedString(); + expect(result).toContain("Group 1"); + expect(result).toContain("Group 2"); + expect(result).toContain("First name: Olaf"); + expect(result).toContain("Gender: male"); + }); + + test("AND within a group lists all items under the same group number", () => { + setQueryStore([ + [ + makeItem("First name", [{ name: "Olaf", value: "Olaf" }]), + makeItem("Gender", [{ name: "male", value: "male" }]), + ], + ]); + const result = getHumanReadableQueryAsFormattedString(); + expect(result).toContain("Group 1"); + expect(result).not.toContain("Group 2"); + expect(result).toContain("First name: Olaf"); + expect(result).toContain("Gender: male"); + }); +}); diff --git a/src/stores/query.test.ts b/src/stores/query.test.ts new file mode 100644 index 00000000..1bd8dc3f --- /dev/null +++ b/src/stores/query.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { get } from "svelte/store"; +import { + queryStore, + addItemToQuery, + removeValueFromQuery, + removeItemFromQuery, + setQueryStore, +} from "./query"; +import type { QueryItem } from "../types/queryData"; + +function makeItem(overrides: Partial = {}): QueryItem { + return { + id: "id-1", + key: "gender", + name: "Gender", + type: "EQUALS", + values: [{ name: "male", value: "male", queryBindId: "qb-1" }], + ...overrides, + }; +} + +beforeEach(() => { + setQueryStore([[]]); +}); + +describe("addItemToQuery", () => { + test("adds item to an existing group", () => { + addItemToQuery(makeItem(), 0); + const store = get(queryStore); + expect(store[0]).toHaveLength(1); + expect(store[0][0].name).toBe("Gender"); + }); + + test("merges duplicate names in the same group (union of values)", () => { + addItemToQuery(makeItem(), 0); + addItemToQuery( + makeItem({ + id: "id-2", + values: [{ name: "female", value: "female", queryBindId: "qb-2" }], + }), + 0, + ); + const store = get(queryStore); + // Both calls target the same name "Gender" → merged into one item + expect(store[0]).toHaveLength(1); + expect(store[0][0].values).toHaveLength(2); + }); + + test("creates a new group when index equals current length", () => { + addItemToQuery(makeItem(), 1); // group 0 exists, index 1 creates group 1 + const store = get(queryStore); + expect(store).toHaveLength(2); + expect(store[1][0].name).toBe("Gender"); + }); + + test("clamps negative index to 0", () => { + addItemToQuery(makeItem(), -5); + const store = get(queryStore); + expect(store[0]).toHaveLength(1); + }); + + test("clamps out-of-range index to last possible position", () => { + addItemToQuery(makeItem(), 99); + const store = get(queryStore); + // Group 0 was the only group; a new group was created at index 1 + expect(store).toHaveLength(2); + }); +}); + +describe("removeValueFromQuery", () => { + test("removes one value from a multi-value item; item stays", () => { + const item = makeItem({ + values: [ + { name: "male", value: "male", queryBindId: "qb-1" }, + { name: "female", value: "female", queryBindId: "qb-2" }, + ], + }); + addItemToQuery(item, 0); + + removeValueFromQuery( + { ...item, values: [{ name: "male", value: "male", queryBindId: "qb-1" }] }, + 0, + ); + + const store = get(queryStore); + expect(store[0]).toHaveLength(1); + expect(store[0][0].values).toHaveLength(1); + expect(store[0][0].values[0].name).toBe("female"); + }); + + test("removing the last value of an item removes the item", () => { + addItemToQuery(makeItem(), 0); + removeValueFromQuery(makeItem(), 0); + + const store = get(queryStore); + expect(store[0]).toHaveLength(0); + }); +}); + +describe("removeItemFromQuery", () => { + test("removes the item by id", () => { + const item = makeItem({ id: "remove-me" }); + const other = makeItem({ id: "keep-me", name: "First name", key: "first-name" }); + addItemToQuery(item, 0); + addItemToQuery(other, 0); + + removeItemFromQuery(item, 0); + + const store = get(queryStore); + expect(store[0]).toHaveLength(1); + expect(store[0][0].id).toBe("keep-me"); + }); +}); diff --git a/src/stores/response.test.ts b/src/stores/response.test.ts index 0de63030..2258873d 100644 --- a/src/stores/response.test.ts +++ b/src/stores/response.test.ts @@ -67,3 +67,32 @@ test("getStrata", () => { ["female", "male", "unknown"].sort(), ); }); + +import { + markSiteClaimed, + removeFailedSite, + clearSiteResults, + siteStatus, +} from "./response"; + +test("markSiteClaimed marks a site as 'claimed' in siteStatus", () => { + markSiteClaimed("pending-site"); + const status = get(siteStatus); + expect(status.get("pending-site")).toBe("claimed"); +}); + +test("removeFailedSite removes the site from both siteStatus and siteResults", () => { + setSiteResult("to-remove", { stratifiers: {}, totals: { patients: 5 } }); + markSiteClaimed("to-remove"); + removeFailedSite("to-remove"); + expect(get(siteStatus).has("to-remove")).toBe(false); + expect(get(siteResults).has("to-remove")).toBe(false); +}); + +test("clearSiteResults empties both siteResults and siteStatus", () => { + setSiteResult("site-a", { stratifiers: {}, totals: { patients: 1 } }); + markSiteClaimed("site-b"); + clearSiteResults(); + expect(get(siteResults).size).toBe(0); + expect(get(siteStatus).size).toBe(0); +}); diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 00000000..cbcc1fba --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/tests/e2e/catalogue.spec.ts b/tests/e2e/catalogue.spec.ts new file mode 100644 index 00000000..0ce146c4 --- /dev/null +++ b/tests/e2e/catalogue.spec.ts @@ -0,0 +1,102 @@ +/** + * Section 6 — Catalogue panel. + */ +import { test, expect } from "@playwright/test"; +import { getChipTexts } from "./helpers"; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page.locator('[part~="lens-searchbar-input"]').first().waitFor(); +}); + +test("6.1 all top-level catalogue categories are visible", async ({ page }) => { + const catalogue = page.locator("lens-catalogue"); + await expect(catalogue).toBeVisible(); + + for (const name of [ + "Diagnosis", + "Blood group", + "Body weight", + "Date of birth", + "First name", + "Donor/Clinical Information", + "Tumorentität", + "Sample ID", + ]) { + await expect(catalogue.getByText(name, { exact: true }).first()).toBeVisible(); + } +}); + +test("6.2 expanding 'Donor/Clinical Information' shows child categories", async ({ + page, +}) => { + const catalogue = page.locator("lens-catalogue"); + // The group has a toggle button/chevron; click the group header + const groupHeader = catalogue.getByText("Donor/Clinical Information", { + exact: true, + }); + await groupHeader.click(); + await page.waitForTimeout(300); + + await expect(catalogue.getByText("Gender", { exact: true })).toBeVisible(); + await expect( + catalogue.getByText("Diagnosis ICD-10", { exact: true }), + ).toBeVisible(); + await expect(catalogue.getByText("Diagnosis age", { exact: true })).toBeVisible(); +}); + +test("6.3 clicking Gender 'male' criterion in catalogue adds a chip", async ({ + page, +}) => { + const catalogue = page.locator("lens-catalogue"); + // Expand the group first + await catalogue.getByText("Donor/Clinical Information", { exact: true }).click(); + await page.waitForTimeout(300); + + // Gender sub-group may need expanding too + const genderSection = catalogue.locator("text=Gender").first(); + await genderSection.click(); + await page.waitForTimeout(300); + + // Click the "+" button next to "male" + const maleRow = catalogue.locator("text=male").first(); + // The add button is adjacent; look for a button within the same list item + await maleRow.locator("..").locator("button").first().click(); + await page.waitForTimeout(300); + + const chips = await getChipTexts(page); + expect(chips.some((t) => t.includes("male"))).toBe(true); +}); + +test("6.4 submitting an empty string input in catalogue shows validation error", async ({ + page, +}) => { + // Click "First name" in catalogue to show the string input + const firstNameEntry = page + .locator("lens-catalogue") + .getByText("First name", { exact: true }); + await firstNameEntry.click(); + await page.waitForTimeout(200); + + // Find the string form and submit without entering a value + const stringForm = page + .locator("lens-catalogue") + .locator('[part~="lens-string-form"]') + .first(); + await expect(stringForm).toBeVisible(); + + // Try to submit via the AddButton (the + inside the form) + await stringForm.locator("button").click(); + + // Browser native validation makes the input invalid; check validity state + const isInvalid = await page.evaluate(() => { + const input = document + .querySelector("lens-catalogue") + ?.shadowRoot?.querySelector('[part~="lens-string-formfield"]') as + | HTMLInputElement + | null; + return input ? !input.validity.valid : false; + }); + expect(isInvalid).toBe(true); +}); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 00000000..91e10819 --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,164 @@ +/** + * Shadow DOM helpers for Lens E2E tests. + * + * All SearchBar content (autocomplete, chips, string inputs) renders inline + * inside lens-search-bar-multiple's single shadow root — there is no nested + * custom-element boundary there. Button components (lens-search-button, + * lens-negotiate-button, lens-query-explain-button) each have their own + * shadow root. + */ +import type { Page } from "@playwright/test"; + +// ─── Search bar ────────────────────────────────────────────────────────────── + +export async function typeInLastSearchBar(page: Page, text: string) { + const input = page.locator('[part~="lens-searchbar-input"]').last(); + await input.click(); + await input.fill(text); +} + +export async function typeInFirstSearchBar(page: Page, text: string) { + const input = page.locator('[part~="lens-searchbar-input"]').first(); + await input.click(); + await input.fill(text); +} + +/** + * Click an autocomplete item by its exact criterion name. + * Uses mousedown because the component closes the list on focusout, which + * fires before a plain click completes. + */ +export async function clickAutocompleteItem(page: Page, exactName: string) { + await page.evaluate((name: string) => { + const root = document.querySelector( + "lens-search-bar-multiple", + )?.shadowRoot; + const items = root?.querySelectorAll( + '[part~="lens-searchbar-autocomplete-options-item"]', + ); + if (!items?.length) + throw new Error("No autocomplete items visible in shadow DOM"); + for (const item of items) { + const nameEl = item.querySelector( + '[part~="lens-searchbar-autocomplete-options-item-name"]', + ); + if (nameEl?.textContent?.trim() === name) { + item.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + return; + } + } + const found = Array.from(items) + .map( + (i) => + i + .querySelector( + '[part~="lens-searchbar-autocomplete-options-item-name"]', + ) + ?.textContent?.trim() ?? "?", + ) + .join(", "); + throw new Error(`Item "${name}" not found. Visible: ${found}`); + }, exactName); +} + +/** + * Add a string-type filter (e.g. First name: Olaf) via the search bar. + * searchTerm must produce at least 2 chars to open the autocomplete. + */ +export async function addStringFilter( + page: Page, + searchTerm: string, + value: string, +) { + await typeInLastSearchBar(page, searchTerm); + const field = page.locator('[part~="lens-string-formfield"]').first(); + await field.waitFor({ state: "visible" }); + await field.fill(value); + await field.press("Enter"); +} + +export async function addOrBar(page: Page) { + await page + .locator('[part~="lens-searchbar-multiple-add-button"]') + .click(); + await page.waitForTimeout(200); +} + +// ─── Buttons ───────────────────────────────────────────────────────────────── + +export async function clickSearchButton(page: Page) { + await page.evaluate(() => { + const btn = document + .querySelector("lens-search-button") + ?.shadowRoot?.querySelector('[part~="lens-search-button"]'); + if (!btn) throw new Error("lens-search-button not found in shadow DOM"); + (btn as HTMLElement).click(); + }); +} + +export async function clickNegotiateButton(page: Page) { + await page.evaluate(() => { + const btn = document + .querySelector("lens-negotiate-button") + ?.shadowRoot?.querySelector('[part~="lens-negotiate-button"]'); + if (!btn) + throw new Error("lens-negotiate-button not found in shadow DOM"); + (btn as HTMLElement).click(); + }); +} + +export async function clickQueryExplainButton(page: Page) { + await page.evaluate(() => { + const root = document.querySelector( + "lens-query-explain-button", + )?.shadowRoot; + if (!root) throw new Error("No shadow root on lens-query-explain-button"); + // InfoButtonComponent renders a