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..e0d665bbd 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -21,12 +21,38 @@ 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 +``` + +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 +``` + # Routing and State Management A big part of this project is to have shareable links @@ -37,5 +63,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..b9484d115 --- /dev/null +++ b/dashboard/e2e/e2e-selectors.ts @@ -0,0 +1,26 @@ +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: '[role="link"]:has-text("Trees")', +} as const; + +export const COMMON_SELECTORS = { + tableRow: 'tr', + tableHeader: 'th', +} as const; diff --git a/dashboard/e2e/tree-listing.spec.ts b/dashboard/e2e/tree-listing.spec.ts new file mode 100644 index 000000000..33049fb33 --- /dev/null +++ b/dashboard/e2e/tree-listing.spec.ts @@ -0,0 +1,116 @@ +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('7'); + + await page.waitForTimeout(DEFAULT_ACTION_TIMEOUT); + + await expect(intervalInput).toHaveValue('7'); + }); + + 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\/[^/]+\/[^/]+\/[^/]+$/); + + await page.goBack(); + 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); + } + }); +}); 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..1e2347f6e 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", diff --git a/dashboard/playwright.config.ts b/dashboard/playwright.config.ts new file mode 100644 index 000000000..dba77c29c --- /dev/null +++ b/dashboard/playwright.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from '@playwright/test'; + +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..acf601b52 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)) @@ -983,6 +986,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==} @@ -3147,6 +3155,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 +3861,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 +5585,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': {} @@ -8003,6 +8030,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 +8682,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/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}/**', + ], + }, +});