From c62635efa619b8ad095bc3f89985dafdbc60b254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Sun, 16 Nov 2025 17:10:16 +0100 Subject: [PATCH 1/4] e2e(setup): Auth context and utils as fixtures --- .gitignore | 1 + README.md | 7 ++-- e2e/api-schema.spec.ts | 2 +- e2e/login.spec.ts | 19 +++++------ e2e/setup/auth.setup.ts | 32 ++++++++++++++++++ e2e/users.spec.ts | 75 +++++++++++++++++++++++++++++++++++++++++ e2e/utils/constants.ts | 5 +++ e2e/utils/index.ts | 9 +++++ e2e/utils/page-utils.ts | 61 --------------------------------- e2e/utils/page.ts | 66 ++++++++++++++++++++++++++++++++++++ e2e/utils/types.ts | 11 ++++++ package.json | 2 ++ playwright.config.ts | 5 +++ 13 files changed, 220 insertions(+), 75 deletions(-) create mode 100644 e2e/setup/auth.setup.ts create mode 100644 e2e/users.spec.ts create mode 100644 e2e/utils/index.ts delete mode 100644 e2e/utils/page-utils.ts create mode 100644 e2e/utils/page.ts create mode 100644 e2e/utils/types.ts diff --git a/.gitignore b/.gitignore index 01eb91354..6a707f2f0 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ src/server/db/generated /test-results/ /playwright-report/ /playwright/.cache/ +e2e/.auth certificates diff --git a/README.md b/README.md index 43c523cb0..c9b85ac2a 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,13 @@ If you want to use the same set of custom duotone icons that Start UI is already E2E tests are setup with Playwright. ```sh -pnpm e2e # Run tests in headless mode, this is the command executed in CI -pnpm e2e:ui # Open a UI which allow you to run specific tests and see test execution +pnpm e2e # Run tests in headless mode, this is the command executed in CI +pnpm e2e:setup # Setup context to be used across test for more efficient execution +pnpm e2e:ui # Open a UI which allow you to run specific tests and see test execution ``` +> [!WARNING] +> The generated e2e context files contain authentication logic. If you make changes to your local database instance, you should re-run `pnpm e2e:setup`. It will be run automatically in a CI context. ## Production ```bash diff --git a/e2e/api-schema.spec.ts b/e2e/api-schema.spec.ts index f56c3e610..d96627d7d 100644 --- a/e2e/api-schema.spec.ts +++ b/e2e/api-schema.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from 'e2e/utils'; test.describe('App Rest API Schema', () => { test(`App API schema is building without error`, async ({ request }) => { diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 4eaf6bb53..08deecb13 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -1,28 +1,25 @@ -import { expect, test } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { test } from 'e2e/utils'; import { ADMIN_EMAIL, USER_EMAIL } from 'e2e/utils/constants'; -import { pageUtils } from 'e2e/utils/page-utils'; test.describe('Login flow', () => { test('Login as admin', async ({ page }) => { - const utils = pageUtils(page); - await page.goto('/login'); - await utils.login({ email: ADMIN_EMAIL }); + await page.to('/login'); + await page.login({ email: ADMIN_EMAIL }); await page.waitForURL('/manager'); await expect(page.getByTestId('layout-manager')).toBeVisible(); }); test('Login as user', async ({ page }) => { - const utils = pageUtils(page); - await page.goto('/login'); - await utils.login({ email: USER_EMAIL }); + await page.to('/login'); + await page.login({ email: USER_EMAIL }); await page.waitForURL('/app'); await expect(page.getByTestId('layout-app')).toBeVisible(); }); test('Login with redirect', async ({ page }) => { - const utils = pageUtils(page); - await page.goto('/app'); - await utils.login({ email: ADMIN_EMAIL }); + await page.to('/app'); + await page.login({ email: ADMIN_EMAIL }); await page.waitForURL('/app'); await expect(page.getByTestId('layout-app')).toBeVisible(); }); diff --git a/e2e/setup/auth.setup.ts b/e2e/setup/auth.setup.ts new file mode 100644 index 000000000..1c4454ab6 --- /dev/null +++ b/e2e/setup/auth.setup.ts @@ -0,0 +1,32 @@ +import { expect } from '@playwright/test'; +import { test as setup } from 'e2e/utils'; +import { + ADMIN_EMAIL, + ADMIN_FILE, + USER_EMAIL, + USER_FILE, +} from 'e2e/utils/constants'; + +/** + * @see https://playwright.dev/docs/auth#multiple-signed-in-roles + */ + +setup('authenticate as admin', async ({ page }) => { + await page.to('/login'); + await page.login({ email: ADMIN_EMAIL }); + + await page.waitForURL('/manager'); + await expect(page.getByTestId('layout-manager')).toBeVisible(); + + await page.context().storageState({ path: ADMIN_FILE }); +}); + +setup('authenticate as user', async ({ page }) => { + await page.to('/login'); + await page.login({ email: USER_EMAIL }); + + await page.waitForURL('/app'); + await expect(page.getByTestId('layout-app')).toBeVisible(); + + await page.context().storageState({ path: USER_FILE }); +}); diff --git a/e2e/users.spec.ts b/e2e/users.spec.ts new file mode 100644 index 000000000..a581b246b --- /dev/null +++ b/e2e/users.spec.ts @@ -0,0 +1,75 @@ +import { expect } from '@playwright/test'; +import { test } from 'e2e/utils'; +import { randomString } from 'remeda'; + +test.describe('User management as user', () => { + test.use({ storageState: 'e2e/.auth/user.json' }); + test('Should not have access', async ({ page }) => { + await page.to('/manager/users'); + + await expect( + page.getByText("You don't have access to this page") + ).toBeVisible(); + }); +}); + +test.describe('User management as manager', () => { + test.use({ + storageState: 'e2e/.auth/admin.json', + }); + + test.beforeEach(async ({ page }) => { + await page.to('/manager/users'); + }); + + test('Create a user', async ({ page }) => { + await page.getByText('New User').click(); + + const randomId = randomString(8); + const uniqueEmail = `new-user-${randomId}@user.com`; + + // Fill the form + await page.waitForURL('/manager/users/new'); + await page.getByLabel('Name').fill('New user'); + await page.getByLabel('Email').fill(uniqueEmail); + await page.getByText('Create').click(); + + await page.waitForURL('/manager/users'); + await page.getByPlaceholder('Search...').fill('new-user'); + await expect(page.getByText(uniqueEmail)).toBeVisible(); + }); + + test('Edit a user', async ({ page }) => { + await page.getByText('admin@admin.com').click({ + force: true, + }); + + await page.getByRole('link', { name: 'Edit user' }).click(); + + const randomId = randomString(8); + const newAdminName = `Admin ${randomId}`; + await page.getByLabel('Name').fill(newAdminName); + await page.getByText('Save').click(); + + await expect(page.getByText(newAdminName).first()).toBeVisible(); + }); + + test('Delete a user', async ({ page }) => { + await page + .getByText('user', { + exact: true, + }) + .first() + .click({ force: true }); + + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect( + page.getByText('You are about to permanently delete this user.') + ).toBeVisible(); + + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByText('User deleted')).toBeVisible(); + }); +}); diff --git a/e2e/utils/constants.ts b/e2e/utils/constants.ts index b53db5be9..b209c7d99 100644 --- a/e2e/utils/constants.ts +++ b/e2e/utils/constants.ts @@ -1,2 +1,7 @@ +const AUTH_FILE_BASE = 'e2e/.auth'; + +export const USER_FILE = `${AUTH_FILE_BASE}/user.json`; export const USER_EMAIL = 'user@user.com'; + +export const ADMIN_FILE = `${AUTH_FILE_BASE}/admi.json`; export const ADMIN_EMAIL = 'admin@admin.com'; diff --git a/e2e/utils/index.ts b/e2e/utils/index.ts new file mode 100644 index 000000000..572912c2e --- /dev/null +++ b/e2e/utils/index.ts @@ -0,0 +1,9 @@ +import { test as base } from '@playwright/test'; +import { ExtendedPage, pageWithUtils } from 'e2e/utils/page'; + +const test = base.extend({ + page: pageWithUtils, +}); + +export * from '@playwright/test'; +export { test }; diff --git a/e2e/utils/page-utils.ts b/e2e/utils/page-utils.ts deleted file mode 100644 index 01450f39a..000000000 --- a/e2e/utils/page-utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { expect, Page } from '@playwright/test'; - -import { - AUTH_EMAIL_OTP_MOCKED, - AUTH_SIGNUP_ENABLED, -} from '@/features/auth/config'; -import { DEFAULT_LANGUAGE_KEY } from '@/lib/i18n/constants'; - -import locales from '@/locales'; -import { FileRouteTypes } from '@/routeTree.gen'; - -/** - * Utilities constructor - * - * @example - * ```ts - * test.describe('My scope', () => { - * test('My test', async ({ page }) => { - * const utils = pageUtils(page); - * - * // No need too pass page on each util - * await utils.login(...) - * }) - * }) - * ``` - */ -export const pageUtils = (page: Page) => { - return { - /** - * Utility used to authenticate a user on the app - */ - async login(input: { email: string; code?: string }) { - const routeLogin = '/login' satisfies FileRouteTypes['to']; - const routeLoginVerify = '/login/verify' satisfies FileRouteTypes['to']; - await page.waitForURL(`**${routeLogin}**`); - - await expect( - page.getByText( - locales[DEFAULT_LANGUAGE_KEY].auth.pageLoginWithSignUp.title - ) - ).toBeVisible(); - - await page - .getByPlaceholder(locales[DEFAULT_LANGUAGE_KEY].auth.common.email.label) - .fill(input.email); - - await page - .getByRole('button', { - name: locales[DEFAULT_LANGUAGE_KEY].auth[ - AUTH_SIGNUP_ENABLED ? 'pageLoginWithSignUp' : 'pageLogin' - ].loginWithEmail, - }) - .click(); - - await page.waitForURL(`**${routeLoginVerify}**`); - await page - .getByText(locales[DEFAULT_LANGUAGE_KEY].auth.common.otp.label) - .fill(input.code ?? AUTH_EMAIL_OTP_MOCKED); - }, - } as const; -}; diff --git a/e2e/utils/page.ts b/e2e/utils/page.ts new file mode 100644 index 000000000..259db7d8d --- /dev/null +++ b/e2e/utils/page.ts @@ -0,0 +1,66 @@ +import { expect, Page } from '@playwright/test'; +import { CustomFixture } from 'e2e/utils/types'; + +import { DEFAULT_LANGUAGE_KEY } from '@/lib/i18n/constants'; + +import { + AUTH_EMAIL_OTP_MOCKED, + AUTH_SIGNUP_ENABLED, +} from '@/features/auth/config'; +import locales from '@/locales'; +import { FileRouteTypes } from '@/routeTree.gen'; + +interface PageUtils { + /** + * Utility used to authenticate a user on the app + */ + login: (input: { email: string; code?: string }) => Promise; + + /** + * Override of the `page.goto` method with typed routes from the app + */ + to: ( + url: FileRouteTypes['to'], + options?: Parameters[1] + ) => ReturnType; +} + +export type ExtendedPage = { page: PageUtils }; + +export const pageWithUtils: CustomFixture = async ( + { page }, + apply +) => { + page.login = async function login(input: { email: string; code?: string }) { + const routeLogin = '/login' satisfies FileRouteTypes['to']; + const routeLoginVerify = '/login/verify' satisfies FileRouteTypes['to']; + await page.waitForURL(`**${routeLogin}**`); + + await expect( + page.getByText( + locales[DEFAULT_LANGUAGE_KEY].auth.pageLoginWithSignUp.title + ) + ).toBeVisible(); + + await page + .getByPlaceholder(locales[DEFAULT_LANGUAGE_KEY].auth.common.email.label) + .fill(input.email); + + await page + .getByRole('button', { + name: locales[DEFAULT_LANGUAGE_KEY].auth[ + AUTH_SIGNUP_ENABLED ? 'pageLoginWithSignUp' : 'pageLogin' + ].loginWithEmail, + }) + .click(); + + await page.waitForURL(`**${routeLoginVerify}**`); + await page + .getByText(locales[DEFAULT_LANGUAGE_KEY].auth.common.otp.label) + .fill(input.code ?? AUTH_EMAIL_OTP_MOCKED); + }; + + page.to = page.goto; + + apply(page); +}; diff --git a/e2e/utils/types.ts b/e2e/utils/types.ts new file mode 100644 index 000000000..0b8de3ab1 --- /dev/null +++ b/e2e/utils/types.ts @@ -0,0 +1,11 @@ +import { + PlaywrightTestArgs, + PlaywrightTestOptions, + TestFixture, +} from '@playwright/test'; +import { ExtendedPage } from 'e2e/utils/page'; + +export type CustomFixture = TestFixture< + TReturn, + PlaywrightTestArgs & PlaywrightTestOptions & ExtendedPage +>; diff --git a/package.json b/package.json index 886c8ce7a..249665e7d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test": "vitest --browser.headless", "test:ci": "vitest run", "test:ui": "vitest", + "e2e:setup": "dotenv -- cross-env playwright test --project=setup", "e2e": "dotenv -- cross-env playwright test", "e2e:ui": "dotenv -- cross-env playwright test --ui", "dk:init": "docker compose up -d", @@ -37,6 +38,7 @@ "dk:stop": "docker compose stop", "dk:clear": "docker compose down --volumes", "db:init": "pnpm db:push && pnpm db:seed", + "db:reset": "pnpm dk:clear && pnpm dk:init && pnpm db:init", "db:push": "prisma db push", "db:ui": "prisma studio", "db:seed": "dotenv -- cross-env node ./run-jiti ./prisma/seed/index.ts" diff --git a/playwright.config.ts b/playwright.config.ts index 3c87d2e5a..ce56a6f77 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -32,18 +32,23 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ + // eslint-disable-next-line sonarjs/slow-regex + { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'] }, + dependencies: process.env.CI ? ['setup'] : [], }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, + dependencies: process.env.CI ? ['setup'] : [], }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, + dependencies: process.env.CI ? ['setup'] : [], }, ], From 6b9369a8419cd3a401048d7bff93851ac1dbcee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Sun, 16 Nov 2025 17:12:35 +0100 Subject: [PATCH 2/4] fix: typo --- e2e/login.spec.ts | 3 +-- e2e/users.spec.ts | 3 +-- e2e/utils/constants.ts | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 08deecb13..257f812b3 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -1,5 +1,4 @@ -import { expect } from '@playwright/test'; -import { test } from 'e2e/utils'; +import { expect, test } from 'e2e/utils'; import { ADMIN_EMAIL, USER_EMAIL } from 'e2e/utils/constants'; test.describe('Login flow', () => { diff --git a/e2e/users.spec.ts b/e2e/users.spec.ts index a581b246b..f31da96c8 100644 --- a/e2e/users.spec.ts +++ b/e2e/users.spec.ts @@ -1,5 +1,4 @@ -import { expect } from '@playwright/test'; -import { test } from 'e2e/utils'; +import { expect, test } from 'e2e/utils'; import { randomString } from 'remeda'; test.describe('User management as user', () => { diff --git a/e2e/utils/constants.ts b/e2e/utils/constants.ts index b209c7d99..c8becb29d 100644 --- a/e2e/utils/constants.ts +++ b/e2e/utils/constants.ts @@ -3,5 +3,5 @@ const AUTH_FILE_BASE = 'e2e/.auth'; export const USER_FILE = `${AUTH_FILE_BASE}/user.json`; export const USER_EMAIL = 'user@user.com'; -export const ADMIN_FILE = `${AUTH_FILE_BASE}/admi.json`; +export const ADMIN_FILE = `${AUTH_FILE_BASE}/admin.json`; export const ADMIN_EMAIL = 'admin@admin.com'; From c389f525820910f323492661492f7a4be91240e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Sun, 16 Nov 2025 17:23:37 +0100 Subject: [PATCH 3/4] fix(book/seed): Avoir unique title, author constraint failure --- prisma/seed/book.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/prisma/seed/book.ts b/prisma/seed/book.ts index d3a19e2a4..ae77d73e2 100644 --- a/prisma/seed/book.ts +++ b/prisma/seed/book.ts @@ -43,6 +43,20 @@ export async function createBooks() { await Promise.all( Array.from({ length: Math.max(0, 100 - existingCount) }, async () => { + const author = faker.book.author(); + const title = faker.book.title(); + + // Avoid @unique([title, author]) constraint failure + const book = await db.book.findFirst({ + where: { + author, + title, + }, + }); + if (book) { + return; + } + await db.book.create({ data: { author: faker.book.author(), From ede04703ddfa41022f349054eaee239f76940d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Sun, 16 Nov 2025 17:32:54 +0100 Subject: [PATCH 4/4] fix: feedbacks --- e2e/users.spec.ts | 8 ++++---- e2e/utils/page.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/e2e/users.spec.ts b/e2e/users.spec.ts index f31da96c8..f18873bd6 100644 --- a/e2e/users.spec.ts +++ b/e2e/users.spec.ts @@ -1,8 +1,10 @@ import { expect, test } from 'e2e/utils'; +import { ADMIN_FILE, USER_FILE } from 'e2e/utils/constants'; import { randomString } from 'remeda'; test.describe('User management as user', () => { - test.use({ storageState: 'e2e/.auth/user.json' }); + test.use({ storageState: USER_FILE }); + test('Should not have access', async ({ page }) => { await page.to('/manager/users'); @@ -13,9 +15,7 @@ test.describe('User management as user', () => { }); test.describe('User management as manager', () => { - test.use({ - storageState: 'e2e/.auth/admin.json', - }); + test.use({ storageState: ADMIN_FILE }); test.beforeEach(async ({ page }) => { await page.to('/manager/users'); diff --git a/e2e/utils/page.ts b/e2e/utils/page.ts index 259db7d8d..518168688 100644 --- a/e2e/utils/page.ts +++ b/e2e/utils/page.ts @@ -62,5 +62,5 @@ export const pageWithUtils: CustomFixture = async ( page.to = page.goto; - apply(page); + await apply(page); };