Merge branch 'test/e2e-setup' into dev

This commit is contained in:
Tibo De Peuter 2025-04-16 20:37:03 +02:00
commit 37d88cfd24
Signed by: tdpeuter
GPG key ID: 38297DE43F75FFE2
15 changed files with 634 additions and 2982 deletions

View file

@ -12,7 +12,7 @@
"format": "prettier --write src/",
"format-check": "prettier --check src/",
"lint": "eslint . --fix",
"pretest:unit": "npm run build",
"pretest:unit": "tsx ../docs/api/generate.ts && npm run build",
"test:unit": "vitest --run"
},
"dependencies": {
@ -26,7 +26,6 @@
"cross": "^1.0.0",
"cross-env": "^7.0.3",
"dotenv": "^16.4.7",
"dwengo-1-common": "^0.1.1",
"express": "^5.0.1",
"express-jwt": "^8.5.1",
"gift-pegjs": "^1.0.2",

View file

@ -8,7 +8,7 @@ import { getGroupRepository } from '../data/repositories.js';
import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment';
import { Class } from '../entities/classes/class.entity.js';
import { StudentDTO } from '@dwengo-1/common/interfaces/student';
import { mapToClassDTO } from './class';
import { mapToClassDTO } from './class.js';
export function mapToGroup(groupDto: GroupDTO, clazz: Class): Group {
const assignmentDto = groupDto.assignment as AssignmentDTO;

View file

@ -2,9 +2,9 @@ import { Submission } from '../entities/assignments/submission.entity.js';
import { mapToGroupDTO } from './group.js';
import { mapToStudentDTO } from './student.js';
import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission';
import { getSubmissionRepository } from '../data/repositories';
import { Student } from '../entities/users/student.entity';
import { Group } from '../entities/assignments/group.entity';
import { getSubmissionRepository } from '../data/repositories.js';
import { Student } from '../entities/users/student.entity.js';
import { Group } from '../entities/assignments/group.entity.js';
export function mapToSubmissionDTO(submission: Submission): SubmissionDTO {
return {

View file

@ -10,7 +10,7 @@ import { AnswerDTO, AnswerId } from '@dwengo-1/common/interfaces/answer';
import { mapToAssignment } from '../interfaces/assignment.js';
import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment';
import { fetchStudent } from './students.js';
import { NotFoundException } from '../exceptions/not-found-exception';
import { NotFoundException } from '../exceptions/not-found-exception.js';
import { FALLBACK_VERSION_NUM } from '../config.js';
export async function getQuestionsAboutLearningObjectInAssignment(

View file

@ -27,7 +27,7 @@
"oauth2DevicePollingInterval": 5,
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"registrationAllowed": true,
"registrationEmailAsUsername": false,
"rememberMe": false,
"verifyEmail": false,

View file

@ -27,7 +27,7 @@
"oauth2DevicePollingInterval": 5,
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"registrationAllowed": true,
"registrationEmailAsUsername": false,
"rememberMe": false,
"verifyEmail": false,

View file

@ -52,11 +52,18 @@ npm run test:unit
### Run End-to-End Tests with [Playwright](https://playwright.dev)
```sh
cd frontend
# Install browsers for the first run
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
cd ..
npm run build
cd frontend
# Runs the end-to-end tests
npm run test:e2e

View file

@ -0,0 +1,82 @@
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 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");
await expect(page.getByRole("link", { name: "log in" })).toBeVisible();
});

View 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();
});

View 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
View 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" },
],
});

View file

@ -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!");
});

View file

@ -24,10 +24,11 @@
"vue": "^3.5.13",
"vue-i18n": "^11.1.2",
"vue-router": "^4.5.0",
"vuetify": "^3.7.12"
"vuetify": "^3.7.12",
"wait-on": "^8.0.3"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@playwright/test": "1.50.1",
"@tsconfig/node22": "^22.0.0",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.13.4",

View file

@ -24,7 +24,7 @@ export default defineConfig({
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: Boolean(process.env.CI),
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
retries: process.env.CI ? 2 : 1,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
@ -65,18 +65,18 @@ export default defineConfig({
},
/* Test against mobile viewports. */
// {
// Name: 'Mobile Chrome',
// Use: {
// ...devices['Pixel 5'],
// },
// },
// {
// Name: 'Mobile Safari',
// Use: {
// ...devices['iPhone 12'],
// },
// },
{
name: "Mobile Chrome",
use: {
...devices["Pixel 5"],
},
},
{
name: "Mobile Safari",
use: {
...devices["iPhone 12"],
},
},
/* Test against branded browsers. */
// {
@ -97,14 +97,28 @@ export default defineConfig({
// OutputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
* Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? "npm run preview" : "npm run dev",
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
webServer: [
{
command: process.env.CI ? "npm run preview" : "npm run dev",
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
{
command: "cd ../ && docker compose up",
reuseExistingServer: false,
gracefulShutdown: {
signal: "SIGTERM",
timeout: 5000,
},
},
{
command: process.env.CI ? "cd ../ && npm run dev -w backend" : "cd ../ && npm run start -w backend",
port: 3000,
reuseExistingServer: !process.env.CI,
},
{
command: "wait-on http://localhost:7080",
timeout: 120000,
},
],
});

3304
package-lock.json generated

File diff suppressed because it is too large Load diff