diff --git a/.github/workflows/backend-testing.yml b/.github/workflows/backend-testing.yml index 0d0b1f3f..ce7600e0 100644 --- a/.github/workflows/backend-testing.yml +++ b/.github/workflows/backend-testing.yml @@ -29,6 +29,12 @@ jobs: if: '! github.event.pull_request.draft' runs-on: [self-hosted, Linux, X64] + permissions: + # Required to checkout the code + contents: read + # Required to put a comment into the pull-request + pull-requests: write + strategy: matrix: node-version: [22.x] @@ -42,4 +48,16 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - - run: npm run test:unit -w backend + - run: npm run build + - run: npm run test:coverage -w backend + - name: 'Report Backend Coverage' + # Set if: always() to also generate the report if tests are failing + # Only works if you set `reportOnFailure: true` in your vite config as specified above + if: always() + uses: davelosert/vitest-coverage-report-action@v2 + with: + name: 'Backend' + json-summary-path: './backend/coverage/coverage-summary.json' + json-final-path: './backend/coverage/coverage-final.json' + vite-config-path: './backend/vitest.config.ts' + file-coverage-mode: all diff --git a/.github/workflows/frontend-testing.yml b/.github/workflows/frontend-testing.yml index ff7bde4d..053e66c3 100644 --- a/.github/workflows/frontend-testing.yml +++ b/.github/workflows/frontend-testing.yml @@ -38,6 +38,12 @@ jobs: if: '! github.event.pull_request.draft' runs-on: [self-hosted, Linux, X64] + permissions: + # Required to checkout the code + contents: read + # Required to put a comment into the pull-request + pull-requests: write + strategy: matrix: node-version: [22.x] @@ -51,4 +57,16 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - - run: npm run test:unit -w frontend + - run: npm run build + - run: npm run test:coverage -w frontend + - name: 'Report Frontend Coverage' + # Set if: always() to also generate the report if tests are failing + # Only works if you set `reportOnFailure: true` in your vite config as specified above + if: always() + uses: davelosert/vitest-coverage-report-action@v2 + with: + name: 'Frontend' + json-summary-path: './frontend/coverage/coverage-summary.json' + json-final-path: './frontend/coverage/coverage-final.json' + vite-config-path: './frontend/vitest.config.ts' + file-coverage-mode: all diff --git a/backend/.env.test b/backend/.env.test index 4444ec29..fb94aa09 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -13,8 +13,10 @@ DWENGO_DB_NAME=":memory:" DWENGO_DB_UPDATE=true 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_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_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 +DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost:9876,* diff --git a/backend/package.json b/backend/package.json index ac5ebb48..468ddb3f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,7 +14,8 @@ "format-check": "prettier --check src/", "lint": "eslint . --fix", "pretest:unit": "tsx ../docs/api/generate.ts && npm run build", - "test:unit": "vitest --run" + "test:unit": "vitest --run", + "test:coverage": "vitest --run --coverage.enabled true" }, "dependencies": { "@mikro-orm/core": "6.4.12", @@ -27,7 +28,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", diff --git a/backend/src/controllers/assignments.ts b/backend/src/controllers/assignments.ts index 2ecb35cb..2ca6d2fc 100644 --- a/backend/src/controllers/assignments.ts +++ b/backend/src/controllers/assignments.ts @@ -4,6 +4,7 @@ import { deleteAssignment, getAllAssignments, getAssignment, + getAssignmentsQuestions, getAssignmentsSubmissions, putAssignment, } from '../services/assignments.js'; @@ -13,6 +14,19 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js'; import { Assignment } from '../entities/assignments/assignment.entity.js'; import { EntityDTO } from '@mikro-orm/core'; +function getAssignmentParams(req: Request): { classid: string; assignmentNumber: number; full: boolean } { + const classid = req.params.classid; + const assignmentNumber = Number(req.params.id); + const full = req.query.full === 'true'; + requireFields({ assignmentNumber, classid }); + + if (isNaN(assignmentNumber)) { + throw new BadRequestException('Assignment id should be a number'); + } + + return { classid, assignmentNumber, full }; +} + export async function getAllAssignmentsHandler(req: Request, res: Response): Promise { const classId = req.params.classid; const full = req.query.full === 'true'; @@ -38,57 +52,42 @@ export async function createAssignmentHandler(req: Request, res: Response): Prom } export async function getAssignmentHandler(req: Request, res: Response): Promise { - const id = Number(req.params.id); - const classid = req.params.classid; - requireFields({ id, classid }); + const { classid, assignmentNumber } = getAssignmentParams(req); - if (isNaN(id)) { - throw new BadRequestException('Assignment id should be a number'); - } - - const assignment = await getAssignment(classid, id); + const assignment = await getAssignment(classid, assignmentNumber); res.json({ assignment }); } export async function putAssignmentHandler(req: Request, res: Response): Promise { - const id = Number(req.params.id); - const classid = req.params.classid; - requireFields({ id, classid }); - - if (isNaN(id)) { - throw new BadRequestException('Assignment id should be a number'); - } + const { classid, assignmentNumber } = getAssignmentParams(req); const assignmentData = req.body as Partial>; - const assignment = await putAssignment(classid, id, assignmentData); + const assignment = await putAssignment(classid, assignmentNumber, assignmentData); res.json({ assignment }); } -export async function deleteAssignmentHandler(req: Request, _res: Response): Promise { - const id = Number(req.params.id); - const classid = req.params.classid; - requireFields({ id, classid }); +export async function deleteAssignmentHandler(req: Request, res: Response): Promise { + const { classid, assignmentNumber } = getAssignmentParams(req); - if (isNaN(id)) { - throw new BadRequestException('Assignment id should be a number'); - } + const assignment = await deleteAssignment(classid, assignmentNumber); - await deleteAssignment(classid, id); + res.json({ assignment }); } export async function getAssignmentsSubmissionsHandler(req: Request, res: Response): Promise { - const classid = req.params.classid; - const assignmentNumber = Number(req.params.id); - const full = req.query.full === 'true'; - requireFields({ assignmentNumber, classid }); - - if (isNaN(assignmentNumber)) { - throw new BadRequestException('Assignment id should be a number'); - } + const { classid, assignmentNumber, full } = getAssignmentParams(req); const submissions = await getAssignmentsSubmissions(classid, assignmentNumber, full); res.json({ submissions }); } + +export async function getAssignmentQuestionsHandler(req: Request, res: Response): Promise { + const { classid, assignmentNumber, full } = getAssignmentParams(req); + + const questions = await getAssignmentsQuestions(classid, assignmentNumber, full); + + res.json({ questions }); +} diff --git a/backend/src/controllers/groups.ts b/backend/src/controllers/groups.ts index 217510f6..f17aada5 100644 --- a/backend/src/controllers/groups.ts +++ b/backend/src/controllers/groups.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { createGroup, deleteGroup, getAllGroups, getGroup, getGroupSubmissions, putGroup } from '../services/groups.js'; +import { createGroup, deleteGroup, getAllGroups, getGroup, getGroupQuestions, getGroupSubmissions, putGroup } from '../services/groups.js'; import { GroupDTO } from '@dwengo-1/common/interfaces/group'; import { requireFields } from './error-helper.js'; import { BadRequestException } from '../exceptions/bad-request-exception.js'; @@ -84,7 +84,7 @@ export async function createGroupHandler(req: Request, res: Response): Promise { +function getGroupParams(req: Request): { classId: string; assignmentId: number; groupId: number; full: boolean } { const classId = req.params.classid; const assignmentId = Number(req.params.assignmentid); const groupId = Number(req.params.groupid); @@ -100,7 +100,21 @@ export async function getGroupSubmissionsHandler(req: Request, res: Response): P throw new BadRequestException('Group id must be a number'); } + return { classId, assignmentId, groupId, full }; +} + +export async function getGroupSubmissionsHandler(req: Request, res: Response): Promise { + const { classId, assignmentId, groupId, full } = getGroupParams(req); + const submissions = await getGroupSubmissions(classId, assignmentId, groupId, full); res.json({ submissions }); } + +export async function getGroupQuestionsHandler(req: Request, res: Response): Promise { + const { classId, assignmentId, groupId, full } = getGroupParams(req); + + const questions = await getGroupQuestions(classId, assignmentId, groupId, full); + + res.json({ questions }); +} diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index 941bca79..6fa6ee05 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -63,9 +63,7 @@ export class QuestionRepository extends DwengoEntityRepository { public async findAllByAssignment(assignment: Assignment): Promise { return this.find({ - inGroup: { - $contained: assignment.groups, - }, + inGroup: assignment.groups.getItems(), learningObjectHruid: assignment.learningPathHruid, learningObjectLanguage: assignment.learningPathLanguage, }); @@ -78,6 +76,13 @@ export class QuestionRepository extends DwengoEntityRepository { }); } + public async findAllByGroup(inGroup: Group): Promise { + return this.findAll({ + where: { inGroup }, + orderBy: { timestamp: 'DESC' }, + }); + } + /** * Looks up all questions for the given learning object which were asked as part of the given assignment. * When forStudentUsername is set, only the questions within the given user's group are shown. diff --git a/backend/src/entities/assignments/assignment.entity.ts b/backend/src/entities/assignments/assignment.entity.ts index ed8745f6..a12ffbac 100644 --- a/backend/src/entities/assignments/assignment.entity.ts +++ b/backend/src/entities/assignments/assignment.entity.ts @@ -1,4 +1,4 @@ -import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { Cascade, Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Class } from '../classes/class.entity.js'; import { Group } from './group.entity.js'; import { Language } from '@dwengo-1/common/util/language'; @@ -34,6 +34,7 @@ export class Assignment { @OneToMany({ entity: () => Group, mappedBy: 'assignment', + cascade: [Cascade.ALL], }) groups: Collection = new Collection(this); } diff --git a/backend/src/entities/assignments/submission.entity.ts b/backend/src/entities/assignments/submission.entity.ts index 4018c3af..b19a99eb 100644 --- a/backend/src/entities/assignments/submission.entity.ts +++ b/backend/src/entities/assignments/submission.entity.ts @@ -1,6 +1,6 @@ import { Student } from '../users/student.entity.js'; import { Group } from './group.entity.js'; -import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; +import { Entity, Enum, ManyToOne, PrimaryKey, Property, Cascade } from '@mikro-orm/core'; import { SubmissionRepository } from '../../data/assignments/submission-repository.js'; import { Language } from '@dwengo-1/common/util/language'; @@ -21,8 +21,8 @@ export class Submission { @PrimaryKey({ type: 'numeric', autoincrement: false }) learningObjectVersion = 1; - @ManyToOne({ - entity: () => Group, + @ManyToOne(() => Group, { + cascade: [Cascade.REMOVE], }) onBehalfOf!: Group; diff --git a/backend/src/routes/assignments.ts b/backend/src/routes/assignments.ts index 5b9ed7c6..4503414d 100644 --- a/backend/src/routes/assignments.ts +++ b/backend/src/routes/assignments.ts @@ -4,6 +4,7 @@ import { deleteAssignmentHandler, getAllAssignmentsHandler, getAssignmentHandler, + getAssignmentQuestionsHandler, getAssignmentsSubmissionsHandler, putAssignmentHandler, } from '../controllers/assignments.js'; @@ -23,6 +24,8 @@ router.delete('/:id', deleteAssignmentHandler); router.get('/:id/submissions', getAssignmentsSubmissionsHandler); +router.get('/:id/questions', getAssignmentQuestionsHandler); + router.use('/:assignmentid/groups', groupRouter); export default router; diff --git a/backend/src/routes/groups.ts b/backend/src/routes/groups.ts index 7f973972..3043c23b 100644 --- a/backend/src/routes/groups.ts +++ b/backend/src/routes/groups.ts @@ -4,6 +4,7 @@ import { deleteGroupHandler, getAllGroupsHandler, getGroupHandler, + getGroupQuestionsHandler, getGroupSubmissionsHandler, putGroupHandler, } from '../controllers/groups.js'; @@ -23,4 +24,6 @@ router.delete('/:groupid', deleteGroupHandler); router.get('/:groupid/submissions', getGroupSubmissionsHandler); +router.get('/:groupid/questions', getGroupQuestionsHandler); + export default router; diff --git a/backend/src/services/groups.ts b/backend/src/services/groups.ts index 382780d8..b75fe82f 100644 --- a/backend/src/services/groups.ts +++ b/backend/src/services/groups.ts @@ -1,5 +1,5 @@ import { EntityDTO } from '@mikro-orm/core'; -import { getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; +import { getGroupRepository, getQuestionRepository, getSubmissionRepository } from '../data/repositories.js'; import { Group } from '../entities/assignments/group.entity.js'; import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; @@ -12,6 +12,8 @@ import { fetchClass } from './classes.js'; import { BadRequestException } from '../exceptions/bad-request-exception.js'; import { Student } from '../entities/users/student.entity.js'; import { Class } from '../entities/classes/class.entity.js'; +import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; +import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js'; async function assertMembersInClass(members: Student[], cls: Class): Promise { if (!members.every((student) => cls.students.contains(student))) { @@ -121,3 +123,21 @@ export async function getGroupSubmissions( return submissions.map(mapToSubmissionDTOId); } + +export async function getGroupQuestions( + classId: string, + assignmentNumber: number, + groupNumber: number, + full: boolean +): Promise { + const group = await fetchGroup(classId, assignmentNumber, groupNumber); + + const questionRepository = getQuestionRepository(); + const questions = await questionRepository.findAllByGroup(group); + + if (full) { + return questions.map(mapToQuestionDTO); + } + + return questions.map(mapToQuestionDTOId); +} diff --git a/backend/tests/data/assignments/submissions.test.ts b/backend/tests/data/assignments/submissions.test.ts index 2bbd00dc..ea2341bc 100644 --- a/backend/tests/data/assignments/submissions.test.ts +++ b/backend/tests/data/assignments/submissions.test.ts @@ -17,6 +17,7 @@ import { ClassRepository } from '../../../src/data/classes/class-repository'; import { Submission } from '../../../src/entities/assignments/submission.entity'; import { Class } from '../../../src/entities/classes/class.entity'; import { Assignment } from '../../../src/entities/assignments/assignment.entity'; +import { testLearningObject01 } from '../../test_assets/content/learning-objects.testdata'; describe('SubmissionRepository', () => { let submissionRepository: SubmissionRepository; @@ -106,7 +107,7 @@ describe('SubmissionRepository', () => { }); it('should not find a deleted submission', async () => { - const id = new LearningObjectIdentifier('id01', Language.English, 1); + const id = new LearningObjectIdentifier(testLearningObject01.hruid, testLearningObject01.language, testLearningObject01.version); await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(id, 1); const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1); diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 29142c49..cd281251 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -5,5 +5,17 @@ export default defineConfig({ environment: 'node', globals: true, testTimeout: 100000, + coverage: { + reporter: ['text', 'json-summary', 'json'], + // If you want a coverage reports even if your tests are failing, include the reportOnFailure option + reportOnFailure: true, + exclude: ['**/*config*', '**/tests/**', 'src/*.ts', '**/dist/**', '**/node_modules/**', 'src/logging/**', 'src/routes/**'], + thresholds: { + lines: 50, + branches: 50, + functions: 50, + statements: 50, + }, + }, }, }); diff --git a/config/idp/student-realm.json b/config/idp/student-realm.json index 32107e4e..452d5490 100644 --- a/config/idp/student-realm.json +++ b/config/idp/student-realm.json @@ -27,7 +27,7 @@ "oauth2DevicePollingInterval": 5, "enabled": true, "sslRequired": "external", - "registrationAllowed": false, + "registrationAllowed": true, "registrationEmailAsUsername": false, "rememberMe": false, "verifyEmail": false, diff --git a/config/idp/teacher-realm.json b/config/idp/teacher-realm.json index b9d29dcb..8a599d98 100644 --- a/config/idp/teacher-realm.json +++ b/config/idp/teacher-realm.json @@ -27,7 +27,7 @@ "oauth2DevicePollingInterval": 5, "enabled": true, "sslRequired": "external", - "registrationAllowed": false, + "registrationAllowed": true, "registrationEmailAsUsername": false, "rememberMe": false, "verifyEmail": false, diff --git a/frontend/README.md b/frontend/README.md index d4b3b63d..522d8a38 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 diff --git a/frontend/e2e/basic-homepage.spec.ts b/frontend/e2e/basic-homepage.spec.ts new file mode 100644 index 00000000..43a6ef82 --- /dev/null +++ b/frontend/e2e/basic-homepage.spec.ts @@ -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(); +}); diff --git a/frontend/e2e/basic-learning.spec.ts b/frontend/e2e/basic-learning.spec.ts new file mode 100644 index 00000000..f7438454 --- /dev/null +++ b/frontend/e2e/basic-learning.spec.ts @@ -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(); +}); diff --git a/frontend/e2e/basic-learning.ts b/frontend/e2e/basic-learning.ts new file mode 100644 index 00000000..157debb0 --- /dev/null +++ b/frontend/e2e/basic-learning.ts @@ -0,0 +1,5 @@ +import { test, expect } from "./fixtures.js"; + +test("myTest", async ({ page }) => { + await expect(page).toHaveURL("/"); +}); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts new file mode 100644 index 00000000..0fa2d99f --- /dev/null +++ b/frontend/e2e/fixtures.ts @@ -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 { + 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({ + // 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 => { + // 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" }, + ], +}); diff --git a/frontend/e2e/vue.spec.ts b/frontend/e2e/vue.spec.ts deleted file mode 100644 index fd4797b7..00000000 --- a/frontend/e2e/vue.spec.ts +++ /dev/null @@ -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!"); -}); diff --git a/frontend/package.json b/frontend/package.json index cdd7dda1..82e0a7c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,11 +13,13 @@ "format-check": "prettier --check src/", "lint": "eslint . --fix", "test:unit": "vitest --run", + "test:coverage": "vitest --run --coverage.enabled true", "test:e2e": "playwright test" }, "dependencies": { "@tanstack/react-query": "^5.69.0", "@tanstack/vue-query": "^5.69.0", + "@vueuse/core": "^13.1.0", "axios": "^1.8.2", "oidc-client-ts": "^3.1.0", "rollup": "^4.40.0", @@ -25,10 +27,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", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 06d60d89..e9c128e8 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -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,25 @@ 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: [ + // Assuming the idp is already running (because it is slow) + { + /* Frontend */ + command: `VITE_API_BASE_URL='http://localhost:9876/api' ${process.env.CI ? "npm run preview" : "npm run dev"}`, + port: process.env.CI ? 4173 : 5173, + timeout: 120 * 1000, + 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, + }, + ], }); diff --git a/frontend/src/assets/assignment.css b/frontend/src/assets/assignment.css new file mode 100644 index 00000000..029dec22 --- /dev/null +++ b/frontend/src/assets/assignment.css @@ -0,0 +1,49 @@ +.container { + display: flex; + justify-content: center; + align-items: center; + padding: 2%; + min-height: 100vh; +} + +.assignment-card { + width: 80%; + padding: 2%; + border-radius: 12px; +} + +.description { + margin-top: 2%; + line-height: 1.6; + font-size: 1.1rem; +} + +.top-right-btn { + position: absolute; + right: 2%; + color: red; +} + +.group-section { + margin-top: 2rem; +} + +.group-section h3 { + margin-bottom: 0.5rem; +} + +.group-section ul { + padding-left: 1.2rem; + list-style-type: disc; +} + +.subtitle-section { + display: flex; + align-items: center; + justify-content: space-between; +} + +.assignmentTopTitle { + white-space: normal; + word-break: break-word; +} diff --git a/frontend/src/components/BrowseThemes.vue b/frontend/src/components/BrowseThemes.vue index 805d2720..b65c4e26 100644 --- a/frontend/src/components/BrowseThemes.vue +++ b/frontend/src/components/BrowseThemes.vue @@ -11,7 +11,7 @@ selectedAge: { type: String, required: true }, }); - const { locale } = useI18n(); + const { t, locale } = useI18n(); const language = computed(() => locale.value); const { data: allThemes, isLoading, error } = useThemeQuery(language); @@ -74,6 +74,22 @@ class="fill-height" /> + + + diff --git a/frontend/src/components/ThemeCard.vue b/frontend/src/components/ThemeCard.vue index 7064b63c..d2420474 100644 --- a/frontend/src/components/ThemeCard.vue +++ b/frontend/src/components/ThemeCard.vue @@ -1,21 +1,26 @@