diff --git a/.github/workflows/messages.yml b/.github/workflows/messages.yml index 4732e040..d640a3b7 100644 --- a/.github/workflows/messages.yml +++ b/.github/workflows/messages.yml @@ -56,6 +56,19 @@ jobs: - name: Run frontend tests run: make front-test + test-e2e: + runs-on: ubuntu-latest + env: + DOCKER_USER: 1001 + PW_TEST_HTML_REPORT_OPEN: 'never' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Create env files + run: make create-env-files + - name: Run E2E tests only for Chromium browser to speed up the tests + run: make e2e-test-ci args="--project chromium" + lint-front: runs-on: ubuntu-latest steps: diff --git a/Makefile b/Makefile index 0553400a..f67c9685 100644 --- a/Makefile +++ b/Makefile @@ -289,11 +289,9 @@ e2e-test-dev: ## Setup, run and teardown e2e tests in UI mode with dev frontend e2e-test-ci: ## Setup and run e2e tests in CI mode @$(MAKE) e2e-setup - @args="$(filter-out $@,$(MAKECMDGOALS))" && \ - $(MAKE) e2e-run-test -- $${args:-${1}} + @$(MAKE) e2e-run-test args="$(args)" .PHONY: e2e-test-ci - e2e-build: ## Build the e2e services @args="$(filter-out $@,$(MAKECMDGOALS))" && \ $(COMPOSE_E2E) build --no-cache $${args:-${1}} @@ -565,3 +563,4 @@ mta-in-poetry-lock: ## lock the dependencies mta-out-poetry-lock: ## lock the dependencies @$(COMPOSE) run --rm --build mta-out-poetry poetry lock .PHONY: mta-out-poetry-lock + diff --git a/src/e2e/compose.yaml b/src/e2e/compose.yaml index 79a35d1a..33169efe 100644 --- a/src/e2e/compose.yaml +++ b/src/e2e/compose.yaml @@ -158,6 +158,8 @@ services: - BACKEND_BASE_URL=http://nginx - KEYCLOAK_BASE_URL=http://keycloak:8802 - DOCKER_HOST=tcp://docker-sock-proxy:2375 + - CI=${CI:-false} + - PW_TEST_HTML_REPORT_OPEN=${PW_TEST_HTML_REPORT_OPEN:-false} volumes: - ./src:/app/src - ./playwright.config.ts:/app/playwright.config.ts diff --git a/src/e2e/playwright.config.ts b/src/e2e/playwright.config.ts index 0812693a..da791e0b 100644 --- a/src/e2e/playwright.config.ts +++ b/src/e2e/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from '@playwright/test'; +import { getStorageStatePathIfExists } from './src/utils'; /** * See https://playwright.dev/docs/test-configuration. @@ -41,15 +42,22 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + storageState: getStorageStatePathIfExists('user.e2e.chromium'), + }, }, { name: 'firefox', - use: { ...devices['Desktop Firefox'] }, + use: { ...devices['Desktop Firefox'], + storageState: getStorageStatePathIfExists('user.e2e.firefox'), + }, }, { name: 'webkit', - use: { ...devices['Desktop Safari'] }, + use: { ...devices['Desktop Safari'], + storageState: getStorageStatePathIfExists('user.e2e.webkit'), + }, }, ], diff --git a/src/e2e/src/__tests__/login.spec.ts b/src/e2e/src/__tests__/login.spec.ts new file mode 100644 index 00000000..87e44f6f --- /dev/null +++ b/src/e2e/src/__tests__/login.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/test'; +import { signInKeycloakIfNeeded } from '../utils-test'; +import { getStorageStatePath } from '../utils'; + +test.describe('Authentication with empty storage state', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + test('should authenticate', async ({ page, browserName }) => { + const username = `user.e2e.${browserName}`; + await page.goto('/'); + await signInKeycloakIfNeeded({ page, username }); + }); +}); + +test.describe('Authentication with existing storage state', () => { + test('should authenticate', async ({ page, browserName }) => { + const username = `user.e2e.${browserName}`; + await page.goto('/'); + await signInKeycloakIfNeeded({ page, username }); + }); +}); + diff --git a/src/e2e/src/__tests__/message-import.spec.ts b/src/e2e/src/__tests__/message-import.spec.ts new file mode 100644 index 00000000..eb6319d9 --- /dev/null +++ b/src/e2e/src/__tests__/message-import.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from "@playwright/test"; +import { FIXTURES_PATH } from "../constants"; +import path from "path"; +import { getMailboxEmail, resetDatabase } from "../utils"; +import { signInKeycloakIfNeeded } from "../utils-test"; + +test.describe("Import Message", () => { + test.beforeAll(async () => { + await resetDatabase(); + }); + + test.beforeEach(async ({ page, browserName }) => { + await page.goto("/"); + await signInKeycloakIfNeeded({ page, username: `user.e2e.${browserName}` }); + }); + + test.afterEach(async () => { + await resetDatabase(); + }); + + test("should import an eml archive file", async ({ page, browserName }) => { + const email = `user.e2e.${browserName}@example.local`; + await page.waitForLoadState("networkidle"); + + // As the database is fresh, there should be no threads and the Import messages button should be visible + const noThreads = page.getByText("No threads."); + await expect(page.getByRole("link", { name: "Import messages" })).toBeVisible(); + + const header = page.locator(".c__header"); + const settingsButton = header.getByRole("button", { name: "More options" }); + await settingsButton.click(); + + const menuItem = page.getByRole("menuitem", { name: "Import messages" }); + await menuItem.click(); + + const importModal = page.getByRole("dialog"); + const modalTitle = importModal.locator(".c__modal__title"); + expect(await modalTitle.textContent()).toBe( + `Import your old messages in ${email}` + ); + + const fileInput = page.locator('input[type="file"][name="archive_file"]'); + + // Import a wrong file type should show an error + const importButton = page.getByRole("button", { name: "Import" }); + await fileInput.setInputFiles(path.join(FIXTURES_PATH, "attachment.png")); + await importButton.click(); + + const errorBanner = page.getByRole("alert", { + name: "An error occurred while uploading the archive file.", + }); + await errorBanner.waitFor({ state: "visible" }); + + await fileInput.setInputFiles(path.join(FIXTURES_PATH, "old-message.eml")); + await importButton.click(); + await expect(errorBanner).not.toBeVisible(); + + expect( + page.getByRole("heading", { name: "Uploading your archive" }) + ).toBeVisible(); + expect(importButton).toBeDisabled(); + expect(await importButton.getAttribute("aria-busy")).toBe("true"); + + const uploadCompleteResponse = await page.waitForResponse((response) => { + return ( + response.url().includes("/api/v1.0/import/file/") && + response.status() === 202 + ); + }); + const uploadCompleteData = await uploadCompleteResponse.json(); + const taskId = uploadCompleteData.task_id; + + expect(page.getByText("Importing...")).toBeVisible(); + + await page.waitForResponse(async (response) => { + if (response.url().includes(`/api/v1.0/tasks/${taskId}/`)) { + const taskData = await response.json(); + return taskData.status === "SUCCESS"; + } + return false; + }); + + await expect( + page.getByText("Your messages have been imported successfully!") + ).toBeVisible(); + + const closeButton = page.getByRole("button", { + name: "Close", + exact: true, + }); + await closeButton.click(); + + await importModal.waitFor({ state: "hidden" }); + + // Then expect the new message to be visible in the thread list + await expect( + page.getByRole("link", { name: "Sardine An old message" }) + ).toBeVisible(); + }); + + test("should not be able to import message if not mailbox admin", async ({ + page, + browserName, + }) => { + const email = `user.e2e.${browserName}@example.local`; + await page.waitForLoadState("networkidle"); + + // Go to the shared mailbox and check if the user has sender rights + await page.getByRole("button", { name: email }).click(); + await page + .getByRole("menuitem", { name: getMailboxEmail("shared") }) + .click(); + await page.waitForLoadState("networkidle"); + + // As the database is fresh, there should be no threads but the Import messages button should not be visible + // as the user does not have admin rights to the shared mailbox + await expect(page.getByText("No threads.")).toBeVisible(); + await expect( + page.getByRole("link", { name: "Import messages" }) + ).not.toBeVisible(); + + // And also in the header settings menu, there should be no entry to import messages + const header = page.locator(".c__header"); + const settingsButton = header.getByRole("button", { name: "More options" }); + await settingsButton.click(); + await expect( + page.getByRole("menuitem", { name: "Import messages" }) + ).not.toBeVisible(); + }); +}); diff --git a/src/e2e/src/__tests__/message-send.spec.ts b/src/e2e/src/__tests__/message-send.spec.ts new file mode 100644 index 00000000..f24667b9 --- /dev/null +++ b/src/e2e/src/__tests__/message-send.spec.ts @@ -0,0 +1,86 @@ +import test, { expect } from "@playwright/test"; +import { getMailboxEmail, resetDatabase } from "../utils"; +import { signInKeycloakIfNeeded } from "../utils-test"; +import path from "path"; +import { FIXTURES_PATH } from "../constants"; + +test.describe("Send Message", () => { + + test.beforeEach(async ({ page, browserName }) => { + await page.goto("/"); + await signInKeycloakIfNeeded({ page, username: `user.e2e.${browserName}` }); + }); + + test("should send a message then receive it", async ({ page, browserName }) => { + await page.waitForLoadState("networkidle"); + + const newMessageButton = page.getByRole("link", { name: "New message" }); + await newMessageButton.click(); + + await page.waitForURL("/mailbox/*/new"); + + const draftBoxLink = page.getByRole("link", { name: "Drafts" }); + let initialDraftCount = "0"; + if (await draftBoxLink.locator("span.mailbox__item-counter").isVisible()) { + initialDraftCount = (await draftBoxLink.locator("span.mailbox__item-counter").textContent()) ?? "0"; + } + + const formHeading = page.getByRole("heading", { name: "New message" }); + await formHeading.waitFor({ state: "visible" }); + await page.getByRole("combobox", { name: "To" }).fill(getMailboxEmail('shared')); + await page.getByRole("textbox", { name: "Subject" }).fill("Hello everyone!"); + await page.locator(".ProseMirror").pressSequentially("# E2E testing\n\nThis is a test message"); + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "Add attachments" }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.join(FIXTURES_PATH, "attachment.png")); + + await page.getByText("Draft saved").waitFor({ state: "visible" }); + + // The number of drafts should be incremented by 1 + await draftBoxLink.locator("span.mailbox__item-counter", { hasText: (parseInt(initialDraftCount!) + 1).toString() }).waitFor({ state: "visible" }); + + await page.getByRole("button", { name: "Send" }).click(); + await page.getByText("Sending message...").waitFor({ state: "visible" }); + await page.getByText("Message sent successfully").waitFor({ state: "visible" }); + + // Once message is sent, the number of drafts should be decremented by 1 + if (initialDraftCount !== "0") { + await draftBoxLink.locator("span.mailbox__item-counter", { hasText: initialDraftCount! }).waitFor({ state: "visible" }); + } else { + await draftBoxLink.locator("span.mailbox__item-counter").waitFor({ state: "hidden" }); + } + + // Go to the sentbox and check if the message is there + await page.getByRole("link", { name: "Sent" }).click(); + const threadItem = page.getByRole("link", { name: "Hello everyone!" }).first(); + await expect(threadItem).toBeVisible(); + expect(await threadItem.textContent()).toMatch(new RegExp(`User E2E ${browserName}`, "i")); + + // Go the shared mailbox and check if the message is there + await page.getByRole("button", { name: getMailboxEmail('user', browserName) }).click(); + await page.getByRole("menuitem", { name: getMailboxEmail('shared') }).click(); + await page.waitForLoadState("networkidle"); + + await page.getByRole("link", { name: "Inbox" }).click(); + + const messageItem = page.getByRole("link", { name: "Hello everyone!" }).first(); + await expect(messageItem).toBeVisible(); + expect(await messageItem.textContent()).toMatch(new RegExp(`User E2E ${browserName}`, "i")); + + // Open the message and check its content + await messageItem.click(); + await page.getByRole("heading", { name: "Hello everyone!", level: 2 }).waitFor({ state: "visible" }); + const threadList = page.locator('.thread-view__messages-list'); + const senderItem = threadList.getByRole("button", { name: getMailboxEmail('user', browserName) }); + await expect(senderItem).toBeVisible(); + const recipientItem = threadList.getByRole("button", { name: getMailboxEmail('shared') }); + await expect(recipientItem).toBeVisible(); + const iframeContent = page.locator('iframe').contentFrame(); + await iframeContent.getByRole('heading', { name: 'E2E testing' }).waitFor({ state: "visible" }); + const messageBodyText = iframeContent.getByText("This is a test message"); + await expect(messageBodyText).toBeVisible(); + await expect(page.getByText("1 attachment")).toBeVisible(); + await expect(page.getByText("attachment.png")).toBeVisible(); + }); +}); diff --git a/src/e2e/src/constants.ts b/src/e2e/src/constants.ts new file mode 100644 index 00000000..b74230cf --- /dev/null +++ b/src/e2e/src/constants.ts @@ -0,0 +1,11 @@ +import path from 'path'; + +if (!process.env.FRONTEND_BASE_URL || !process.env.BACKEND_BASE_URL || !process.env.KEYCLOAK_BASE_URL) { + throw new Error('FRONTEND_BASE_URL, BACKEND_BASE_URL and KEYCLOAK_BASE_URL must be set'); +} + +export const CLIENT_URL = process.env.FRONTEND_BASE_URL; +export const API_URL = process.env.BACKEND_BASE_URL; +export const AUTHENTICATION_URL = process.env.KEYCLOAK_BASE_URL; +export const STORAGE_STATE_PATH = path.join(__dirname, `./__tests__/.auth`); +export const FIXTURES_PATH = path.join(__dirname, `./fixtures`); diff --git a/src/e2e/src/fixtures/attachment.png b/src/e2e/src/fixtures/attachment.png new file mode 100644 index 00000000..8f8ad190 Binary files /dev/null and b/src/e2e/src/fixtures/attachment.png differ diff --git a/src/e2e/src/fixtures/old-message.eml b/src/e2e/src/fixtures/old-message.eml new file mode 100644 index 00000000..407ea94b --- /dev/null +++ b/src/e2e/src/fixtures/old-message.eml @@ -0,0 +1,152 @@ +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=example.local; + i=@example.local; q=dns/txt; s=stmessages; t=1763499505; h=to : cc : + from : subject : message-id : reply-to : in-reply-to : references : + date; bh=/gryh6mpJfhXsnFl3xd1wGBWx4pWqs7Wk83EDFk2j4A=; + b=K9tuR3AuMIvZ2BVcJB9OAaVA/7nxJm7vdbwGk12MLl/OPahEZFRGGspuz0phgKdfyC8ZY + vqC2cjMhnSkFikmJc++8iD3DGM/ECnsLLYIKCKQnuAMFjlpRw+n8F35fxE5d9rIumZShYyV + +dcV05YZ4ZGc1O6U42Z6FxKoyZNmFLVRIPbEEMV2PxXcX3wyPPsIVMAu3mdhPOZO52cBOUM + K3cuMPPT/zOlYua2c2zRHpd6RL614oEm7rzPg4/ThD2qIUxuGOPv6QWzemWPrM+6BPPAJMx + ecFz41BS2mzroPPg044VM/ZPrRiek/OEa/v3udQfNOaZMsJejFoMC/QTqqOQ== +Message-Id: +Date: Tue, 18 Nov 2025 20:58:25 +0000 +To: "e2e.old.account" +From: Sardine +Subject: An old message +Mime-Version: 1.0 +Content-Type: multipart/mixed; boundary="0891b8b8a4a646c8906e23db65c02896" + +--0891b8b8a4a646c8906e23db65c02896 +Mime-Version: 1.0 +Content-Type: multipart/alternative; + boundary="c9502976030147e9bfdf42924dd8b244" + +--c9502976030147e9bfdf42924dd8b244 +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; charset="ascii" +Mime-Version: 1.0 + +A good old message. + +--c9502976030147e9bfdf42924dd8b244 +Content-Transfer-Encoding: 7bit +Content-Type: text/html; charset="ascii" +Mime-Version: 1.0 + +

