Skip to content

Commit fcfb9d6

Browse files
authored
feat: add Playwright E2E testing infrastructure (#1674)
- 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.
1 parent 6fd130c commit fcfb9d6

16 files changed

Lines changed: 348 additions & 9 deletions

dashboard/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
# Use port 80 to go through Nginx (proxy service on Docker)
33
VITE_API_BASE_URL=http://localhost:8000
44
VITE_FEATURE_FLAG_SHOW_DEV=false
5+
PLAYWRIGHT_TEST_BASE_URL=https://staging.dashboard.kernelci.org:9000

dashboard/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,9 @@ dist-ssr
2828
# eslint cache
2929
.eslintcache
3030

31+
# Playwright test reports and artifacts
32+
test-results/
33+
playwright-report/
34+
3135
.env*
3236
!.env.example

dashboard/README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,45 @@ pnpm dev
2121
```
2222

2323
## Running unit tests
24+
2425
The frontend includes unit tests covering some parts of the source code. To run the tests, use the following command:
2526

2627
```sh
2728
pnpm test
2829
```
2930

31+
## Running end-to-end (e2e) tests
32+
33+
The project includes Playwright-based end-to-end tests. To run the tests, first set the test environment URL in your .env file:
34+
35+
```sh
36+
# Copy the example file
37+
cp .env.example .env
38+
39+
# Edit the .env file to set PLAYWRIGHT_TEST_BASE_URL to your desired environment
40+
# Available environments:
41+
# - Staging: https://staging.dashboard.kernelci.org:9000 (default)
42+
# - Production: https://dashboard.kernelci.org
43+
# - Local: http://localhost:5173
44+
45+
# Install Playwright browsers if you don't have them yet
46+
pnpm exec playwright install
47+
```
48+
49+
Then run the e2e tests:
50+
51+
```sh
52+
# Run all e2e tests
53+
pnpm run e2e
54+
55+
# Run e2e tests with UI mode for debugging
56+
pnpm run e2e-ui
57+
```
58+
59+
## E2E Test Selectors
60+
61+
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.
62+
3063
# Routing and State Management
3164

3265
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
3770
# Feature Flags
3871

3972
They are used when we want to hide a feature for some users, without having to do branch manipulation.
40-
Right now the only feature flag is for Dev only and it is controlled by the env
73+
Right now the only feature flag is for Dev only and it is controlled by the env
4174
`FEATURE_FLAG_SHOW_DEV=false` it is a boolean.

dashboard/e2e/e2e-selectors.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export const TREE_LISTING_SELECTORS = {
2+
table: 'table',
3+
treeColumnHeader: 'th button:has-text("Tree")',
4+
branchColumnHeader: 'th button:has-text("Branch")',
5+
6+
intervalInput: 'input[type="number"][min="1"]',
7+
8+
// This requires nth() selector which can't be stored as string
9+
itemsPerPageDropdown: '[role="listbox"]',
10+
itemsPerPageOption: (value: string) => `[role="option"]:has-text("${value}")`,
11+
12+
searchInput: 'input[type="text"]',
13+
14+
nextPageButton: '[role="button"]:has-text(">")',
15+
previousPageButton: '[role="button"]:has-text("<")',
16+
17+
treeNameCell: (treeName: string) => `td a:has-text("${treeName}")`,
18+
firstTreeCell: 'td a',
19+
20+
breadcrumbTreesLink: '[data-test-id="breadcrumb-link"]:has-text("Trees")',
21+
} as const;
22+
23+
export const COMMON_SELECTORS = {
24+
tableRow: 'tr',
25+
tableHeader: 'th',
26+
27+
originDropdown: '[data-test-id="origin-dropdown"]',
28+
originOption: (origin: string) => `[data-test-id="origin-option-${origin}"]`,
29+
} as const;

dashboard/e2e/tree-listing.spec.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
import { TREE_LISTING_SELECTORS, COMMON_SELECTORS } from './e2e-selectors';
4+
5+
const PAGE_LOAD_TIMEOUT = 5000;
6+
const DEFAULT_ACTION_TIMEOUT = 1000;
7+
const SEARCH_UPDATE_TIMEOUT = 2000;
8+
const NAVIGATION_TIMEOUT = 5000;
9+
const GO_BACK_TIMEOUT = 3000;
10+
11+
test.describe('Tree Listing Page Tests', () => {
12+
test.beforeEach(async ({ page }) => {
13+
await page.goto('/tree');
14+
await page.waitForTimeout(PAGE_LOAD_TIMEOUT);
15+
});
16+
17+
test('loads tree listing page correctly', async ({ page }) => {
18+
await expect(page).toHaveTitle(/KernelCI/);
19+
await expect(page).toHaveURL(/\/tree/);
20+
21+
await expect(page.locator(TREE_LISTING_SELECTORS.table)).toBeVisible();
22+
23+
await expect(
24+
page.locator(TREE_LISTING_SELECTORS.treeColumnHeader),
25+
).toBeVisible();
26+
await expect(
27+
page.locator(TREE_LISTING_SELECTORS.branchColumnHeader),
28+
).toBeVisible();
29+
});
30+
31+
test('change time interval', async ({ page }) => {
32+
await expect(page.locator(COMMON_SELECTORS.tableRow).first()).toBeVisible();
33+
34+
const intervalInput = page
35+
.locator(TREE_LISTING_SELECTORS.intervalInput)
36+
.first();
37+
await expect(intervalInput).toBeVisible();
38+
39+
await intervalInput.fill('14');
40+
41+
await page.waitForTimeout(DEFAULT_ACTION_TIMEOUT);
42+
43+
await expect(intervalInput).toHaveValue('14');
44+
});
45+
46+
test('change table size', async ({ page }) => {
47+
await expect(page.locator(TREE_LISTING_SELECTORS.table)).toBeVisible();
48+
49+
const tableSizeSelector = page.locator('[role="combobox"]').nth(1);
50+
await expect(tableSizeSelector).toBeVisible();
51+
52+
await tableSizeSelector.click();
53+
54+
await expect(
55+
page.locator(TREE_LISTING_SELECTORS.itemsPerPageDropdown),
56+
).toBeVisible();
57+
58+
await page.locator(TREE_LISTING_SELECTORS.itemsPerPageOption('20')).click();
59+
60+
await page.waitForTimeout(DEFAULT_ACTION_TIMEOUT);
61+
62+
await expect(tableSizeSelector).toContainText('20');
63+
});
64+
65+
test('search for trees', async ({ page }) => {
66+
const searchInput = page.locator(TREE_LISTING_SELECTORS.searchInput).nth(0);
67+
await expect(searchInput).toBeVisible();
68+
await searchInput.fill('main');
69+
70+
await page.waitForTimeout(SEARCH_UPDATE_TIMEOUT);
71+
72+
const tableRows = page.locator(COMMON_SELECTORS.tableRow);
73+
const count = await tableRows.count();
74+
expect(count).toBeGreaterThan(1);
75+
});
76+
77+
test('navigate to tree details and back via breadcrumb', async ({ page }) => {
78+
await expect(page.locator(TREE_LISTING_SELECTORS.table)).toBeVisible();
79+
80+
const firstTreeLink = page.locator('td a').first();
81+
await expect(firstTreeLink).toBeVisible();
82+
83+
await firstTreeLink.click();
84+
85+
await page.waitForTimeout(NAVIGATION_TIMEOUT);
86+
87+
const url = page.url();
88+
expect(url).toMatch(/\/tree\/[^/]+\/[^/]+\/[^/]+$/);
89+
90+
const breadcrumbLink = page.locator(
91+
TREE_LISTING_SELECTORS.breadcrumbTreesLink,
92+
);
93+
await expect(breadcrumbLink).toBeVisible({ timeout: 15000 });
94+
await breadcrumbLink.click();
95+
await page.waitForTimeout(GO_BACK_TIMEOUT);
96+
97+
await expect(page).toHaveURL(/\/tree$/);
98+
});
99+
100+
test('pagination navigation', async ({ page }) => {
101+
await expect(page.locator(TREE_LISTING_SELECTORS.table)).toBeVisible();
102+
103+
const nextPageButton = page
104+
.locator(TREE_LISTING_SELECTORS.nextPageButton)
105+
.first();
106+
const hasNextPage =
107+
(await nextPageButton.count()) > 0 &&
108+
!(await nextPageButton.isDisabled());
109+
110+
if (hasNextPage) {
111+
const originalPageUrl = page.url();
112+
await nextPageButton.click();
113+
114+
await page.waitForTimeout(SEARCH_UPDATE_TIMEOUT);
115+
116+
const newPageUrl = page.url();
117+
expect(newPageUrl).not.toBe(originalPageUrl);
118+
}
119+
});
120+
121+
test('change origin', async ({ page }) => {
122+
const testOrigin = 'linaro';
123+
124+
await expect(page.locator(TREE_LISTING_SELECTORS.table)).toBeVisible();
125+
126+
await expect(page.locator('text="Origin"')).toBeVisible();
127+
128+
const originDropdown = page.locator(COMMON_SELECTORS.originDropdown);
129+
await expect(originDropdown).toBeVisible({ timeout: 15000 });
130+
131+
await originDropdown.click();
132+
133+
await expect(
134+
page.locator(COMMON_SELECTORS.originOption(testOrigin)),
135+
).toBeVisible();
136+
137+
await page.locator(COMMON_SELECTORS.originOption(testOrigin)).click();
138+
139+
await page.waitForTimeout(SEARCH_UPDATE_TIMEOUT);
140+
141+
await expect(originDropdown).toContainText(testOrigin);
142+
});
143+
});

dashboard/eslint.config.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export default [{
5454
},
5555

5656
requireConfigFile: false,
57-
project: ["./tsconfig.app.json", "./tsconfig.node.json"],
57+
project: ["./tsconfig.app.json", "./tsconfig.node.json", "./tsconfig.e2e.json"],
5858
tsconfigRootDir: __dirname,
5959
}
6060
},
@@ -137,6 +137,7 @@ export default [{
137137
".storybook/**",
138138
"src/stories/**",
139139
"**/*.stories*",
140+
"playwright.config.ts",
140141
],
141142
}],
142143

dashboard/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
"prepare": "cd .. && husky dashboard/.husky",
1717
"pycommit": "cd .. && cd backend && sh pre-commit",
1818
"pypush": "cd .. && cd backend && sh pre-push",
19-
"prettify": "prettier --write ./src"
19+
"prettify": "prettier --write ./src ./e2e",
20+
"e2e": "playwright test",
21+
"e2e-ui": "playwright test --ui"
2022
},
2123
"dependencies": {
2224
"@date-fns/tz": "^1.4.1",
@@ -77,6 +79,7 @@
7779
"@eslint/compat": "^1.3.2",
7880
"@eslint/eslintrc": "^3.3.1",
7981
"@eslint/js": "^9.34.0",
82+
"@playwright/test": "^1.57.0",
8083
"@storybook/addon-essentials": "^8.6.14",
8184
"@storybook/addon-interactions": "^8.6.14",
8285
"@storybook/addon-links": "^8.6.14",
@@ -95,6 +98,7 @@
9598
"@typescript-eslint/eslint-plugin": "^8.41.0",
9699
"@typescript-eslint/parser": "^8.41.0",
97100
"@vitest/coverage-v8": "3.2.4",
101+
"dotenv": "^17.2.3",
98102
"eslint": "^9.34.0",
99103
"eslint-config-prettier": "^9.1.2",
100104
"eslint-import-resolver-webpack": "^0.13.10",

dashboard/playwright.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineConfig } from '@playwright/test';
2+
import dotenv from 'dotenv';
3+
4+
dotenv.config();
5+
6+
export default defineConfig({
7+
testDir: './e2e',
8+
use: {
9+
baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:5173',
10+
},
11+
});

0 commit comments

Comments
 (0)