diff --git a/app/e2e-tests/.gitignore b/app/e2e-tests/.gitignore new file mode 100644 index 00000000000..68c5d18f00d --- /dev/null +++ b/app/e2e-tests/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/app/e2e-tests/README.md b/app/e2e-tests/README.md new file mode 100644 index 00000000000..1fc1da0132c --- /dev/null +++ b/app/e2e-tests/README.md @@ -0,0 +1,69 @@ +# e2e test for local playwright app mode + +Currently we have the original e2e tests for the web mode in the `e2e-tests` directory. We are adding new tests for the app mode in the `app-e2e-tests` directory for local testing. Unlike the other tests, these tests do not require a token to run so the setup is as followed: + +## Running app mode tests + +## Setup + +- Before running the tests, be sure to have an instance of Minikube running with the name `minikube` + +### Running the tests + +To run the tests for the app mode, follow the steps below: + +- cd into the e2e-tests directory within the headlamp repository + `cd headlamp/app/e2e-tests` + +- npm install the needed packages + `npm install` + +- run the following command + `npm run test-app` + (optional: include `-- --headed` to run the tests in headed mode) + (optional: include `-- --ui` to run the tests in ui mode) + +## Running web mode tests + +Running the tests for the web mode requires the backend and frontend to be running. Follow the steps below to run the tests for the web mode: + +Note: You may encouter issues switching from the app mode tests to the web mode tests. If you do, search for any running headlamp server processes and end them before running the web mode tests or app mode tests. + +## Setup + +- Before running the tests, be sure to have an instance of Minikube running with the name `minikube` + +### Backend + +To run the tests for the web mode, you will need to have the backend running. Follow the steps below to run the backend: + +- cd into the headlamp directory in a singular terminal + `cd headlamp` + +- run the following command + `make backend` followed by `make run-backend` + +### Frontend + +To run the tests for the web mode, you will need to have the frontend running. Follow the steps below to run the frontend: + +- cd into the headlamp directory in a separate terminal + `cd headlamp/frontend` + +- run the following command + `make frontend` followed by `make run-frontend` + +### Running the tests + +To run the tests for the web mode, follow the steps below: + +- cd into the e2e-tests directory within the headlamp repository in a separate terminal + `cd headlamp/app/e2e-tests` + +- npm install the needed packages + `npm install` + +- run the following command + `npm run test-web` + (optional: include `-- --headed` to run the tests in headed mode) + (optional: include `-- --ui` to run the tests in ui mode) diff --git a/app/e2e-tests/package-lock.json b/app/e2e-tests/package-lock.json new file mode 100644 index 00000000000..8ee2f7b8a9c --- /dev/null +++ b/app/e2e-tests/package-lock.json @@ -0,0 +1,97 @@ +{ + "name": "e2e-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "e2e-tests", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.48.1", + "@types/node": "^22.7.5" + } + }, + "node_modules/@playwright/test": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.1.tgz", + "integrity": "sha512-s9RtWoxkOLmRJdw3oFvhFbs9OJS0BzrLUc8Hf6l2UdCNd1rqeEyD4BhCJkvzeEoD1FsK4mirsWwGerhVmYKtZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.48.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "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/playwright": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.1.tgz", + "integrity": "sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.48.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.1.tgz", + "integrity": "sha512-Yw/t4VAFX/bBr1OzwCuOMZkY1Cnb4z/doAFSwf4huqAGWmf9eMNjmK7NiOljCdLmxeRYcGPPmcDgU0zOlzP0YA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/app/e2e-tests/package.json b/app/e2e-tests/package.json new file mode 100644 index 00000000000..2da0bbd744e --- /dev/null +++ b/app/e2e-tests/package.json @@ -0,0 +1,17 @@ +{ + "name": "e2e-tests", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test-app": "PLAYWRIGHT_TEST_MODE=app playwright test", + "test-web": "PLAYWRIGHT_TEST_MODE=web playwright test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@playwright/test": "^1.48.1", + "@types/node": "^22.7.5" + } +} diff --git a/app/e2e-tests/playwright.config.ts b/app/e2e-tests/playwright.config.ts new file mode 100644 index 00000000000..601fc4b67b2 --- /dev/null +++ b/app/e2e-tests/playwright.config.ts @@ -0,0 +1,57 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + timeout: 60 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 120000, + }, + fullyParallel: false, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/app/e2e-tests/tests/headlampPage.ts b/app/e2e-tests/tests/headlampPage.ts new file mode 100644 index 00000000000..2a6237e0413 --- /dev/null +++ b/app/e2e-tests/tests/headlampPage.ts @@ -0,0 +1,154 @@ +/// +import { expect, Page } from '@playwright/test'; + +export class HeadlampPage { + constructor(private page: Page) {} + + async authenticate() { + // If we are running in cluster, we need to authenticate + if (process.env.PLAYWRIGHT_TEST_MODE === 'app' || process.env.PLAYWRIGHT_TEST_MODE === 'web') { + await this.startFromMainPage(); + return; + } + + // Go to the authentication page + const url = process.env.HEADLAMP_TEST_URL; + await this.page.goto(url || '/'); + await this.page.waitForSelector('h1:has-text("Authentication")'); + + // Check to see if already authenticated + if (await this.page.isVisible('button:has-text("Authenticate")')) { + const token = process.env.HEADLAMP_TOKEN || ''; + this.hasToken(token); + + // Fill in the token + await this.page.locator('#token').fill(token); + + // Click on the "Authenticate" button and wait for navigation + await Promise.all([ + this.page.waitForNavigation(), + this.page.click('button:has-text("Authenticate")'), + ]); + } + } + + async hasURLContaining(pattern: RegExp) { + await expect(this.page).toHaveURL(pattern); + } + + async hasTitleContaining(pattern: RegExp) { + await expect(this.page).toHaveTitle(pattern); + } + + async hasToken(token: string) { + expect(token).not.toBe(''); + } + + async hasNetworkTab() { + const networkTab = this.page.locator('span:has-text("Network")').first(); + expect(await networkTab.textContent()).toBe('Network'); + } + + async hasSecurityTab() { + const networkTab = this.page.locator('span:has-text("Security")').first(); + expect(await networkTab.textContent()).toBe('Security'); + } + + async checkPageContent(text: string) { + await this.page.waitForSelector(`:has-text("${text}")`); + const pageContent = await this.page.content(); + expect(pageContent).toContain(text); + } + + async pageLocatorContent(locator: string, text: string) { + const pageContent = this.page.locator(locator).textContent(); + expect(await pageContent).toContain(text); + } + + // note: must have minikube started before running these + async startFromMainPage() { + await this.page.waitForLoadState('load'); + + // note: backend must be running with connected frontend for web mode + if (process.env.PLAYWRIGHT_TEST_MODE === 'web') { + await this.page.goto('localhost:3000'); + } + + await this.page.waitForTimeout(5000); + const currentURL = this.page.url(); + + // note: this starts at the cluster select page if the URL does not contain minikube or main then there is more than one cluster + if (!currentURL.includes('c/minikube') && !currentURL.includes('c/main')) { + console.log('MORE THAN ONE CLUSTER'); + await this.page.waitForSelector('a:has-text("minikube")'); + await this.page.getByRole('link', { name: 'minikube', exact: true }).click(); + await this.page.waitForLoadState('load'); + } + } + + async navigateTopage(page: string, title: RegExp) { + await this.page.goto(page); + await this.page.waitForLoadState('load'); + await this.hasTitleContaining(title); + } + + async logout() { + // Click on the account button to open the user menu + await this.page.click('button[aria-label="Account of current user"]'); + + // Wait for the logout option to be visible and click on it + await this.page.waitForSelector('a.MuiMenuItem-root:has-text("Log out")'); + await this.page.click('a.MuiMenuItem-root:has-text("Log out")'); + await this.page.waitForLoadState('load'); + + // Expects the URL to contain c/main/token + await this.hasURLContaining(/.*token/); + } + + async tableHasHeaders(tableSelector: string, expectedHeaders: string[]) { + // Get all table headers + const headers = await this.page.$$eval(`${tableSelector} th`, ths => + ths.map(th => { + if (th && th.textContent) { + // Table header also contains a number, displayed during multi-sorting, so we remove it + return th.textContent.trim().replace('0', ''); + } + }) + ); + + // Check if all expected headers are present in the table + for (const header of expectedHeaders) { + if (!headers.includes(header)) { + throw new Error(`Table does not contain header: ${header}`); + } + } + } + + async clickOnPlugin(pluginName: string) { + await this.page.click(`a:has-text("${pluginName}")`); + await this.page.waitForLoadState('load'); + } + + async checkRows() { + // Get value of rows per page + const rowsDisplayed1 = await this.getRowsDisplayed(); + + // Click on the next page button + const nextPageButton = this.page.getByRole('button', { + name: 'Go to next page', + }); + await nextPageButton.click(); + + // Get value of rows per page after clicking next page button + const rowsDisplayed2 = await this.getRowsDisplayed(); + + // Check if the rows displayed are different + expect(rowsDisplayed1).not.toBe(rowsDisplayed2); + } + + async getRowsDisplayed() { + const paginationCaption = this.page.locator("span:has-text(' of ')"); + const captionText = await paginationCaption.textContent(); + return captionText; + } +} diff --git a/app/e2e-tests/tests/namespaces.spec.ts b/app/e2e-tests/tests/namespaces.spec.ts new file mode 100644 index 00000000000..ba2958b2b14 --- /dev/null +++ b/app/e2e-tests/tests/namespaces.spec.ts @@ -0,0 +1,57 @@ +import { test } from '@playwright/test'; +import path from 'path'; +import { _electron, Page } from 'playwright'; +import { HeadlampPage } from './headlampPage'; +import { NamespacesPage } from './namespacesPage'; + +const electronExecutable = process.platform === 'win32' ? 'electron.cmd' : 'electron'; +const electronPath = path.resolve(__dirname, `../../node_modules/.bin/${electronExecutable}`); + +const electron = _electron; +const appPath = path.resolve(__dirname, '../../'); +let electronApp; +let electronPage: Page; + +if (process.env.PLAYWRIGHT_TEST_MODE === 'app') { + test.beforeAll(async () => { + electronApp = await electron.launch({ + cwd: appPath, + executablePath: electronPath, + args: ['.'], + env: { + ...process.env, + NODE_ENV: 'development', + ELECTRON_DEV: 'true', + }, + }); + + electronPage = await electronApp.firstWindow(); + }); + + test.beforeEach(async ({ page }) => { + if (process.env.PLAYWRIGHT_TEST_MODE === 'app') { + page.close(); + } + }); +} + +// note: this test is for local app development testing and will require: +// - a running minikube cluster named 'minikube' +// - an ENV variable of PLAYWRIGHT_TEST_MODE=app +test.describe('create a namespace with the minimal editor', async () => { + test.setTimeout(0); + test('create a namespace with the minimal editor then delete it', async ({ + page: browserPage, + }) => { + const page = process.env.PLAYWRIGHT_TEST_MODE === 'app' ? electronPage : browserPage; + const name = 'testing-e2e'; + const headlampPage = new HeadlampPage(page); + const namespacesPage = new NamespacesPage(page); + + await headlampPage.authenticate(); + + await namespacesPage.navigateToNamespaces(); + await namespacesPage.createNamespace(name); + await namespacesPage.deleteNamespace(name); + }); +}); diff --git a/app/e2e-tests/tests/namespacesPage.ts b/app/e2e-tests/tests/namespacesPage.ts new file mode 100644 index 00000000000..40e75ee1865 --- /dev/null +++ b/app/e2e-tests/tests/namespacesPage.ts @@ -0,0 +1,86 @@ +import { expect, Page } from '@playwright/test'; + +export class NamespacesPage { + constructor(private page: Page) {} + + async navigateToNamespaces() { + await this.page.waitForLoadState('load'); + await this.page.waitForSelector('span:has-text("Cluster")'); + await this.page.getByText('Cluster', { exact: true }).click(); + await this.page.waitForSelector('span:has-text("Namespaces")'); + await this.page.click('span:has-text("Namespaces")'); + await this.page.waitForLoadState('load'); + } + + async createNamespace(name) { + const yaml = ` + apiVersion: v1 + kind: Namespace + metadata: + name: ${name} + `; + const page = this.page; + + await page.waitForSelector('span:has-text("Namespaces")'); + await page.click('span:has-text("Namespaces")'); + await page.waitForLoadState('load'); + + // If the namespace already exists, return. + // This makes it a bit more resilient to flakiness. + const pageContent = await this.page.content(); + if (pageContent.includes(name)) { + throw new Error(`Test failed: Namespace "${name}" already exists.`); + } + + await page.getByText('Create', { exact: true }).click(); + + await page.waitForLoadState('load'); + + // this is a workaround for the checked input not having any unique identifier + const checkedSpan = await page.$('span.Mui-checked'); + + if (!checkedSpan) { + await expect(page.getByText('Use minimal editor')).toBeVisible(); + + await page.getByText('Use minimal editor').click(); + } + + await page.waitForLoadState('load'); + + await page.waitForSelector('textarea[aria-label="yaml Code"]', { state: 'visible' }); + + await expect(page.getByRole('textbox', { name: 'yaml Code' })).toBeVisible(); + await page.fill('textarea[aria-label="yaml Code"]', yaml); + + await expect(page.getByRole('button', { name: 'Apply' })).toBeVisible(); + await page.getByRole('button', { name: 'Apply' }).click(); + + await page.waitForSelector(`a:has-text("${name}")`); + await expect(page.locator(`a:has-text("${name}")`)).toBeVisible(); + } + + async deleteNamespace(name) { + const page = this.page; + await page.click('span:has-text("Namespaces")'); + await page.waitForLoadState('load'); + + await page.waitForSelector(`text=${name}`); + await page.click(`a:has-text("${name}")`); + + await page.waitForSelector('button[aria-label="Delete"]'); + await page.click('button[aria-label="Delete"]'); + + await page.waitForLoadState('load'); + + await page.waitForSelector('button:has-text("Yes")'); + + await page.waitForLoadState('load'); + + await page.click('button:has-text("Yes")'); + + await page.waitForSelector('h1:has-text("Namespaces")'); + await page.waitForSelector('td:has-text("Terminating")'); + + await expect(page.locator(`a:has-text("${name}")`)).toBeHidden(); + } +} diff --git a/e2e-tests/tests/headlampPage.ts b/e2e-tests/tests/headlampPage.ts index 51ae4a27621..1ceab9eb70a 100644 --- a/e2e-tests/tests/headlampPage.ts +++ b/e2e-tests/tests/headlampPage.ts @@ -5,23 +5,25 @@ export class HeadlampPage { constructor(private page: Page) {} async authenticate() { - await this.page.goto('/'); + // Go to the authentication page + const url = process.env.HEADLAMP_TEST_URL; + await this.page.goto(url || '/'); await this.page.waitForSelector('h1:has-text("Authentication")'); - // Expects the URL to contain c/main/token - this.hasURLContaining(/.*token/); - - const token = process.env.HEADLAMP_TOKEN || ''; - this.hasToken(token); + // Check to see if already authenticated + if (await this.page.isVisible('button:has-text("Authenticate")')) { + const token = process.env.HEADLAMP_TOKEN || ''; + this.hasToken(token); - // Fill in the token - await this.page.locator('#token').fill(token); + // Fill in the token + await this.page.locator('#token').fill(token); - // Click on the "Authenticate" button and wait for navigation - await Promise.all([ - this.page.waitForNavigation(), - this.page.click('button:has-text("Authenticate")'), - ]); + // Click on the "Authenticate" button and wait for navigation + await Promise.all([ + this.page.waitForNavigation(), + this.page.click('button:has-text("Authenticate")'), + ]); + } } async hasURLContaining(pattern: RegExp) {