From 25ef09cb03d7b64ef652963123ee413a608a4b7a Mon Sep 17 00:00:00 2001 From: Wilson Neto Date: Mon, 15 Dec 2025 16:41:18 -0300 Subject: [PATCH] feat: add Playwright E2E testing infrastructure - Add Playwright configuration and test framework - Create e2e tests for tree listing page with proper selectors - Set up strict TypeScript configuration for e2e tests - Add e2e commands to package.json and update scripts - Update .env.example with staging environment as default - Add comprehensive e2e documentation to README - Add Playwright artifacts to .gitignore - Update ESLint and TypeScript configs to support e2e tests - Add vitest config to exclude the e2e folder - Add dotenv - Add a few test-id selectors This establishes a complete E2E testing infrastructure using Playwright with staging as the default test environment, strict TypeScript settings, and proper documentation for developers. --- dashboard/.env.example | 1 + dashboard/.gitignore | 4 + dashboard/README.md | 35 ++++- dashboard/e2e/e2e-selectors.ts | 29 ++++ dashboard/e2e/tree-listing.spec.ts | 143 ++++++++++++++++++ dashboard/eslint.config.mjs | 3 +- dashboard/package.json | 6 +- dashboard/playwright.config.ts | 11 ++ dashboard/pnpm-lock.yaml | 47 ++++++ .../src/components/Breadcrumb/Breadcrumb.tsx | 1 + dashboard/src/components/Select/Select.tsx | 17 ++- dashboard/src/components/TopBar/TopBar.tsx | 12 +- dashboard/tsconfig.e2e.json | 23 +++ dashboard/tsconfig.json | 3 + dashboard/tsconfig.node.json | 2 +- dashboard/vitest.config.ts | 20 +++ 16 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 dashboard/e2e/e2e-selectors.ts create mode 100644 dashboard/e2e/tree-listing.spec.ts create mode 100644 dashboard/playwright.config.ts create mode 100644 dashboard/tsconfig.e2e.json create mode 100644 dashboard/vitest.config.ts diff --git a/dashboard/.env.example b/dashboard/.env.example index eba4b6e3b..1f3944b5b 100644 --- a/dashboard/.env.example +++ b/dashboard/.env.example @@ -2,3 +2,4 @@ # Use port 80 to go through Nginx (proxy service on Docker) VITE_API_BASE_URL=http://localhost:8000 VITE_FEATURE_FLAG_SHOW_DEV=false +PLAYWRIGHT_TEST_BASE_URL=https://staging.dashboard.kernelci.org:9000 diff --git a/dashboard/.gitignore b/dashboard/.gitignore index d70c5d02d..eb13330ec 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -28,5 +28,9 @@ dist-ssr # eslint cache .eslintcache +# Playwright test reports and artifacts +test-results/ +playwright-report/ + .env* !.env.example diff --git a/dashboard/README.md b/dashboard/README.md index 3eb03cfb0..cc939c1ff 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -21,12 +21,45 @@ pnpm dev ``` ## Running unit tests + The frontend includes unit tests covering some parts of the source code. To run the tests, use the following command: ```sh pnpm test ``` +## Running end-to-end (e2e) tests + +The project includes Playwright-based end-to-end tests. To run the tests, first set the test environment URL in your .env file: + +```sh +# Copy the example file +cp .env.example .env + +# Edit the .env file to set PLAYWRIGHT_TEST_BASE_URL to your desired environment +# Available environments: +# - Staging: https://staging.dashboard.kernelci.org:9000 (default) +# - Production: https://dashboard.kernelci.org +# - Local: http://localhost:5173 + +# Install Playwright browsers if you don't have them yet +pnpm exec playwright install +``` + +Then run the e2e tests: + +```sh +# Run all e2e tests +pnpm run e2e + +# Run e2e tests with UI mode for debugging +pnpm run e2e-ui +``` + +## E2E Test Selectors + +To avoid complex css selectors, you can add a data-test-id attribute to elements that you want to target in your e2e tests. That way you don't need to fight with complex selectors. + # Routing and State Management A big part of this project is to have shareable links @@ -37,5 +70,5 @@ Also, we are using file based routing in the tanstack router, only files that st # Feature Flags They are used when we want to hide a feature for some users, without having to do branch manipulation. -Right now the only feature flag is for Dev only and it is controlled by the env +Right now the only feature flag is for Dev only and it is controlled by the env `FEATURE_FLAG_SHOW_DEV=false` it is a boolean. diff --git a/dashboard/e2e/e2e-selectors.ts b/dashboard/e2e/e2e-selectors.ts new file mode 100644 index 000000000..51e170ce3 --- /dev/null +++ b/dashboard/e2e/e2e-selectors.ts @@ -0,0 +1,29 @@ +export const TREE_LISTING_SELECTORS = { + table: 'table', + treeColumnHeader: 'th button:has-text("Tree")', + branchColumnHeader: 'th button:has-text("Branch")', + + intervalInput: 'input[type="number"][min="1"]', + + // This requires nth() selector which can't be stored as string + itemsPerPageDropdown: '[role="listbox"]', + itemsPerPageOption: (value: string) => `[role="option"]:has-text("${value}")`, + + searchInput: 'input[type="text"]', + + nextPageButton: '[role="button"]:has-text(">")', + previousPageButton: '[role="button"]:has-text("<")', + + treeNameCell: (treeName: string) => `td a:has-text("${treeName}")`, + firstTreeCell: 'td a', + + breadcrumbTreesLink: '[data-test-id="breadcrumb-link"]:has-text("Trees")', +} as const; + +export const COMMON_SELECTORS = { + tableRow: 'tr', + tableHeader: 'th', + + originDropdown: '[data-test-id="origin-dropdown"]', + originOption: (origin: string) => `[data-test-id="origin-option-${origin}"]`, +} as const; diff --git a/dashboard/e2e/tree-listing.spec.ts b/dashboard/e2e/tree-listing.spec.ts new file mode 100644 index 000000000..ae37e5990 --- /dev/null +++ b/dashboard/e2e/tree-listing.spec.ts @@ -0,0 +1,143 @@ +import { test, expect } from '@playwright/test'; + +import { TREE_LISTING_SELECTORS, COMMON_SELECTORS } from './e2e-selectors'; + +const PAGE_LOAD_TIMEOUT = 5000; +const DEFAULT_ACTION_TIMEOUT = 1000; +const SEARCH_UPDATE_TIMEOUT = 2000; +const NAVIGATION_TIMEOUT = 5000; +const GO_BACK_TIMEOUT = 3000; + +test.describe('Tree Listing Page Tests', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tree'); + await page.waitForTimeout(PAGE_LOAD_TIMEOUT); + }); + + test('loads tree listing page correctly', async ({ page }) => { + await expect(page).toHaveTitle(/KernelCI/); + await expect(page).toHaveURL(/\/tree/); + + await expect(page.locator(TREE_LISTING_SELECTORS.table)).toBeVisible(); + + await expect( + page.locator(TREE_LISTING_SELECTORS.treeColumnHeader), + ).toBeVisible(); + await expect( + page.locator(TREE_LISTING_SELECTORS.branchColumnHeader), + ).toBeVisible(); + }); + + test('change time interval', async ({ page }) => { + await expect(page.locator(COMMON_SELECTORS.tableRow).first()).toBeVisible(); + + const intervalInput = page + .locator(TREE_LISTING_SELECTORS.intervalInput) + .first(); + await expect(intervalInput).toBeVisible(); + + await intervalInput.fill('14'); + + await page.waitForTimeout(DEFAULT_ACTION_TIMEOUT); + + await expect(intervalInput).toHaveValue('14'); + }); + + test('change table size', async ({ page }) => { + await expect(page.locator(TREE_LISTING_SELECTORS.table)).toBeVisible(); + + const tableSizeSelector = page.locator('[role="combobox"]').nth(1); + await expect(tableSizeSelector).toBeVisible(); + + await tableSizeSelector.click(); + + await expect( + page.locator(TREE_LISTING_SELECTORS.itemsPerPageDropdown), + ).toBeVisible(); + + await page.locator(TREE_LISTING_SELECTORS.itemsPerPageOption('20')).click(); + + await page.waitForTimeout(DEFAULT_ACTION_TIMEOUT); + + await expect(tableSizeSelector).toContainText('20'); + }); + + test('search for trees', async ({ page }) => { + const searchInput = page.locator(TREE_LISTING_SELECTORS.searchInput).nth(0); + await expect(searchInput).toBeVisible(); + await searchInput.fill('main'); + + await page.waitForTimeout(SEARCH_UPDATE_TIMEOUT); + + const tableRows = page.locator(COMMON_SELECTORS.tableRow); + const count = await tableRows.count(); + expect(count).toBeGreaterThan(1); + }); + + test('navigate to tree details and back via breadcrumb', async ({ page }) => { + await expect(page.locator(TREE_LISTING_SELECTORS.table)).toBeVisible(); + + const firstTreeLink = page.locator('td a').first(); + await expect(firstTreeLink).toBeVisible(); + + await firstTreeLink.click(); + + await page.waitForTimeout(NAVIGATION_TIMEOUT); + + const url = page.url(); + expect(url).toMatch(/\/tree\/[^/]+\/[^/]+\/[^/]+$/); + + const breadcrumbLink = page.locator( + TREE_LISTING_SELECTORS.breadcrumbTreesLink, + ); + await expect(breadcrumbLink).toBeVisible({ timeout: 15000 }); + await breadcrumbLink.click(); + await page.waitForTimeout(GO_BACK_TIMEOUT); + + await expect(page).toHaveURL(/\/tree$/); + }); + + test('pagination navigation', async ({ page }) => { + await expect(page.locator(TREE_LISTING_SELECTORS.table)).toBeVisible(); + + const nextPageButton = page + .locator(TREE_LISTING_SELECTORS.nextPageButton) + .first(); + const hasNextPage = + (await nextPageButton.count()) > 0 && + !(await nextPageButton.isDisabled()); + + if (hasNextPage) { + const originalPageUrl = page.url(); + await nextPageButton.click(); + + await page.waitForTimeout(SEARCH_UPDATE_TIMEOUT); + + const newPageUrl = page.url(); + expect(newPageUrl).not.toBe(originalPageUrl); + } + }); + + test('change origin', async ({ page }) => { + const testOrigin = 'linaro'; + + await expect(page.locator(TREE_LISTING_SELECTORS.table)).toBeVisible(); + + await expect(page.locator('text="Origin"')).toBeVisible(); + + const originDropdown = page.locator(COMMON_SELECTORS.originDropdown); + await expect(originDropdown).toBeVisible({ timeout: 15000 }); + + await originDropdown.click(); + + await expect( + page.locator(COMMON_SELECTORS.originOption(testOrigin)), + ).toBeVisible(); + + await page.locator(COMMON_SELECTORS.originOption(testOrigin)).click(); + + await page.waitForTimeout(SEARCH_UPDATE_TIMEOUT); + + await expect(originDropdown).toContainText(testOrigin); + }); +}); diff --git a/dashboard/eslint.config.mjs b/dashboard/eslint.config.mjs index 6969d1223..a3014444c 100644 --- a/dashboard/eslint.config.mjs +++ b/dashboard/eslint.config.mjs @@ -54,7 +54,7 @@ export default [{ }, requireConfigFile: false, - project: ["./tsconfig.app.json", "./tsconfig.node.json"], + project: ["./tsconfig.app.json", "./tsconfig.node.json", "./tsconfig.e2e.json"], tsconfigRootDir: __dirname, } }, @@ -137,6 +137,7 @@ export default [{ ".storybook/**", "src/stories/**", "**/*.stories*", + "playwright.config.ts", ], }], diff --git a/dashboard/package.json b/dashboard/package.json index 2c8522bd5..2cc84a1b3 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -16,7 +16,9 @@ "prepare": "cd .. && husky dashboard/.husky", "pycommit": "cd .. && cd backend && sh pre-commit", "pypush": "cd .. && cd backend && sh pre-push", - "prettify": "prettier --write ./src" + "prettify": "prettier --write ./src ./e2e", + "e2e": "playwright test", + "e2e-ui": "playwright test --ui" }, "dependencies": { "@date-fns/tz": "^1.4.1", @@ -77,6 +79,7 @@ "@eslint/compat": "^1.3.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.34.0", + "@playwright/test": "^1.57.0", "@storybook/addon-essentials": "^8.6.14", "@storybook/addon-interactions": "^8.6.14", "@storybook/addon-links": "^8.6.14", @@ -95,6 +98,7 @@ "@typescript-eslint/eslint-plugin": "^8.41.0", "@typescript-eslint/parser": "^8.41.0", "@vitest/coverage-v8": "3.2.4", + "dotenv": "^17.2.3", "eslint": "^9.34.0", "eslint-config-prettier": "^9.1.2", "eslint-import-resolver-webpack": "^0.13.10", diff --git a/dashboard/playwright.config.ts b/dashboard/playwright.config.ts new file mode 100644 index 000000000..a7f120e4d --- /dev/null +++ b/dashboard/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@playwright/test'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export default defineConfig({ + testDir: './e2e', + use: { + baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:5173', + }, +}); diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index 6d72dac74..74843907c 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -177,6 +177,9 @@ importers: '@eslint/js': specifier: ^9.34.0 version: 9.34.0 + '@playwright/test': + specifier: ^1.57.0 + version: 1.57.0 '@storybook/addon-essentials': specifier: ^8.6.14 version: 8.6.14(@types/react@19.1.11)(storybook@8.6.14(prettier@3.3.2)) @@ -231,6 +234,9 @@ importers: '@vitest/coverage-v8': specifier: 3.2.4 version: 3.2.4(vitest@3.2.4(@types/node@20.19.11)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.5)) + dotenv: + specifier: ^17.2.3 + version: 17.2.3 eslint: specifier: ^9.34.0 version: 9.34.0(jiti@2.5.1) @@ -983,6 +989,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.57.0': + resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} + engines: {node: '>=18'} + hasBin: true + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -2816,6 +2827,10 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3147,6 +3162,11 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3848,6 +3868,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.57.0: + resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.57.0: + resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + engines: {node: '>=18'} + hasBin: true + polished@4.3.1: resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} engines: {node: '>=10'} @@ -5562,6 +5592,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.57.0': + dependencies: + playwright: 1.57.0 + '@popperjs/core@2.11.8': {} '@radix-ui/number@1.1.1': {} @@ -7535,6 +7569,8 @@ snapshots: '@babel/runtime': 7.28.3 csstype: 3.1.3 + dotenv@17.2.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8003,6 +8039,9 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -8652,6 +8691,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.57.0: {} + + playwright@1.57.0: + dependencies: + playwright-core: 1.57.0 + optionalDependencies: + fsevents: 2.3.2 + polished@4.3.1: dependencies: '@babel/runtime': 7.28.3 diff --git a/dashboard/src/components/Breadcrumb/Breadcrumb.tsx b/dashboard/src/components/Breadcrumb/Breadcrumb.tsx index abd886ca6..6b66a6211 100644 --- a/dashboard/src/components/Breadcrumb/Breadcrumb.tsx +++ b/dashboard/src/components/Breadcrumb/Breadcrumb.tsx @@ -33,6 +33,7 @@ const BreadcrumbLink = ({ {children} diff --git a/dashboard/src/components/Select/Select.tsx b/dashboard/src/components/Select/Select.tsx index 170999a7e..635595d53 100644 --- a/dashboard/src/components/Select/Select.tsx +++ b/dashboard/src/components/Select/Select.tsx @@ -8,12 +8,23 @@ import { SelectValue, } from '@/components/ui/select'; -const Select = (props: React.ComponentProps): JSX.Element => { - const { children, ...propsWithoutChildren } = props; +export interface SelectProps extends React.ComponentProps { + 'data-test-id'?: string; +} + +const Select = (props: SelectProps): JSX.Element => { + const { + children, + 'data-test-id': dataTestId, + ...propsWithoutChildren + } = props; return ( - + {children} diff --git a/dashboard/src/components/TopBar/TopBar.tsx b/dashboard/src/components/TopBar/TopBar.tsx index a8e4aef56..10a3f099d 100644 --- a/dashboard/src/components/TopBar/TopBar.tsx +++ b/dashboard/src/components/TopBar/TopBar.tsx @@ -56,7 +56,11 @@ const OriginSelect = ({ basePath }: { basePath: string }): JSX.Element => { } return pageOrigins.map(option => ( - + {option} )); @@ -82,7 +86,11 @@ const OriginSelect = ({ basePath }: { basePath: string }): JSX.Element => { - {selectItems} diff --git a/dashboard/tsconfig.e2e.json b/dashboard/tsconfig.e2e.json new file mode 100644 index 000000000..5d2c94e62 --- /dev/null +++ b/dashboard/tsconfig.e2e.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.e2e.tsbuildinfo", + "skipLibCheck": false, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["@playwright/test", "node"] + }, + "include": ["e2e/**/*"] +} diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json index d004f93be..a7263b09a 100644 --- a/dashboard/tsconfig.json +++ b/dashboard/tsconfig.json @@ -6,6 +6,9 @@ }, { "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.e2e.json" } ], "compilerOptions": { diff --git a/dashboard/tsconfig.node.json b/dashboard/tsconfig.node.json index 3afdd6e38..3a77c810c 100644 --- a/dashboard/tsconfig.node.json +++ b/dashboard/tsconfig.node.json @@ -9,5 +9,5 @@ "strict": true, "noEmit": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "playwright.config.ts", "vitest.config.ts"] } diff --git a/dashboard/vitest.config.ts b/dashboard/vitest.config.ts new file mode 100644 index 000000000..9aa398d82 --- /dev/null +++ b/dashboard/vitest.config.ts @@ -0,0 +1,20 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/cypress/**', + '**/e2e/**', + '**/.{idea,git,cache,output,temp}/**', + ], + }, +});