Merge pull request #193 from SELab-2/test/e2e-setup
test: Eerste End-to-End Testen
This commit is contained in:
		
						commit
						cdcc75a101
					
				
					 12 changed files with 1711 additions and 932 deletions
				
			
		|  | @ -13,8 +13,10 @@ DWENGO_DB_NAME=":memory:" | ||||||
| DWENGO_DB_UPDATE=true | DWENGO_DB_UPDATE=true | ||||||
| 
 | 
 | ||||||
| DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student | DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student | ||||||
|  | DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo | ||||||
| DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs | DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs | ||||||
| DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher | DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher | ||||||
|  | DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo | ||||||
| DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs | DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs | ||||||
| 
 | 
 | ||||||
| DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 | DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost:9876,* | ||||||
|  |  | ||||||
|  | @ -27,7 +27,7 @@ | ||||||
|     "oauth2DevicePollingInterval": 5, |     "oauth2DevicePollingInterval": 5, | ||||||
|     "enabled": true, |     "enabled": true, | ||||||
|     "sslRequired": "external", |     "sslRequired": "external", | ||||||
|     "registrationAllowed": false, |     "registrationAllowed": true, | ||||||
|     "registrationEmailAsUsername": false, |     "registrationEmailAsUsername": false, | ||||||
|     "rememberMe": false, |     "rememberMe": false, | ||||||
|     "verifyEmail": false, |     "verifyEmail": false, | ||||||
|  |  | ||||||
|  | @ -27,7 +27,7 @@ | ||||||
|     "oauth2DevicePollingInterval": 5, |     "oauth2DevicePollingInterval": 5, | ||||||
|     "enabled": true, |     "enabled": true, | ||||||
|     "sslRequired": "external", |     "sslRequired": "external", | ||||||
|     "registrationAllowed": false, |     "registrationAllowed": true, | ||||||
|     "registrationEmailAsUsername": false, |     "registrationEmailAsUsername": false, | ||||||
|     "rememberMe": false, |     "rememberMe": false, | ||||||
|     "verifyEmail": false, |     "verifyEmail": false, | ||||||
|  |  | ||||||
|  | @ -52,11 +52,18 @@ npm run test:unit | ||||||
| ### Run End-to-End Tests with [Playwright](https://playwright.dev) | ### Run End-to-End Tests with [Playwright](https://playwright.dev) | ||||||
| 
 | 
 | ||||||
| ```sh | ```sh | ||||||
|  | cd frontend | ||||||
|  | 
 | ||||||
| # Install browsers for the first run | # Install browsers for the first run | ||||||
| npx playwright install | npx playwright install | ||||||
|  | # On Ubuntu, you can also use | ||||||
|  | npx playwright install --with-deps | ||||||
|  | # to additionally install the dependencies. | ||||||
| 
 | 
 | ||||||
| # When testing on CI, must build the project first | # When testing on CI, must build the project first | ||||||
|  | cd .. | ||||||
| npm run build | npm run build | ||||||
|  | cd frontend | ||||||
| 
 | 
 | ||||||
| # Runs the end-to-end tests | # Runs the end-to-end tests | ||||||
| npm run test:e2e | npm run test:e2e | ||||||
|  |  | ||||||
							
								
								
									
										86
									
								
								frontend/e2e/basic-homepage.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								frontend/e2e/basic-homepage.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,86 @@ | ||||||
|  | import { test, expect } from "@playwright/test"; | ||||||
|  | 
 | ||||||