A good old message.

+--c9502976030147e9bfdf42924dd8b244-- + +--0891b8b8a4a646c8906e23db65c02896 +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="sardine.png" +Content-Type: image/png; name="sardine.png" +Mime-Version: 1.0 + +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 ++6E6ocLQnjDJsOiw5rD5cNfwsvDRCOOIdRHXIhUiuZFdUdiosKimqLmVbiv3rJyItogujB5epb0q +e9WV1QqrU1afjpGMYcaciEXHhsceiX3P9Gc2MOfivOJq42ZZLqy9rGdsR3Y5e5pjxynjTMbbxZfF +TyXYJexOmE50SKxInOG6cKu5L5I8k+qS5pP9kw8lf0oJT2lLxaXGpp7kyfCSeb1pymnZaYPp+umF +6aNrbNbsWTPL9+E3ZUAZqzK6BFTRz1S/UEe4RTiWaZ9Zk/kmKyzrRLZ0Ni+7P0cvZ3vOZK577rdr +UWtZa3vyVPM25Y2tc1pXvx5aH7e+Z4P6hoINExs9Nh7eRNiUvOmnfJP8svxXm8M3dxcoFWwsGN/i +saWlUKKQXziy1XZr3TbUNu62ge3m26u2fyxiF10tNimuKH5fwiq5+o3pN5XffNoRv2Og1LJ0/07M +Tt7O4V0Ouw6XSZfllo3v9tvdUU4vLyp/tSdmz5WKZRV1ewl7hXtHK30ru6o0qnZWva9OrL5d41zT +VqtYu712fh9739B+x/2tdUp1xXXvDnAP3Kn3qO9o0GqoOIg5mHnwSWNYY9+3jG+bmxSaips+HOId +Gj0cdLi32aq5+YjikdIWuEXYMn00+uiN71y/62o1bK1vo7UVHwPHhMeefh/7/fBxn+M9JxgnWn/Q +/KG2ndJe1AF15HTMdiZ2jnZFdg2e9D7Z023b3f6j0Y+HTqmeqjkte7r0DOFMwZlPZ3PPzp1LPzdz +PuH8eE9Mz/0LERdu9Qb2Dlz0uXj5kvulC31OfWcv210+dcXmysmrjKud1yyvdfRb9Lf/ZPFT+4Dl +QMd1q+tdN6xvdA8uHzwz5DB0/qbrzUu3vG5du73i9uBw6PCdkeiR0TvsO1N3U+6+uJd5b+H+xgfo +B0UPpR5WPFJ81PCz7s9to5ajp8dcx/ofBz++P84af/ZLxi/vJwqekJ9UTKpMNk+ZTZ2adp++8XTl +04ln6c8WZgp/lf619rnO8x9+c/ytfzZiduIF/8Wn30teyr889GrZq565gLlHr1NfL8wXvZF/c/gt +423fu/B3kwtZ77HvKz/ofuj+6PPxwafUT5/+BQOY8/ximktCAAAACXBIWXMAAAsTAAALEwEAmpwY +AAAHfmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJX +NU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczpt +ZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDMgNzkuOTY5MGE4N2ZjLCAyMDI1 +LzAzLzA2LTIwOjUwOjE2ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cu +dzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjph +Ym91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRj +PSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRw +Oi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFk +b2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFw +LzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3No +b3AgMjYuMTEgKE1hY2ludG9zaCkiIHhtcDpDcmVhdGVEYXRlPSIyMDI1LTExLTE4VDIxOjU2OjI0 +KzAxOjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyNS0xMS0xOFQyMTo1Nzo0MSswMTowMCIgeG1wOk1l +dGFkYXRhRGF0ZT0iMjAyNS0xMS0xOFQyMTo1Nzo0MSswMTowMCIgZGM6Zm9ybWF0PSJpbWFnZS9w +bmciIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiIHBob3Rvc2hvcDpJQ0NQcm9maWxlPSJzUkdCIElF +QzYxOTY2LTIuMSIgcGhvdG9zaG9wOkhpc3Rvcnk9IjIwMjUtMTEtMThUMjE6NTY6MjQrMDE6MDAm +I3g5O0ZpY2hpZXIgU2FucyB0aXRyZS0xIG91dmVydCYjeEE7MjAyNS0xMS0xOFQyMTo1Nzo0MSsw +MTowMCYjeDk7RmljaGllciBzYXJkaW5lLnBuZyBlbnJlZ2lzdHLDqSYjeEE7IiB4bXBNTTpJbnN0 +YW5jZUlEPSJ4bXAuaWlkOmQ5ZWQ0NjhjLTU5YWQtNDk0Yi05OTU4LWEzZmFkODdiMmQwYiIgeG1w +TU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjIzM2MxN2M4LTJlZDMtMGE0MS1h +YzU3LTBmZDg2NWExYjI3ZSIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjIyODA0 +YmFkLWYyMjAtNDc3Yi05MmEzLWFmOTA2NWZkNzRlNyI+IDxwaG90b3Nob3A6VGV4dExheWVycz4g +PHJkZjpCYWc+IDxyZGY6bGkgcGhvdG9zaG9wOkxheWVyTmFtZT0i8J+QnyIgcGhvdG9zaG9wOkxh +eWVyVGV4dD0i8J+QnyIvPiA8L3JkZjpCYWc+IDwvcGhvdG9zaG9wOlRleHRMYXllcnM+IDx4bXBN +TTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0 +Omluc3RhbmNlSUQ9InhtcC5paWQ6MjI4MDRiYWQtZjIyMC00NzdiLTkyYTMtYWY5MDY1ZmQ3NGU3 +IiBzdEV2dDp3aGVuPSIyMDI1LTExLTE4VDIxOjU2OjI0KzAxOjAwIiBzdEV2dDpzb2Z0d2FyZUFn +ZW50PSJBZG9iZSBQaG90b3Nob3AgMjYuMTEgKE1hY2ludG9zaCkiLz4gPHJkZjpsaSBzdEV2dDph +Y3Rpb249ImNvbnZlcnRlZCIgc3RFdnQ6cGFyYW1ldGVycz0iZnJvbSBhcHBsaWNhdGlvbi92bmQu +YWRvYmUucGhvdG9zaG9wIHRvIGltYWdlL3BuZyIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2 +ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZDllZDQ2OGMtNTlhZC00OTRiLTk5NTgtYTNm +YWQ4N2IyZDBiIiBzdEV2dDp3aGVuPSIyMDI1LTExLTE4VDIxOjU3OjQxKzAxOjAwIiBzdEV2dDpz +b2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgMjYuMTEgKE1hY2ludG9zaCkiIHN0RXZ0OmNo +YW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9u +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 + +--0891b8b8a4a646c8906e23db65c02896-- diff --git a/src/e2e/src/fixtures/sample.txt b/src/e2e/src/fixtures/sample.txt new file mode 100644 index 00000000..b091d73e --- /dev/null +++ b/src/e2e/src/fixtures/sample.txt @@ -0,0 +1 @@ +I’m a dummy text file. \ No newline at end of file diff --git a/src/e2e/src/utils-test.ts b/src/e2e/src/utils-test.ts new file mode 100644 index 00000000..5a21198d --- /dev/null +++ b/src/e2e/src/utils-test.ts @@ -0,0 +1,39 @@ + +import { expect, Page } from "@playwright/test"; +import { AUTHENTICATION_URL } from "./constants"; +import { getStorageStatePath } from "./utils"; + +export const signInKeycloakIfNeeded = async ({ page, username }: { page: Page, username: string }) => { + // Await that GET request /api/v1.0/users/me is completed. + const meResponse = await page.waitForResponse((response) => response.url().includes('/api/v1.0/users/me/') && [200, 401].includes(response.status())); + const isAuthenticated = meResponse.status() === 200; + + if (isAuthenticated) return; + + const email = `${username}@example.local`; + const storageStatePath = getStorageStatePath(username); + + const proConnectButton = page.locator('button.pro-connect-button'); + proConnectButton.click(); + + await page.waitForURL(`${AUTHENTICATION_URL}/realms/messages/protocol/openid-connect/auth**`); + const attemptedUsernameInput = page.locator('input[id="kc-attempted-username"]'); + if (await attemptedUsernameInput.isVisible()) { + if (await attemptedUsernameInput.inputValue() !== email) { + const restartLoginButton = page.getByRole('button', { name: 'Restart login' }); + await restartLoginButton.click(); + await page.fill('input[name="username"]', email); + } + } else { + await page.fill('input[name="username"]', email); + } + await page.fill('input[name="password"]', 'e2e'); + await page.click('button[type="submit"]'); + await page.waitForURL(`/`, { waitUntil: 'networkidle' }); + + expect(proConnectButton).not.toBeVisible(); + const mailboxName = await page.getByRole('button', { name: email }); + expect(mailboxName).toBeVisible(); + + await page.context().storageState({ path: storageStatePath }); +}; diff --git a/src/e2e/src/utils.ts b/src/e2e/src/utils.ts new file mode 100644 index 00000000..4c97b3c8 --- /dev/null +++ b/src/e2e/src/utils.ts @@ -0,0 +1,55 @@ +/** + * Utility functions for E2E tests. + */ + +import { exec } from 'child_process'; +import { STORAGE_STATE_PATH } from './constants'; +import path from 'path'; +import fs from 'fs'; + +/** + * Execute a npm command in the runner container. + */ +async function runNpmCommand(command: string, args: string[] = [], timeout: number = 1000): Promise { + const commandArgs = [command, ...args].join(' '); + + const fullCommand = `npm run ${commandArgs}`; + + + if (timeout) { + await new Promise((resolve) => { setTimeout(resolve, timeout) }); + } + + return new Promise((resolve, reject) => { + exec(fullCommand, (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + }); + }); +} + +/** + * Reset the database by flushing all data (keeps schema) then + * bootstrapping the demo data. + */ +export async function resetDatabase(): Promise { + await runNpmCommand('db:reset'); +} + +export const getStorageStatePath = (username: string): string => { + return path.join(STORAGE_STATE_PATH, `user-${username}.json`); +}; + +/** + * Helper function to get storage state path if it exists, otherwise undefined. + * This allows setup projects to reuse existing authentication if available. + */ +export const getStorageStatePathIfExists = (username: string): string | undefined => { + const path = getStorageStatePath(username); + return fs.existsSync(path) ? path : undefined; +}; + +export const getMailboxEmail = (username: 'user' | 'mailbox_admin' | 'domain_admin' | 'super_admin' | 'shared', browserName?: 'chromium' | 'firefox' | 'webkit'): string => { + if (browserName) return `${username}.e2e.${browserName}@example.local`; + return `${username}.e2e@example.local`; +}; diff --git a/src/frontend/src/features/forms/components/message-form/index.tsx b/src/frontend/src/features/forms/components/message-form/index.tsx index 0ff9517a..ab21d06a 100644 --- a/src/frontend/src/features/forms/components/message-form/index.tsx +++ b/src/frontend/src/features/forms/components/message-form/index.tsx @@ -358,7 +358,7 @@ export const MessageForm = ({ */ const saveDraft = async () => { const data = form.getValues(); - if (!canWriteMessages) return; + if (!canWriteMessages || isSavingDraft) return; const saveDraftNeeded = ( Object.keys(form.formState.dirtyFields).length > 0 diff --git a/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-actions/index.tsx b/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-actions/index.tsx index 20d2ae70..5c6195b9 100644 --- a/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-actions/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-actions/index.tsx @@ -4,6 +4,7 @@ import { Button } from "@openfun/cunningham-react"; import { useMailboxContext } from "@/features/providers/mailbox"; import { useLayoutContext } from "../../../main"; import useAbility, { Abilities } from "@/hooks/use-ability"; +import { Icon, IconType } from "@gouvfr-lasuite/ui-kit"; export const MailboxPanelActions = () => { const { t } = useTranslation(); @@ -28,7 +29,7 @@ export const MailboxPanelActions = () => {