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 => {
-