|  | test("User can pick their language", async ({ page }) => { | ||||||
|  |     await page.goto("/"); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByRole("button", { name: "translate" })).toBeVisible(); | ||||||
|  |     await page.getByRole("button", { name: "translate" }).click(); | ||||||
|  |     await page.getByText("Nederlands").click(); | ||||||
|  |     await expect(page.locator("h1")).toContainText("Onze sterke punten"); | ||||||
|  |     await expect(page.getByRole("heading", { name: "Innovatief" })).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |     await page.getByRole("heading", { name: "Innovatief" }).click(); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByRole("button", { name: "vertalen" })).toBeVisible(); | ||||||
|  |     await page.getByRole("button", { name: "vertalen" }).click(); | ||||||
|  |     await page.getByText("English").click(); | ||||||
|  |     await expect(page.locator("h1")).toContainText("Our strengths"); | ||||||
|  |     await expect(page.getByRole("heading", { name: "Innovative" })).toBeVisible(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test("Teacher can sign in", async ({ page }) => { | ||||||
|  |     await page.goto("/"); | ||||||
|  |     await expect(page.getByRole("link", { name: "log in" })).toBeVisible(); | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByRole("button", { name: "teacher" })).toBeVisible(); | ||||||
|  |     await page.getByRole("button", { name: "teacher" }).click(); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByText("teacher")).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |     await expect(page).toHaveURL(/\/realms\/teacher\//); | ||||||
|  | 
 | ||||||
|  |     await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); | ||||||
|  |     await page.getByRole("textbox", { name: "Password" }).fill("password"); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByRole("link", { name: "Dwengo logo teacher" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("button").nth(1)).toBeVisible(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test("Student can sign in", async ({ page }) => { | ||||||
|  |     await page.goto("/"); | ||||||
|  |     await expect(page.getByRole("link", { name: "log in" })).toBeVisible(); | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByRole("button", { name: "student" })).toBeVisible(); | ||||||
|  |     await page.getByRole("button", { name: "student" }).click(); | ||||||
|  | 
 | ||||||
|  |     await expect(page).toHaveURL(/\/realms\/student\//); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByText("student")).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |     await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1"); | ||||||
|  |     await page.getByRole("textbox", { name: "Password" }).fill("password"); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByRole("link", { name: "Dwengo logo student" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("button").nth(1)).toBeVisible(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test("Cannot sign in with invalid credentials", async ({ page }) => { | ||||||
|  |     await page.goto("/"); | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  |     await page.getByRole("button", { name: "teacher" }).click(); | ||||||
|  |     await page.getByRole("textbox", { name: "Username or email" }).fill("doesnotexist"); | ||||||
|  |     await page.getByRole("textbox", { name: "Password" }).fill("wrong"); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  |     await expect(page.getByText("Invalid username or password.")).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |     await page.goto("/"); | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  |     await page.getByRole("button", { name: "student" }).click(); | ||||||
|  |     await page.getByRole("textbox", { name: "Username or email" }).fill("doesnotexist"); | ||||||
|  |     await page.getByRole("textbox", { name: "Password" }).fill("wrong"); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  |     await expect(page.getByText("Invalid username or password.")).toBeVisible(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test("Cannot skip login", async ({ page }) => { | ||||||
|  |     await page.goto("/user"); | ||||||
|  |     // Should redirect to login
 | ||||||
|  |     await expect(page.getByText("login")).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("button", { name: "teacher" })).toBeVisible(); | ||||||
|  | }); | ||||||
							
								
								
									
										12
									
								
								frontend/e2e/basic-learning.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/e2e/basic-learning.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,12 @@ | ||||||
|  | import { test, expect } from "./fixtures.js"; | ||||||
|  | 
 | ||||||
|  | test("Users can filter", async ({ page }) => { | ||||||
|  |     await page.goto("/user"); | ||||||
|  | 
 | ||||||
|  |     await page.getByRole("combobox").filter({ hasText: "Select a themeAll" }).locator("i").click(); | ||||||
|  |     await page.getByText("Nature and climate").click(); | ||||||
|  |     await page.getByRole("combobox").filter({ hasText: "Select ageAll agesSelect age" }).locator("i").click(); | ||||||
|  |     await page.getByText("and older").click(); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByRole("link", { name: "AI and Climate Students in" })).toBeVisible(); | ||||||
|  | }); | ||||||
							
								
								
									
										5
									
								
								frontend/e2e/basic-learning.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/e2e/basic-learning.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | ||||||
|  | import { test, expect } from "./fixtures.js"; | ||||||
|  | 
 | ||||||
|  | test("myTest", async ({ page }) => { | ||||||
|  |     await expect(page).toHaveURL("/"); | ||||||
|  | }); | ||||||
							
								
								
									
										116
									
								
								frontend/e2e/fixtures.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								frontend/e2e/fixtures.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,116 @@ | ||||||
|  | /* eslint-disable no-await-in-loop */ | ||||||
|  | // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||||
|  | import { test as baseTest, expect } from "@playwright/test"; | ||||||
|  | import type { Browser } from "playwright-core"; | ||||||
|  | import fs from "fs"; | ||||||
|  | import path from "path"; | ||||||
|  | 
 | ||||||
|  | /* Based on https://playwright.dev/docs/auth#moderate-one-account-per-parallel-worker */ | ||||||
|  | 
 | ||||||
|  | export * from "@playwright/test"; | ||||||
|  | export const ROOT_URL = "http://localhost:5173"; | ||||||
|  | 
 | ||||||
|  | interface Account { | ||||||
|  |     username: string; | ||||||
|  |     password: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Acquire an account by logging in or creating a new one. | ||||||
|  |  * @param id | ||||||
|  |  * @param browser | ||||||
|  |  */ | ||||||
|  | async function acquireAccount(id: number, browser: Browser): Promise<Account> { | ||||||
|  |     const account = { | ||||||
|  |         username: `worker${id}`, | ||||||
|  |         password: "password", | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const page = await browser.newPage(); | ||||||
|  |     await page.goto(ROOT_URL); | ||||||
|  | 
 | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  |     await page.getByRole("button", { name: "student" }).click(); | ||||||
|  | 
 | ||||||
|  |     await page.getByRole("textbox", { name: "Username" }).fill(account.username); | ||||||
|  |     await page.getByRole("textbox", { name: "Password", exact: true }).fill(account.password); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  | 
 | ||||||
|  |     let failed = await page.getByText("Invalid username or password.").isVisible(); | ||||||
|  | 
 | ||||||
|  |     if (failed) { | ||||||
|  |         await page.getByRole("link", { name: "Register" }).click(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const MAX_RETRIES = 5; | ||||||
|  |     let retries = 0; | ||||||
|  |     while (failed && retries < MAX_RETRIES) { | ||||||
|  |         // Retry with a different username, based on Unix timestamp.
 | ||||||
|  |         account.username = `worker${id}-${Date.now()}`; | ||||||
|  | 
 | ||||||
|  |         await page.getByRole("textbox", { name: "Username" }).fill(account.username); | ||||||
|  |         await page.getByRole("textbox", { name: "Password", exact: true }).fill(account.password); | ||||||
|  |         await page.getByRole("textbox", { name: "Confirm password" }).fill(account.password); | ||||||
|  |         await page.getByRole("textbox", { name: "Email" }).fill(`${account.username}@dwengo.org`); | ||||||
|  |         await page.getByRole("textbox", { name: "First name" }).fill("Worker"); | ||||||
|  |         await page.getByRole("textbox", { name: "Last name" }).fill(id.toString()); | ||||||
|  |         await page.getByRole("button", { name: "Register" }).click(); | ||||||
|  | 
 | ||||||
|  |         await page.waitForURL(/localhost/); | ||||||
|  | 
 | ||||||
|  |         failed = await page.getByText("Username already exists.").isVisible(); | ||||||
|  |         retries += failed ? 1 : 0; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     await page.waitForURL(/localhost/); | ||||||
|  |     await page.close(); | ||||||
|  | 
 | ||||||
|  |     return account; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const test = baseTest.extend<object, { workerStorageState: string }>({ | ||||||
|  |     // Use the same storage state for all tests in this worker.
 | ||||||
|  |     storageState: async ({ workerStorageState }, use) => use(workerStorageState), | ||||||
|  | 
 | ||||||
|  |     // Authenticate once per worker with a worker-scoped fixture.
 | ||||||
|  |     workerStorageState: [ | ||||||
|  |         async ({ browser }, use): Promise<void> => { | ||||||
|  |             // Use parallelIndex as a unique identifier for each worker.
 | ||||||
|  |             const id = test.info().parallelIndex; | ||||||
|  |             const fileName = path.resolve(test.info().project.outputDir, `.auth/${id}.json`); | ||||||
|  | 
 | ||||||
|  |             if (fs.existsSync(fileName)) { | ||||||
|  |                 // Reuse existing authentication state if any.
 | ||||||
|  |                 await use(fileName); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             // Important: make sure we authenticate in a clean environment by unsetting storage state.
 | ||||||
|  |             const page = await browser.newPage({ storageState: undefined }); | ||||||
|  | 
 | ||||||
|  |             // Acquire a unique account by creating a new one.
 | ||||||
|  |             const account = await acquireAccount(id, browser); | ||||||
|  | 
 | ||||||
|  |             // Perform authentication steps. Replace these actions with your own.
 | ||||||
|  |             await page.goto(ROOT_URL); | ||||||
|  |             await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  |             await page.getByRole("button", { name: "student" }).click(); | ||||||
|  |             await page.getByRole("textbox", { name: "Username or email" }).fill(account.username); | ||||||
|  |             await page.getByRole("textbox", { name: "Password" }).fill(account.password); | ||||||
|  |             await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  |             // Wait until the page receives the cookies.
 | ||||||
|  |             //
 | ||||||
|  |             // Sometimes login flow sets cookies in the process of several redirects.
 | ||||||
|  |             // Wait for the final URL to ensure that the cookies are actually set.
 | ||||||
|  |             await page.waitForLoadState("domcontentloaded"); | ||||||
|  |             // Alternatively, you can wait until the page reaches a state where all cookies are set.
 | ||||||
|  | 
 | ||||||
|  |             // End of authentication steps.
 | ||||||
|  | 
 | ||||||
|  |             await page.context().storageState({ path: fileName }); | ||||||
|  |             await page.close(); | ||||||
|  |             await use(fileName); | ||||||
|  |         }, | ||||||
|  |         { scope: "worker" }, | ||||||
|  |     ], | ||||||
|  | }); | ||||||
|  | @ -1,8 +0,0 @@ | ||||||
| import { test, expect } from "@playwright/test"; |  | ||||||
| 
 |  | ||||||
| // See here how to get started:
 |  | ||||||
| // https://playwright.dev/docs/intro
 |  | ||||||
| test("visits the app root url", async ({ page }) => { |  | ||||||
|     await page.goto("/"); |  | ||||||
|     await expect(page.locator("h1")).toHaveText("You did it!"); |  | ||||||
| }); |  | ||||||
|  | @ -25,10 +25,11 @@ | ||||||
|         "vue": "^3.5.13", |         "vue": "^3.5.13", | ||||||
|         "vue-i18n": "^11.1.2", |         "vue-i18n": "^11.1.2", | ||||||
|         "vue-router": "^4.5.0", |         "vue-router": "^4.5.0", | ||||||
|         "vuetify": "^3.7.12" |         "vuetify": "^3.7.12", | ||||||
|  |         "wait-on": "^8.0.3" | ||||||
|     }, |     }, | ||||||
|     "devDependencies": { |     "devDependencies": { | ||||||
|         "@playwright/test": "^1.50.1", |         "@playwright/test": "1.50.1", | ||||||
|         "@tsconfig/node22": "^22.0.0", |         "@tsconfig/node22": "^22.0.0", | ||||||
|         "@types/jsdom": "^21.1.7", |         "@types/jsdom": "^21.1.7", | ||||||
|         "@types/node": "^22.13.4", |         "@types/node": "^22.13.4", | ||||||
|  |  | ||||||
|  | @ -24,7 +24,7 @@ export default defineConfig({ | ||||||
|     /* Fail the build on CI if you accidentally left test.only in the source code. */ |     /* Fail the build on CI if you accidentally left test.only in the source code. */ | ||||||
|     forbidOnly: Boolean(process.env.CI), |     forbidOnly: Boolean(process.env.CI), | ||||||
|     /* Retry on CI only */ |     /* Retry on CI only */ | ||||||
|     retries: process.env.CI ? 2 : 0, |     retries: process.env.CI ? 2 : 1, | ||||||
|     /* Opt out of parallel tests on CI. */ |     /* Opt out of parallel tests on CI. */ | ||||||
|     workers: process.env.CI ? 1 : undefined, |     workers: process.env.CI ? 1 : undefined, | ||||||
|     /* Reporter to use. See https://playwright.dev/docs/test-reporters */ |     /* Reporter to use. See https://playwright.dev/docs/test-reporters */ | ||||||
|  | @ -65,18 +65,18 @@ export default defineConfig({ | ||||||
|         }, |         }, | ||||||
| 
 | 
 | ||||||
|         /* Test against mobile viewports. */ |         /* Test against mobile viewports. */ | ||||||
|         // {
 |         { | ||||||
|         //   Name: 'Mobile Chrome',
 |             name: "Mobile Chrome", | ||||||
|         //   Use: {
 |             use: { | ||||||
|         //     ...devices['Pixel 5'],
 |                 ...devices["Pixel 5"], | ||||||
|         //   },
 |             }, | ||||||
|         // },
 |         }, | ||||||
|         // {
 |         { | ||||||
|         //   Name: 'Mobile Safari',
 |             name: "Mobile Safari", | ||||||
|         //   Use: {
 |             use: { | ||||||
|         //     ...devices['iPhone 12'],
 |                 ...devices["iPhone 12"], | ||||||
|         //   },
 |             }, | ||||||
|         // },
 |         }, | ||||||
| 
 | 
 | ||||||
|         /* Test against branded browsers. */ |         /* Test against branded browsers. */ | ||||||
|         // {
 |         // {
 | ||||||
|  | @ -97,14 +97,25 @@ export default defineConfig({ | ||||||
|     // OutputDir: 'test-results/',
 |     // OutputDir: 'test-results/',
 | ||||||
| 
 | 
 | ||||||
|     /* Run your local dev server before starting the tests */ |     /* Run your local dev server before starting the tests */ | ||||||
|     webServer: { |     webServer: [ | ||||||
|         /** |         // Assuming the idp is already running (because it is slow)
 | ||||||
|          * Use the dev server by default for faster feedback loop. |         { | ||||||
|          * Use the preview server on CI for more realistic testing. |             /* Frontend */ | ||||||
|          * Playwright will re-use the local server if there is already a dev-server running. |             command: `VITE_API_BASE_URL='http://localhost:9876/api' ${process.env.CI ? "npm run preview" : "npm run dev"}`, | ||||||
|          */ |             port: process.env.CI ? 4173 : 5173, | ||||||
|         command: process.env.CI ? "npm run preview" : "npm run dev", |             timeout: 120 * 1000, | ||||||
|         port: process.env.CI ? 4173 : 5173, |             reuseExistingServer: !process.env.CI, | ||||||
|         reuseExistingServer: !process.env.CI, |         }, | ||||||
|     }, |         { | ||||||
|  |             /* Backend */ | ||||||
|  |             command: ` | ||||||
|  |             cd .. \ | ||||||
|  |             && npx tsc --build common/tsconfig.json \ | ||||||
|  |             && cd backend \ | ||||||
|  |             && npx tsx --env-file=./.env.test ./tool/startTestApp.ts | ||||||
|  |             `,
 | ||||||
|  |             port: 9876, | ||||||
|  |             reuseExistingServer: !process.env.CI, | ||||||
|  |         }, | ||||||
|  |     ], | ||||||
| }); | }); | ||||||
|  |  | ||||||
							
								
								
									
										2339
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										2339
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Reference in a new issue