From dbf8c8856b1468d7285d51c1edd59a064b07d71f Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 13 Feb 2026 09:07:23 +0100 Subject: [PATCH] test(e2e) : add Playwright setup with product and category CRUD specs Co-Authored-By: Claude Opus 4.6 --- .gitignore | 5 + e2e/auth.setup.ts | 35 ++++ e2e/product-category-crud.spec.ts | 166 +++++++++++++++++ e2e/product-crud.spec.ts | 299 ++++++++++++++++++++++++++++++ package-lock.json | 64 +++++++ package.json | 6 +- playwright.config.ts | 37 ++++ 7 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 e2e/auth.setup.ts create mode 100644 e2e/product-category-crud.spec.ts create mode 100644 e2e/product-crud.spec.ts create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index 4a7f73a..1f6f542 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ logs .env .env.* !.env.example + +# Playwright +e2e/.auth/ +playwright-report/ +test-results/ diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..c47f676 --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,35 @@ +import { test as setup, expect } from '@playwright/test' + +const AUTH_FILE = 'e2e/.auth/session.json' + +/** + * Authentication setup: selects the first available profile + * to establish a session cookie before running any test. + * + * The app uses a profile-based session system: + * - GET /api/session/profiles → list available profiles + * - POST /api/session/profile → activate a profile (sets cookie) + * + * The global middleware (profile.global.ts) redirects to /profiles + * if no active profile is found. + */ +setup('select a profile to authenticate', async ({ page }) => { + // Go to the profiles page + await page.goto('/profiles') + + // Wait for profiles to load + await expect(page.getByRole('heading', { name: 'Choisir un profil' })).toBeVisible({ timeout: 15_000 }) + + // Wait for at least one profile button to appear + const profileButton = page.locator('button.btn-outline').first() + await expect(profileButton).toBeVisible({ timeout: 10_000 }) + + // Click the first available profile + await profileButton.click() + + // Wait for redirect to home page (profile selected → session cookie set) + await page.waitForURL('/', { timeout: 10_000 }) + + // Save authenticated state (cookies + localStorage) + await page.context().storageState({ path: AUTH_FILE }) +}) diff --git a/e2e/product-category-crud.spec.ts b/e2e/product-category-crud.spec.ts new file mode 100644 index 0000000..8296db2 --- /dev/null +++ b/e2e/product-category-crud.spec.ts @@ -0,0 +1,166 @@ +import { test, expect } from '@playwright/test' + +/** + * E2E tests for Product Category CRUD operations. + * + * Prerequisites: + * - Frontend running on http://localhost:3001 (npm run dev) + * - Backend running on http://localhost:8081 (docker compose up) + * - Auth setup must run first (profile selected) + */ + +const UNIQUE = Date.now() +const CATEGORY_NAME = `E2E Catégorie Produit ${UNIQUE}` +const CATEGORY_NOTES = `Notes de test automatisé ${UNIQUE}` +const CATEGORY_NAME_UPDATED = `${CATEGORY_NAME} modifié` +const CATEGORY_NOTES_UPDATED = `${CATEGORY_NOTES} — mis à jour` + +test.describe('Product Category CRUD', () => { + test.describe.configure({ mode: 'serial' }) + + // ────────────────────────────────────────────── + // CREATE + // ────────────────────────────────────────────── + + test('should display the product category list page', async ({ page }) => { + await page.goto('/product-category') + await expect(page.getByRole('heading', { name: 'Catégories de produit' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByText('Catégories enregistrées')).toBeVisible() + }) + + test('should navigate to the create form', async ({ page }) => { + await page.goto('/product-category') + // The toolbar button text is "Créer" (with a plus icon) + await page.getByRole('button', { name: /créer/i }).click() + await expect(page).toHaveURL('/product-category/new') + await expect(page.getByRole('heading', { name: 'Nouvelle catégorie de produit' })).toBeVisible() + }) + + test('should show validation error for short name', async ({ page }) => { + await page.goto('/product-category/new') + await page.locator('#model-type-name').fill('A') + // The form submit button in ModelTypeForm is also "Créer" + await page.locator('button[type="submit"]').click() + await expect(page.getByText('Le nom doit contenir au moins 2 caractères')).toBeVisible() + }) + + test('should create a new product category', async ({ page }) => { + await page.goto('/product-category/new') + await page.locator('#model-type-name').fill(CATEGORY_NAME) + await page.locator('#model-type-notes').fill(CATEGORY_NOTES) + + // Verify category is locked to PRODUCT + const categorySelect = page.locator('#model-type-category') + await expect(categorySelect).toBeDisabled() + await expect(categorySelect).toHaveValue('PRODUCT') + + await page.locator('button[type="submit"]').click() + + // Should redirect to list and show success toast + await expect(page).toHaveURL('/product-category', { timeout: 10_000 }) + await expect(page.getByText('Catégorie de produit créée avec succès')).toBeVisible() + }) + + // ────────────────────────────────────────────── + // READ + // ────────────────────────────────────────────── + + test('should display the created category in the list', async ({ page }) => { + await page.goto('/product-category') + // Target the table cell specifically (desktop view also renders a mobile card) + await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 10_000 }) + }) + + test('should find the category via search', async ({ page }) => { + await page.goto('/product-category') + + // Type in search input (placeholder: "Rechercher par nom…") + const searchInput = page.getByPlaceholder('Rechercher par nom…') + await searchInput.fill(UNIQUE.toString()) + // Wait for debounce (300ms) + API response + await page.waitForTimeout(500) + + await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 5_000 }) + }) + + // ────────────────────────────────────────────── + // UPDATE + // ────────────────────────────────────────────── + + test('should navigate to the edit page', async ({ page }) => { + await page.goto('/product-category') + await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 10_000 }) + + // Find the row with our category and click "Éditer" + const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME }) + await row.getByRole('button', { name: 'Éditer' }).click() + + await expect(page.getByRole('heading', { name: /modifier/i })).toBeVisible({ timeout: 10_000 }) + }) + + test('should edit the category name and notes', async ({ page }) => { + await page.goto('/product-category') + await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 10_000 }) + + const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME }) + await row.getByRole('button', { name: 'Éditer' }).click() + await expect(page.getByRole('heading', { name: /modifier/i })).toBeVisible({ timeout: 10_000 }) + + // Update name + const nameInput = page.locator('#model-type-name') + await nameInput.clear() + await nameInput.fill(CATEGORY_NAME_UPDATED) + + // Update notes + const notesTextarea = page.locator('#model-type-notes') + await notesTextarea.clear() + await notesTextarea.fill(CATEGORY_NOTES_UPDATED) + + await page.locator('button[type="submit"]').click() + + // Should redirect and show success + await expect(page).toHaveURL('/product-category', { timeout: 10_000 }) + await expect(page.getByText('Catégorie de produit mise à jour avec succès')).toBeVisible() + }) + + test('should display updated category in the list', async ({ page }) => { + await page.goto('/product-category') + await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible({ timeout: 10_000 }) + }) + + // ────────────────────────────────────────────── + // DELETE + // ────────────────────────────────────────────── + + test('should cancel deletion when clicking Annuler', async ({ page }) => { + await page.goto('/product-category') + await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible({ timeout: 10_000 }) + + const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME_UPDATED }) + await row.getByRole('button', { name: 'Supprimer' }).click() + + // Confirmation modal should appear + await expect(page.getByText('Supprimer ce type ?')).toBeVisible() + await page.getByRole('button', { name: 'Annuler' }).click() + + // Category should still be present + await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible() + }) + + test('should delete the category', async ({ page }) => { + await page.goto('/product-category') + await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible({ timeout: 10_000 }) + + const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME_UPDATED }) + await row.getByRole('button', { name: 'Supprimer' }).click() + + // Confirm deletion in modal + await expect(page.getByText('Supprimer ce type ?')).toBeVisible() + // Click the confirm "Supprimer" button inside the modal (btn-error style) + await page.locator('button.btn-error').filter({ hasText: 'Supprimer' }).click() + + // Should show success toast and category should disappear + await expect(page.getByText(/supprimé avec succès/i)).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).not.toBeVisible() + }) +}) diff --git a/e2e/product-crud.spec.ts b/e2e/product-crud.spec.ts new file mode 100644 index 0000000..2a11598 --- /dev/null +++ b/e2e/product-crud.spec.ts @@ -0,0 +1,299 @@ +import { test, expect, type Page } from '@playwright/test' + +/** + * E2E tests for Product CRUD operations. + * + * Prerequisites: + * - Frontend running on http://localhost:3001 (npm run dev) + * - Backend running on http://localhost:8081 (docker compose up) + * - Auth setup must run first (profile selected) + * + * These tests create a temporary product category, use it to test + * the full product CRUD, then clean up both. + */ + +const UNIQUE = Date.now() +const TEST_CATEGORY_NAME = `E2E Cat Produit ${UNIQUE}` +const PRODUCT_NAME = `E2E Produit Test ${UNIQUE}` +const PRODUCT_REFERENCE = `REF-E2E-${UNIQUE}` +const PRODUCT_PRICE = '42.50' +const PRODUCT_NAME_UPDATED = `${PRODUCT_NAME} modifié` +const PRODUCT_REFERENCE_UPDATED = `${PRODUCT_REFERENCE}-UPD` +const PRODUCT_PRICE_UPDATED = '99.99' + +// ────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────── + +/** + * Creates a product category via the UI. + */ +async function createTestCategory(page: Page) { + await page.goto('/product-category/new') + await page.locator('#model-type-name').fill(TEST_CATEGORY_NAME) + await page.locator('button[type="submit"]').click() + await expect(page).toHaveURL('/product-category', { timeout: 10_000 }) + await expect(page.getByText('Catégorie de produit créée avec succès')).toBeVisible() +} + +/** + * Selects an option in a SearchSelect component. + * + * The SearchSelect renders: + * .search-select > .relative > input[placeholder] + * .search-select > .relative > div (dropdown) > ul > li > button + */ +async function selectSearchOption(page: Page, placeholder: string, searchText: string) { + const input = page.getByPlaceholder(placeholder) + await input.click() + await input.fill(searchText) + + // The dropdown is inside .search-select > .relative > div > ul > li > button + const option = page.locator('.search-select ul li button') + .filter({ hasText: searchText }) + .first() + await option.waitFor({ state: 'visible', timeout: 10_000 }) + await option.click() +} + +/** + * Cleans up test data: deletes the category via the UI. + */ +async function cleanupTestCategory(page: Page) { + await page.goto('/product-category') + // Wait for list to load + await page.waitForTimeout(1_000) + + const row = page.getByRole('row').filter({ hasText: TEST_CATEGORY_NAME }) + if (await row.isVisible().catch(() => false)) { + await row.getByRole('button', { name: 'Supprimer' }).click() + const confirmBtn = page.locator('button.btn-error').filter({ hasText: 'Supprimer' }) + await confirmBtn.waitFor({ state: 'visible', timeout: 5_000 }) + await confirmBtn.click() + await page.waitForTimeout(1_000) + } +} + +test.describe('Product CRUD', () => { + test.describe.configure({ mode: 'serial' }) + + // ────────────────────────────────────────────── + // SETUP: Create a test category + // ────────────────────────────────────────────── + + test('setup: create a test product category', async ({ page }) => { + await createTestCategory(page) + }) + + // ────────────────────────────────────────────── + // LIST PAGE + // ────────────────────────────────────────────── + + test('should display the product catalog page', async ({ page }) => { + await page.goto('/product-catalog') + await expect(page.getByRole('heading', { name: 'Catalogue des produits' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('link', { name: /ajouter un produit/i })).toBeVisible() + await expect(page.getByRole('link', { name: /gérer les catégories/i })).toBeVisible() + }) + + test('should navigate to create product page', async ({ page }) => { + await page.goto('/product-catalog') + await page.getByRole('link', { name: /ajouter un produit/i }).click() + await expect(page).toHaveURL('/product/create') + await expect(page.getByRole('heading', { name: 'Nouveau produit' })).toBeVisible() + }) + + // ────────────────────────────────────────────── + // CREATE + // ────────────────────────────────────────────── + + test('should show disabled fields until category is selected', async ({ page }) => { + await page.goto('/product/create') + + // Name input should be disabled before selecting a category + const nameInput = page.getByPlaceholder('Nom affiché dans le catalogue') + await expect(nameInput).toBeDisabled() + }) + + test('should create a product with all fields', async ({ page }) => { + await page.goto('/product/create') + + // 1. Select category via SearchSelect + await selectSearchOption(page, 'Rechercher une catégorie...', TEST_CATEGORY_NAME) + + // Wait for form to enable after category selection + const nameInput = page.getByPlaceholder('Nom affiché dans le catalogue') + await expect(nameInput).toBeEnabled({ timeout: 5_000 }) + + // 2. Fill form fields + await nameInput.clear() + await nameInput.fill(PRODUCT_NAME) + + const referenceInput = page.getByPlaceholder('Référence interne ou fournisseur') + await referenceInput.fill(PRODUCT_REFERENCE) + + const priceInput = page.getByPlaceholder('Valeur indicatrice') + await priceInput.fill(PRODUCT_PRICE) + + // 3. Submit + await page.getByRole('button', { name: /créer le produit/i }).click() + + // Should redirect to catalog and show success + await expect(page).toHaveURL('/product-catalog', { timeout: 15_000 }) + await expect(page.getByText('Produit créé avec succès')).toBeVisible() + }) + + // ────────────────────────────────────────────── + // READ + // ────────────────────────────────────────────── + + test('should display the created product in the catalog', async ({ page }) => { + await page.goto('/product-catalog') + await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 }) + }) + + test('should show product reference in the catalog', async ({ page }) => { + await page.goto('/product-catalog') + await expect(page.getByText(PRODUCT_REFERENCE)).toBeVisible({ timeout: 10_000 }) + }) + + test('should find the product via search', async ({ page }) => { + await page.goto('/product-catalog') + + const searchInput = page.getByPlaceholder('Nom ou référence…') + await searchInput.fill(PRODUCT_REFERENCE) + // Wait for client-side filtering + await page.waitForTimeout(300) + + await expect(page.getByText(PRODUCT_NAME)).toBeVisible() + await expect(page.getByText(PRODUCT_REFERENCE)).toBeVisible() + }) + + test('should show category link in the catalog', async ({ page }) => { + await page.goto('/product-catalog') + await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 }) + + const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME }) + await expect(row.getByText(TEST_CATEGORY_NAME)).toBeVisible() + }) + + test('should sort products by name', async ({ page }) => { + await page.goto('/product-catalog') + await page.locator('#product-sort').selectOption('name') + await page.locator('#product-dir').selectOption('asc') + await expect(page.getByRole('heading', { name: 'Catalogue des produits' })).toBeVisible() + }) + + test('should sort products by creation date', async ({ page }) => { + await page.goto('/product-catalog') + await page.locator('#product-sort').selectOption('createdAt') + await page.locator('#product-dir').selectOption('desc') + await expect(page.getByRole('heading', { name: 'Catalogue des produits' })).toBeVisible() + }) + + // ────────────────────────────────────────────── + // UPDATE + // ────────────────────────────────────────────── + + test('should navigate to edit page from catalog', async ({ page }) => { + await page.goto('/product-catalog') + await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 }) + + const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME }) + await row.getByRole('link', { name: 'Modifier' }).click() + + await expect(page.getByRole('heading', { name: 'Modifier le produit' })).toBeVisible({ timeout: 10_000 }) + }) + + test('should show category note on edit page', async ({ page }) => { + await page.goto('/product-catalog') + await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 }) + + const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME }) + await row.getByRole('link', { name: 'Modifier' }).click() + await expect(page.getByRole('heading', { name: 'Modifier le produit' })).toBeVisible({ timeout: 10_000 }) + + // Category should be displayed but disabled + await expect(page.getByText("La catégorie d'origine ne peut pas être modifiée")).toBeVisible() + }) + + test('should update the product', async ({ page }) => { + await page.goto('/product-catalog') + await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 }) + + const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME }) + await row.getByRole('link', { name: 'Modifier' }).click() + await expect(page.getByRole('heading', { name: 'Modifier le produit' })).toBeVisible({ timeout: 10_000 }) + + // Update name (label-text "Nom du produit" → sibling input) + const nameInput = page.locator('.form-control').filter({ hasText: 'Nom du produit' }).locator('input') + await nameInput.clear() + await nameInput.fill(PRODUCT_NAME_UPDATED) + + // Update reference + const refInput = page.locator('.form-control').filter({ hasText: 'Référence' }).locator('input') + await refInput.clear() + await refInput.fill(PRODUCT_REFERENCE_UPDATED) + + // Update price + const priceInput = page.locator('.form-control').filter({ hasText: 'Prix fournisseur' }).locator('input') + await priceInput.clear() + await priceInput.fill(PRODUCT_PRICE_UPDATED) + + // Submit + await page.getByRole('button', { name: /enregistrer les modifications/i }).click() + + await expect(page).toHaveURL('/product-catalog', { timeout: 10_000 }) + await expect(page.getByText('Produit mis à jour avec succès')).toBeVisible() + }) + + test('should display updated product in catalog', async ({ page }) => { + await page.goto('/product-catalog') + await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible({ timeout: 10_000 }) + await expect(page.getByText(PRODUCT_REFERENCE_UPDATED)).toBeVisible() + }) + + // ────────────────────────────────────────────── + // DELETE + // ────────────────────────────────────────────── + + test('should cancel product deletion', async ({ page }) => { + await page.goto('/product-catalog') + await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible({ timeout: 10_000 }) + + const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME_UPDATED }) + await row.getByRole('button', { name: 'Supprimer' }).click() + + // Confirmation modal + await expect(page.getByText(/voulez-vous vraiment supprimer/i)).toBeVisible() + await page.getByRole('button', { name: 'Annuler' }).click() + + // Product should still be here + await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible() + }) + + test('should delete the product', async ({ page }) => { + await page.goto('/product-catalog') + await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible({ timeout: 10_000 }) + + const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME_UPDATED }) + await row.getByRole('button', { name: 'Supprimer' }).click() + + // Confirm deletion in modal + await expect(page.getByText(/voulez-vous vraiment supprimer/i)).toBeVisible() + await page.locator('button.btn-error').filter({ hasText: 'Supprimer' }).click() + + await expect(page.getByText(/supprimé/i)).toBeVisible({ timeout: 10_000 }) + // The toast message contains the product name, so check the table specifically + const table = page.locator('table') + await expect(table.getByText(PRODUCT_NAME_UPDATED)).not.toBeVisible({ timeout: 5_000 }) + }) + + // ────────────────────────────────────────────── + // CLEANUP: Remove the test category + // ────────────────────────────────────────────── + + test('cleanup: delete the test product category', async ({ page }) => { + await cleanupTestCategory(page) + }) +}) diff --git a/package-lock.json b/package-lock.json index 1eb43ed..affc6f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "devDependencies": { "@iconify-json/lucide": "^1.2.68", "@nuxt/eslint-config": "^1.9.0", + "@playwright/test": "^1.58.2", "@rushstack/eslint-patch": "^1.12.0", "@types/node": "^25.2.1", "@typescript-eslint/eslint-plugin": "^8.44.1", @@ -3202,6 +3203,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -10828,6 +10845,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/package.json b/package.json index 2306e6f..5cf1eb0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "lint": "eslint . --ext .js,.ts,.vue", "lint:fix": "npm run lint -- --fix", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed" }, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", @@ -26,6 +29,7 @@ "devDependencies": { "@iconify-json/lucide": "^1.2.68", "@nuxt/eslint-config": "^1.9.0", + "@playwright/test": "^1.58.2", "@rushstack/eslint-patch": "^1.12.0", "@types/node": "^25.2.1", "@typescript-eslint/eslint-plugin": "^8.44.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..8940c7c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test' + +const AUTH_FILE = 'e2e/.auth/session.json' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'html', + timeout: 30_000, + + use: { + baseURL: process.env.E2E_BASE_URL || 'http://localhost:3001', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + // Auth setup: selects a profile to get a session cookie + { + name: 'auth-setup', + testMatch: /auth\.setup\.ts/, + }, + + // All tests run after auth setup, with the saved session + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: AUTH_FILE, + }, + dependencies: ['auth-setup'], + }, + ], +})