diff --git a/.gitea/workflows/e2e.yml b/.gitea/workflows/e2e.yml new file mode 100644 index 0000000..54d0f27 --- /dev/null +++ b/.gitea/workflows/e2e.yml @@ -0,0 +1,77 @@ +name: E2E Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: web/package-lock.json + + - name: Create .env for stack + run: | + cat > .env <<'EOF' + POSTGRES_PASSWORD=ci-postgres-pass + BRAIN_INTERNAL_SECRET=ci-brain-secret + ADMIN_USERNAME=admin + ADMIN_PASSWORD=ci-admin-pass + WEB_PORT=8081 + LLM_PROVIDER=ollama + EOF + + - name: Build & start stack + run: | + docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build + + - name: Wait for web to be ready + run: | + timeout 180 bash -c 'until curl -sf http://localhost:8081 > /dev/null; do echo "waiting..."; sleep 3; done' + + - name: Install web deps + working-directory: web + run: npm ci + + - name: Install Playwright browsers + working-directory: web + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + working-directory: web + env: + E2E_BASE_URL: http://localhost:8081 + CI: 'true' + run: npm run e2e + + - name: Dump container logs on failure + if: failure() + run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: web/playwright-report/ + retention-days: 14 + + - name: Stop stack + if: always() + run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v diff --git a/.gitignore b/.gitignore index d170b91..702be85 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,12 @@ yarn-error.log* .pnpm-debug.log* coverage/ +# Playwright (E2E) +web/test-results/ +web/playwright-report/ +web/blob-report/ +web/playwright/.cache/ + # ============================================================================ # IDE / Editeurs # ============================================================================ diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..0d54bfe --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,17 @@ +# Override pour la CI E2E : build les images depuis les sources au lieu de les puller. +# Usage : docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build +services: + core: + build: + context: ./core + image: loremind-core:e2e + + brain: + build: + context: ./brain + image: loremind-brain:e2e + + web: + build: + context: ./web + image: loremind-web:e2e diff --git a/web/e2e/fixtures/api.ts b/web/e2e/fixtures/api.ts new file mode 100644 index 0000000..31975e2 --- /dev/null +++ b/web/e2e/fixtures/api.ts @@ -0,0 +1,281 @@ +import { APIRequestContext, expect } from '@playwright/test'; + +export interface SeededLore { + id: string; + name: string; + rootFolderId: string; + rootFolderName: string; +} + +/** + * Seed un Lore + un dossier racine via l'API backend. + * Les noms sont uniques (timestamp + random) pour éviter les collisions en parallèle. + */ +export async function seedLoreWithFolder(request: APIRequestContext): Promise { + const suffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const loreName = `E2E Lore ${suffix}`; + const folderName = `E2E Folder ${suffix}`; + + const loreRes = await request.post('/api/lores', { + data: { name: loreName, description: 'Seeded by Playwright' }, + }); + expect(loreRes.ok(), `POST /api/lores -> ${loreRes.status()}`).toBeTruthy(); + const lore = await loreRes.json(); + + const folderRes = await request.post('/api/lore-nodes', { + data: { loreId: lore.id, name: folderName, icon: 'folder', description: '' }, + }); + expect(folderRes.ok(), `POST /api/lore-nodes -> ${folderRes.status()}`).toBeTruthy(); + const folder = await folderRes.json(); + + return { id: lore.id, name: loreName, rootFolderId: folder.id, rootFolderName: folderName }; +} + +/** Cleanup best-effort — n'échoue pas si déjà supprimé. */ +export async function deleteLore(request: APIRequestContext, loreId: string): Promise { + await request.delete(`/api/lores/${loreId}`).catch(() => undefined); +} + +export async function getTemplatesForLore( + request: APIRequestContext, + loreId: string, +): Promise> { + const res = await request.get(`/api/templates?loreId=${loreId}`); + expect(res.ok(), `GET /api/templates -> ${res.status()}`).toBeTruthy(); + return res.json(); +} + +export interface SeededTemplate { + id: string; + name: string; +} + +export async function seedTemplate( + request: APIRequestContext, + opts: { loreId: string; defaultNodeId: string; name?: string; fieldNames?: string[] }, +): Promise { + const templateName = opts.name ?? `E2E Template ${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const fields = (opts.fieldNames ?? ['Nom', 'Description']).map((name) => ({ name, type: 'TEXT' })); + + const res = await request.post('/api/templates', { + data: { + loreId: opts.loreId, + name: templateName, + description: 'Seeded by Playwright', + defaultNodeId: opts.defaultNodeId, + fields, + }, + }); + expect(res.ok(), `POST /api/templates -> ${res.status()}`).toBeTruthy(); + const tpl = await res.json(); + return { id: tpl.id, name: templateName }; +} + +export async function deleteCampaign(request: APIRequestContext, campaignId: string): Promise { + await request.delete(`/api/campaigns/${campaignId}`).catch(() => undefined); +} + +export interface SeededCampaign { + id: string; + name: string; +} + +export async function seedCampaign( + request: APIRequestContext, + opts: { name?: string; loreId?: string | null; playerCount?: number } = {}, +): Promise { + const name = opts.name ?? `E2E Campaign ${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const res = await request.post('/api/campaigns', { + data: { + name, + description: 'Seeded by Playwright', + playerCount: opts.playerCount ?? 4, + loreId: opts.loreId ?? null, + gameSystemId: null, + }, + }); + expect(res.ok(), `POST /api/campaigns -> ${res.status()}`).toBeTruthy(); + const c = await res.json(); + return { id: c.id, name }; +} + +export interface SeededArc { + id: string; + name: string; +} + +export async function seedArc( + request: APIRequestContext, + opts: { campaignId: string; name?: string; order?: number }, +): Promise { + const name = opts.name ?? `E2E Arc ${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const res = await request.post('/api/arcs', { + data: { + campaignId: opts.campaignId, + name, + description: '', + order: opts.order ?? 1, + }, + }); + expect(res.ok(), `POST /api/arcs -> ${res.status()}`).toBeTruthy(); + const a = await res.json(); + return { id: a.id, name }; +} + +export interface SeededChapter { + id: string; + name: string; +} + +export async function seedChapter( + request: APIRequestContext, + opts: { arcId: string; name?: string; order?: number }, +): Promise { + const name = opts.name ?? `E2E Chapter ${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const res = await request.post('/api/chapters', { + data: { arcId: opts.arcId, name, description: '', order: opts.order ?? 1 }, + }); + expect(res.ok(), `POST /api/chapters -> ${res.status()}`).toBeTruthy(); + const c = await res.json(); + return { id: c.id, name }; +} + +export async function getChapterById( + request: APIRequestContext, + chapterId: string, +): Promise<{ + id: string; + name: string; + description?: string; + gmNotes?: string | null; + playerObjectives?: string | null; + narrativeStakes?: string | null; +}> { + const res = await request.get(`/api/chapters/${chapterId}`); + expect(res.ok(), `GET /api/chapters/${chapterId} -> ${res.status()}`).toBeTruthy(); + return res.json(); +} + +export interface SeededScene { + id: string; + name: string; +} + +export async function seedScene( + request: APIRequestContext, + opts: { chapterId: string; name?: string; order?: number }, +): Promise { + const name = opts.name ?? `E2E Scene ${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const res = await request.post('/api/scenes', { + data: { chapterId: opts.chapterId, name, description: '', order: opts.order ?? 1 }, + }); + expect(res.ok(), `POST /api/scenes -> ${res.status()}`).toBeTruthy(); + const s = await res.json(); + return { id: s.id, name }; +} + +export async function getSceneById( + request: APIRequestContext, + sceneId: string, +): Promise<{ + id: string; + name: string; + description?: string; + location?: string | null; + timing?: string | null; + atmosphere?: string | null; + playerNarration?: string | null; + gmSecretNotes?: string | null; + choicesConsequences?: string | null; + combatDifficulty?: string | null; + enemies?: string | null; + branches?: Array<{ label: string; targetSceneId: string; condition?: string }>; +}> { + const res = await request.get(`/api/scenes/${sceneId}`); + expect(res.ok(), `GET /api/scenes/${sceneId} -> ${res.status()}`).toBeTruthy(); + return res.json(); +} + +export async function getArcById( + request: APIRequestContext, + arcId: string, +): Promise<{ + id: string; + name: string; + description?: string; + themes?: string | null; + stakes?: string | null; + gmNotes?: string | null; + rewards?: string | null; + resolution?: string | null; +}> { + const res = await request.get(`/api/arcs/${arcId}`); + expect(res.ok(), `GET /api/arcs/${arcId} -> ${res.status()}`).toBeTruthy(); + return res.json(); +} + +export async function getCampaigns( + request: APIRequestContext, +): Promise> { + const res = await request.get('/api/campaigns'); + expect(res.ok(), `GET /api/campaigns -> ${res.status()}`).toBeTruthy(); + return res.json(); +} + +export async function getPagesForLore( + request: APIRequestContext, + loreId: string, +): Promise> { + const res = await request.get(`/api/pages?loreId=${loreId}`); + expect(res.ok(), `GET /api/pages -> ${res.status()}`).toBeTruthy(); + return res.json(); +} + +export interface SeededPage { + id: string; + title: string; +} + +export async function seedPage( + request: APIRequestContext, + opts: { loreId: string; nodeId: string; templateId: string; title?: string }, +): Promise { + const title = opts.title ?? `E2E Page ${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const res = await request.post('/api/pages', { + data: { loreId: opts.loreId, nodeId: opts.nodeId, templateId: opts.templateId, title }, + }); + expect(res.ok(), `POST /api/pages -> ${res.status()}`).toBeTruthy(); + const page = await res.json(); + return { id: page.id, title }; +} + +export async function getPageById( + request: APIRequestContext, + pageId: string, +): Promise<{ + id: string; + title: string; + nodeId: string; + values?: Record; + tags?: string[]; + notes?: string; +}> { + const res = await request.get(`/api/pages/${pageId}`); + expect(res.ok(), `GET /api/pages/${pageId} -> ${res.status()}`).toBeTruthy(); + return res.json(); +} + +export async function getTemplateById( + request: APIRequestContext, + templateId: string, +): Promise<{ + id: string; + name: string; + description?: string; + defaultNodeId?: string | null; + fields: Array<{ name: string; type: string }>; +}> { + const res = await request.get(`/api/templates/${templateId}`); + expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy(); + return res.json(); +} diff --git a/web/e2e/tests/campaign/arc-edit.spec.ts b/web/e2e/tests/campaign/arc-edit.spec.ts new file mode 100644 index 0000000..648c34f --- /dev/null +++ b/web/e2e/tests/campaign/arc-edit.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from '@playwright/test'; +import { + seedCampaign, + seedArc, + deleteCampaign, + getArcById, + type SeededCampaign, + type SeededArc, +} from '../../fixtures/api'; + +test.describe('Arc edit', () => { + let campaign: SeededCampaign; + let arc: SeededArc; + + test.beforeEach(async ({ request }) => { + campaign = await seedCampaign(request); + arc = await seedArc(request, { campaignId: campaign.id }); + }); + + test.afterEach(async ({ request }) => { + if (campaign?.id) await deleteCampaign(request, campaign.id); + }); + + test('form is prefilled with the arc name', async ({ page }) => { + await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`); + await expect(page.getByLabel(/Titre de l'arc/i)).toHaveValue(arc.name); + }); + + test('edits all narrative fields and persists them to API', async ({ page, request }) => { + const newName = `${arc.name} renamed`; + const values = { + description: "Un arc sombre où la trahison s'installe.", + themes: 'Trahison, rédemption, dette de sang.', + stakes: 'La survie du royaume est en jeu.', + gmNotes: 'Révéler le traître en scène 3.', + rewards: 'Relique ancienne + alliance avec le clan nordique.', + resolution: 'Le héros pardonne au traître ou le tue.', + }; + + await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`); + + await page.getByLabel(/Titre de l'arc/i).fill(newName); + await page.getByLabel(/Synopsis de l'arc/i).fill(values.description); + await page.getByLabel(/Thèmes principaux/i).fill(values.themes); + await page.getByLabel(/Enjeux globaux/i).fill(values.stakes); + await page.getByLabel(/Notes et planification du MJ/i).fill(values.gmNotes); + await page.getByLabel(/Récompenses et progression/i).fill(values.rewards); + await page.getByLabel(/Dénouement prévu/i).fill(values.resolution); + + await page.getByRole('button', { name: /^Sauvegarder$/i }).click(); + + await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}$`)); + + const persisted = await getArcById(request, arc.id); + expect(persisted.name).toBe(newName); + expect(persisted.description).toBe(values.description); + expect(persisted.themes).toBe(values.themes); + expect(persisted.stakes).toBe(values.stakes); + expect(persisted.gmNotes).toBe(values.gmNotes); + expect(persisted.rewards).toBe(values.rewards); + expect(persisted.resolution).toBe(values.resolution); + }); + + test('save button is disabled when name is empty', async ({ page }) => { + await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`); + + const nameField = page.getByLabel(/Titre de l'arc/i); + const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i }); + + await expect(saveBtn).toBeEnabled(); + await nameField.fill(''); + await expect(saveBtn).toBeDisabled(); + await nameField.fill('Valid'); + await expect(saveBtn).toBeEnabled(); + }); +}); diff --git a/web/e2e/tests/campaign/campaign-create.spec.ts b/web/e2e/tests/campaign/campaign-create.spec.ts new file mode 100644 index 0000000..6539f6a --- /dev/null +++ b/web/e2e/tests/campaign/campaign-create.spec.ts @@ -0,0 +1,96 @@ +import { test, expect } from '@playwright/test'; +import { + seedLoreWithFolder, + deleteLore, + deleteCampaign, + getCampaigns, + type SeededLore, +} from '../../fixtures/api'; + +test.describe('Campaign creation', () => { + const createdCampaignIds: string[] = []; + let linkedLore: SeededLore; + + test.beforeEach(async ({ request }) => { + linkedLore = await seedLoreWithFolder(request); + }); + + test.afterEach(async ({ request }) => { + while (createdCampaignIds.length) { + await deleteCampaign(request, createdCampaignIds.pop()!); + } + if (linkedLore?.id) await deleteLore(request, linkedLore.id); + }); + + test('creates a standalone campaign (no lore, no system) and shows it in the grid', async ({ + page, + request, + }) => { + const campaignName = `Campagne E2E ${Date.now()}`; + const description = 'Une campagne créée par les tests automatisés.'; + + await page.goto('/campaigns'); + await expect(page.getByRole('heading', { name: /Vos Campagnes|Campagnes/i })).toBeVisible(); + + await page.locator('.campaign-card.card-new').click(); + + const modal = page.locator('.modal'); + await expect(modal).toBeVisible(); + + await modal.getByLabel(/Nom de la campagne/i).fill(campaignName); + await modal.getByLabel(/Description/i).fill(description); + await modal.getByLabel(/Nombre de joueurs/i).fill('5'); + + await modal.getByRole('button', { name: /^Créer la campagne$/i }).click(); + + await expect(modal).not.toBeVisible(); + + const newCard = page.locator('.campaign-card', { hasText: campaignName }); + await expect(newCard).toBeVisible(); + + const campaigns = await getCampaigns(request); + const created = campaigns.find((c) => c.name === campaignName); + expect(created).toBeDefined(); + expect(created!.loreId).toBeNull(); + createdCampaignIds.push(created!.id); + }); + + test('creates a campaign linked to an existing lore', async ({ page, request }) => { + const campaignName = `Campagne liée ${Date.now()}`; + + await page.goto('/campaigns'); + await page.locator('.campaign-card.card-new').click(); + + const modal = page.locator('.modal'); + await modal.getByLabel(/Nom de la campagne/i).fill(campaignName); + await modal.getByLabel(/Univers associé/i).selectOption({ label: linkedLore.name }); + + await modal.getByRole('button', { name: /^Créer la campagne$/i }).click(); + await expect(modal).not.toBeVisible(); + + const campaigns = await getCampaigns(request); + const created = campaigns.find((c) => c.name === campaignName); + expect(created).toBeDefined(); + expect(created!.loreId).toBe(linkedLore.id); + createdCampaignIds.push(created!.id); + }); + + test('submit is disabled without a name and when player count is invalid', async ({ page }) => { + await page.goto('/campaigns'); + await page.locator('.campaign-card.card-new').click(); + + const modal = page.locator('.modal'); + const submit = modal.getByRole('button', { name: /^Créer la campagne$/i }); + + await expect(submit).toBeDisabled(); + + await modal.getByLabel(/Nom de la campagne/i).fill('Valid name'); + await expect(submit).toBeEnabled(); + + await modal.getByLabel(/Nombre de joueurs/i).fill('0'); + await expect(submit).toBeDisabled(); + + await modal.getByLabel(/Nombre de joueurs/i).fill('3'); + await expect(submit).toBeEnabled(); + }); +}); diff --git a/web/e2e/tests/campaign/chapter-edit.spec.ts b/web/e2e/tests/campaign/chapter-edit.spec.ts new file mode 100644 index 0000000..882c1ec --- /dev/null +++ b/web/e2e/tests/campaign/chapter-edit.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '@playwright/test'; +import { + seedCampaign, + seedArc, + seedChapter, + deleteCampaign, + getChapterById, + type SeededCampaign, + type SeededArc, + type SeededChapter, +} from '../../fixtures/api'; + +test.describe('Chapter edit', () => { + let campaign: SeededCampaign; + let arc: SeededArc; + let chapter: SeededChapter; + + test.beforeEach(async ({ request }) => { + campaign = await seedCampaign(request); + arc = await seedArc(request, { campaignId: campaign.id }); + chapter = await seedChapter(request, { arcId: arc.id }); + }); + + test.afterEach(async ({ request }) => { + if (campaign?.id) await deleteCampaign(request, campaign.id); + }); + + test('edits all chapter fields and persists them to API', async ({ page, request }) => { + const newName = `${chapter.name} renamed`; + const values = { + description: 'Le chapitre ouvre sur un village en proie à la peur.', + gmNotes: 'Le maire cache un pacte avec les gobelins.', + playerObjectives: "Découvrir la source des disparitions.", + narrativeStakes: "La confiance du village est en jeu.", + }; + + await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/edit`); + + await expect(page.getByLabel(/Titre du chapitre/i)).toHaveValue(chapter.name); + + await page.getByLabel(/Titre du chapitre/i).fill(newName); + await page.getByLabel(/Synopsis du chapitre/i).fill(values.description); + await page.getByLabel(/Notes du Maître de Jeu/i).fill(values.gmNotes); + await page.getByLabel(/Objectifs des joueurs/i).fill(values.playerObjectives); + await page.getByLabel(/Enjeux narratifs/i).fill(values.narrativeStakes); + + await page.getByRole('button', { name: /^Sauvegarder$/i }).click(); + + await expect(page).toHaveURL( + new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}$`), + ); + + const persisted = await getChapterById(request, chapter.id); + expect(persisted.name).toBe(newName); + expect(persisted.description).toBe(values.description); + expect(persisted.gmNotes).toBe(values.gmNotes); + expect(persisted.playerObjectives).toBe(values.playerObjectives); + expect(persisted.narrativeStakes).toBe(values.narrativeStakes); + }); + + test('save button is disabled when name is empty', async ({ page }) => { + await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/edit`); + const nameField = page.getByLabel(/Titre du chapitre/i); + const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i }); + + await expect(saveBtn).toBeEnabled(); + await nameField.fill(''); + await expect(saveBtn).toBeDisabled(); + await nameField.fill('OK'); + await expect(saveBtn).toBeEnabled(); + }); +}); diff --git a/web/e2e/tests/campaign/scene-edit.spec.ts b/web/e2e/tests/campaign/scene-edit.spec.ts new file mode 100644 index 0000000..6a5f92f --- /dev/null +++ b/web/e2e/tests/campaign/scene-edit.spec.ts @@ -0,0 +1,147 @@ +import { test, expect } from '@playwright/test'; +import { + seedCampaign, + seedArc, + seedChapter, + seedScene, + deleteCampaign, + getSceneById, + type SeededCampaign, + type SeededArc, + type SeededChapter, + type SeededScene, +} from '../../fixtures/api'; + +test.describe('Scene edit', () => { + let campaign: SeededCampaign; + let arc: SeededArc; + let chapter: SeededChapter; + let scene: SeededScene; + + test.beforeEach(async ({ request }) => { + campaign = await seedCampaign(request); + arc = await seedArc(request, { campaignId: campaign.id }); + chapter = await seedChapter(request, { arcId: arc.id }); + scene = await seedScene(request, { chapterId: chapter.id }); + }); + + test.afterEach(async ({ request }) => { + if (campaign?.id) await deleteCampaign(request, campaign.id); + }); + + test('edits all text fields across sections and persists them to API', async ({ page, request }) => { + const newName = `${scene.name} renamed`; + const values = { + description: "Les PJ arrivent au village à la nuit tombée.", + location: "Taverne du Dragon d'Or", + timing: 'Soir, pleine lune', + atmosphere: 'Silence pesant, regards fuyants des villageois.', + playerNarration: 'Vous poussez la porte de la taverne…', + gmSecretNotes: 'Le tavernier est complice des bandits.', + choicesConsequences: 'Accepter = piégés à l\'étage. Refuser = filature.', + combatDifficulty: 'Moyenne, 4 bandits niveau 3', + enemies: 'Bandit chef (feuille jointe) + 3 sbires.', + }; + + await page.goto( + `/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`, + ); + + await expect(page.getByLabel(/Titre de la scène/i)).toHaveValue(scene.name); + + await page.getByLabel(/Titre de la scène/i).fill(newName); + await page.getByLabel(/Description courte/i).fill(values.description); + await page.getByLabel(/^Lieu$/i).fill(values.location); + await page.getByLabel(/^Moment$/i).fill(values.timing); + await page.getByLabel(/Ambiance et atmosphère/i).fill(values.atmosphere); + + // Les sections suivantes sont fermées par défaut : on les ouvre avant de taper. + // Un clic sur le header de la section toggle son état. + await page.locator('app-expandable-section', { hasText: 'Narration pour les joueurs' }).click(); + await page.getByPlaceholder(/Le texte que vous lirez aux joueurs/i).fill(values.playerNarration); + + await page.locator('app-expandable-section', { hasText: 'Notes et secrets du MJ' }).click(); + await page + .getByPlaceholder(/Informations cachées, indices, éléments secrets/i) + .fill(values.gmSecretNotes); + + await page.locator('app-expandable-section', { hasText: 'Choix et conséquences' }).click(); + await page + .getByPlaceholder(/Décrivez les différentes options/i) + .fill(values.choicesConsequences); + + await page.locator('app-expandable-section', { hasText: 'Combat ou rencontre' }).click(); + await page.getByLabel(/Difficulté estimée/i).fill(values.combatDifficulty); + await page.getByLabel(/Ennemis et créatures/i).fill(values.enemies); + + await page.getByRole('button', { name: /^Sauvegarder$/i }).click(); + + await expect(page).toHaveURL( + new RegExp( + `/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}$`, + ), + ); + + const persisted = await getSceneById(request, scene.id); + expect(persisted.name).toBe(newName); + expect(persisted.description).toBe(values.description); + expect(persisted.location).toBe(values.location); + expect(persisted.timing).toBe(values.timing); + expect(persisted.atmosphere).toBe(values.atmosphere); + expect(persisted.playerNarration).toBe(values.playerNarration); + expect(persisted.gmSecretNotes).toBe(values.gmSecretNotes); + expect(persisted.choicesConsequences).toBe(values.choicesConsequences); + expect(persisted.combatDifficulty).toBe(values.combatDifficulty); + expect(persisted.enemies).toBe(values.enemies); + }); + + test('adds a narrative branch to a sibling scene and persists it', async ({ page, request }) => { + const sibling = await seedScene(request, { + chapterId: chapter.id, + name: 'Scène alternative', + order: 2, + }); + + await page.goto( + `/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`, + ); + + const branchesSection = page.locator('app-expandable-section', { hasText: 'Branches narratives' }); + await branchesSection.click(); + + await branchesSection.getByRole('button', { name: /Ajouter une branche/i }).click(); + + const branchItem = branchesSection.locator('.branch-item').first(); + await branchItem.getByPlaceholder(/Ex: Si les joueurs attaquent/i).fill('Si les PJ fuient'); + await branchItem.locator('select').selectOption({ label: sibling.name }); + await branchItem.getByPlaceholder(/Jet de Persuasion/i).fill('Sur échec initiative'); + + await page.getByRole('button', { name: /^Sauvegarder$/i }).click(); + + await expect(page).toHaveURL( + new RegExp( + `/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}$`, + ), + ); + + const persisted = await getSceneById(request, scene.id); + expect(persisted.branches).toHaveLength(1); + expect(persisted.branches![0].label).toBe('Si les PJ fuient'); + expect(persisted.branches![0].targetSceneId).toBe(sibling.id); + expect(persisted.branches![0].condition).toBe('Sur échec initiative'); + }); + + test('save button is disabled when name is empty', async ({ page }) => { + await page.goto( + `/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`, + ); + const nameField = page.getByLabel(/Titre de la scène/i); + const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i }); + + await expect(saveBtn).toBeEnabled(); + await nameField.fill(''); + await expect(saveBtn).toBeDisabled(); + await nameField.fill('OK'); + await expect(saveBtn).toBeEnabled(); + }); +}); diff --git a/web/e2e/tests/lore/lore-create.spec.ts b/web/e2e/tests/lore/lore-create.spec.ts new file mode 100644 index 0000000..9126ca8 --- /dev/null +++ b/web/e2e/tests/lore/lore-create.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; +import { deleteLore } from '../../fixtures/api'; + +test.describe('Lore creation', () => { + const createdLoreIds: string[] = []; + + test.afterEach(async ({ request }) => { + while (createdLoreIds.length) { + const id = createdLoreIds.pop()!; + await deleteLore(request, id); + } + }); + + test('opens the modal, creates a lore, and shows it in the grid', async ({ page, request }) => { + const loreName = `Univers E2E ${Date.now()}`; + const description = "Un univers créé par les tests automatisés."; + + await page.goto('/lore'); + + await expect(page.getByRole('heading', { name: /Vos Univers/i })).toBeVisible(); + + await page.locator('.lore-card.card-new').click(); + + const modal = page.locator('.modal'); + await expect(modal).toBeVisible(); + await expect(modal.getByRole('heading', { name: /Créer un nouveau Lore/i })).toBeVisible(); + + await modal.getByLabel(/Nom de l'univers/i).fill(loreName); + await modal.getByLabel('Description').fill(description); + + await modal.getByRole('button', { name: /^Créer le lore$/i }).click(); + + await expect(modal).not.toBeVisible(); + + const newCard = page.locator('.lore-card', { hasText: loreName }); + await expect(newCard).toBeVisible(); + await expect(newCard).toContainText(description); + + const allLores = await request.get('/api/lores').then((r) => r.json()); + const created = allLores.find((l: { name: string; id: string }) => l.name === loreName); + expect(created).toBeDefined(); + createdLoreIds.push(created.id); + }); + + test('submit button is disabled when name is empty', async ({ page }) => { + await page.goto('/lore'); + await page.locator('.lore-card.card-new').click(); + + const modal = page.locator('.modal'); + const submit = modal.getByRole('button', { name: /^Créer le lore$/i }); + + await expect(submit).toBeDisabled(); + + await modal.getByLabel(/Nom de l'univers/i).fill('Quelque chose'); + await expect(submit).toBeEnabled(); + + await modal.getByLabel(/Nom de l'univers/i).fill(''); + await expect(submit).toBeDisabled(); + }); + + test('clicking the backdrop closes the modal without creating', async ({ page }) => { + const typedButAbandoned = `Univers abandonné ${Date.now()}`; + + await page.goto('/lore'); + await page.locator('.lore-card.card-new').click(); + + const modal = page.locator('.modal'); + await expect(modal).toBeVisible(); + + await modal.getByLabel(/Nom de l'univers/i).fill(typedButAbandoned); + + await page.locator('.modal-backdrop').click({ position: { x: 5, y: 5 } }); + await expect(modal).not.toBeVisible(); + + await expect(page.locator('.lore-card', { hasText: typedButAbandoned })).toHaveCount(0); + }); +}); diff --git a/web/e2e/tests/lore/page-create.spec.ts b/web/e2e/tests/lore/page-create.spec.ts new file mode 100644 index 0000000..ffe87fa --- /dev/null +++ b/web/e2e/tests/lore/page-create.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from '@playwright/test'; +import { + seedLoreWithFolder, + seedTemplate, + deleteLore, + getPagesForLore, + type SeededLore, + type SeededTemplate, +} from '../../fixtures/api'; + +test.describe('Page creation', () => { + let seeded: SeededLore; + let template: SeededTemplate; + + test.beforeEach(async ({ request }) => { + seeded = await seedLoreWithFolder(request); + template = await seedTemplate(request, { + loreId: seeded.id, + defaultNodeId: seeded.rootFolderId, + fieldNames: ['Apparence', 'Motivation'], + }); + }); + + test.afterEach(async ({ request }) => { + if (seeded?.id) await deleteLore(request, seeded.id); + }); + + test('creates an empty page from a template and redirects to edit', async ({ page, request }) => { + const pageTitle = `Maître Eldrin ${Date.now()}`; + + await page.goto(`/lore/${seeded.id}/pages/create`); + + await expect(page.getByRole('heading', { name: /Créer une nouvelle Page/i })).toBeVisible(); + + await page.getByLabel(/Titre de la page/i).fill(pageTitle); + + await page.locator('.template-card', { hasText: template.name }).click(); + await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible(); + + await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId); + + await page.getByRole('button', { name: /^Créer la page$/i }).click(); + + await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/[^/]+/edit$`)); + + const pages = await getPagesForLore(request, seeded.id); + const created = pages.find((p) => p.title === pageTitle); + expect(created).toBeDefined(); + expect(created?.templateId).toBe(template.id); + expect(created?.nodeId).toBe(seeded.rootFolderId); + }); + + test('submit is disabled until title, template and folder are set', async ({ page }) => { + await page.goto(`/lore/${seeded.id}/pages/create`); + + const submit = page.getByRole('button', { name: /^Créer la page$/i }); + await expect(submit).toBeDisabled(); + + await page.getByLabel(/Titre de la page/i).fill('Un titre'); + await expect(submit).toBeDisabled(); + + await page.locator('.template-card', { hasText: template.name }).click(); + await expect(submit).toBeEnabled(); + }); + + test('entering on a folder-scoped route preselects that folder', async ({ page, request }) => { + const pageTitle = `Page scoped ${Date.now()}`; + + const secondFolderRes = await request.post('/api/lore-nodes', { + data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' }, + }); + expect(secondFolderRes.ok()).toBeTruthy(); + const secondFolderId = (await secondFolderRes.json()).id; + + await page.goto(`/lore/${seeded.id}/nodes/${secondFolderId}/pages/create`); + + const nodeSelect = page.locator('#page-node'); + await expect(nodeSelect).toHaveValue(secondFolderId); + await expect(nodeSelect).toBeDisabled(); + + await page.getByLabel(/Titre de la page/i).fill(pageTitle); + await page.locator('.template-card', { hasText: template.name }).click(); + await page.getByRole('button', { name: /^Créer la page$/i }).click(); + + await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/[^/]+/edit$`)); + + const pages = await getPagesForLore(request, seeded.id); + expect(pages.find((p) => p.title === pageTitle)?.nodeId).toBe(secondFolderId); + }); +}); diff --git a/web/e2e/tests/lore/page-edit.spec.ts b/web/e2e/tests/lore/page-edit.spec.ts new file mode 100644 index 0000000..32a29af --- /dev/null +++ b/web/e2e/tests/lore/page-edit.spec.ts @@ -0,0 +1,95 @@ +import { test, expect } from '@playwright/test'; +import { + seedLoreWithFolder, + seedTemplate, + seedPage, + deleteLore, + getPageById, + type SeededLore, + type SeededTemplate, + type SeededPage, +} from '../../fixtures/api'; + +test.describe('Page edit', () => { + let seeded: SeededLore; + let template: SeededTemplate; + let pageEntity: SeededPage; + + test.beforeEach(async ({ request }) => { + seeded = await seedLoreWithFolder(request); + template = await seedTemplate(request, { + loreId: seeded.id, + defaultNodeId: seeded.rootFolderId, + fieldNames: ['Apparence', 'Motivation'], + }); + pageEntity = await seedPage(request, { + loreId: seeded.id, + nodeId: seeded.rootFolderId, + templateId: template.id, + }); + }); + + test.afterEach(async ({ request }) => { + if (seeded?.id) await deleteLore(request, seeded.id); + }); + + test('edits title, dynamic fields, notes and persists to API', async ({ page, request }) => { + const newTitle = `Maître Eldrin ${Date.now()}`; + const apparence = 'Vieil homme au regard perçant.'; + const motivation = 'Protéger la cité à tout prix.'; + const notes = 'MJ : il connaît le secret du roi.'; + + await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`); + + await expect(page.locator('input[name="title"]')).toHaveValue(pageEntity.title); + + await page.locator('input[name="title"]').fill(newTitle); + await page.getByPlaceholder('Valeur pour Apparence...').fill(apparence); + await page.getByPlaceholder('Valeur pour Motivation...').fill(motivation); + await page.locator('textarea[name="notes"]').fill(notes); + + await page.getByRole('button', { name: /^Sauvegarder$/i }).click(); + + await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}$`)); + + const persisted = await getPageById(request, pageEntity.id); + expect(persisted.title).toBe(newTitle); + expect(persisted.values?.['Apparence']).toBe(apparence); + expect(persisted.values?.['Motivation']).toBe(motivation); + expect(persisted.notes).toBe(notes); + }); + + test('adds a tag via chips input and persists it', async ({ page, request }) => { + await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`); + + const chipsInput = page.locator('app-chips-input input'); + await chipsInput.fill('dangereux'); + await chipsInput.press('Enter'); + await chipsInput.fill('ancien'); + await chipsInput.press('Enter'); + + await expect(page.locator('app-chips-input').getByText('dangereux')).toBeVisible(); + await expect(page.locator('app-chips-input').getByText('ancien')).toBeVisible(); + + await page.getByRole('button', { name: /^Sauvegarder$/i }).click(); + await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}$`)); + + const persisted = await getPageById(request, pageEntity.id); + expect(persisted.tags).toEqual(expect.arrayContaining(['dangereux', 'ancien'])); + }); + + test('save button is disabled when title is empty', async ({ page }) => { + await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`); + + const titleInput = page.locator('input[name="title"]'); + const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i }); + + await expect(saveBtn).toBeEnabled(); + await titleInput.fill(''); + await expect(saveBtn).toBeDisabled(); + await titleInput.fill(' '); + await expect(saveBtn).toBeDisabled(); + await titleInput.fill('OK'); + await expect(saveBtn).toBeEnabled(); + }); +}); diff --git a/web/e2e/tests/lore/template-create.spec.ts b/web/e2e/tests/lore/template-create.spec.ts new file mode 100644 index 0000000..f8a35d3 --- /dev/null +++ b/web/e2e/tests/lore/template-create.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '@playwright/test'; +import { seedLoreWithFolder, deleteLore, getTemplatesForLore, type SeededLore } from '../../fixtures/api'; + +test.describe('Template creation', () => { + let seeded: SeededLore; + + test.beforeEach(async ({ request }) => { + seeded = await seedLoreWithFolder(request); + }); + + test.afterEach(async ({ request }) => { + if (seeded?.id) await deleteLore(request, seeded.id); + }); + + test('creates a template with default fields', async ({ page, request }) => { + const templateName = `Auberge ${Date.now()}`; + + await page.goto(`/lore/${seeded.id}/templates/create`); + + await expect(page.getByRole('heading', { name: /Créer un nouveau Template/i })).toBeVisible(); + + await page.getByLabel(/Nom du template/i).fill(templateName); + await page.getByLabel('Description').fill('Template créé via E2E'); + await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName }); + + await page.getByRole('button', { name: /^Créer le template$/i }).click(); + + await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`)); + + const templates = await getTemplatesForLore(request, seeded.id); + expect(templates.map((t) => t.name)).toContain(templateName); + }); + + test('submit is disabled when name is empty', async ({ page }) => { + await page.goto(`/lore/${seeded.id}/templates/create`); + + const submit = page.getByRole('button', { name: /^Créer le template$/i }); + await expect(submit).toBeDisabled(); + + await page.getByLabel(/Nom du template/i).fill('Valid name'); + await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName }); + await expect(submit).toBeEnabled(); + }); + + test('can add and remove a custom field before creating', async ({ page, request }) => { + const templateName = `Artefact ${Date.now()}`; + + await page.goto(`/lore/${seeded.id}/templates/create`); + + await page.getByLabel(/Nom du template/i).fill(templateName); + await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName }); + + const addFieldInput = page.getByPlaceholder('+ Ajouter un champ'); + await addFieldInput.fill('Pouvoir'); + await addFieldInput.press('Enter'); + await expect(page.locator('.fields-list .field-chip', { hasText: 'Pouvoir' })).toBeVisible(); + + await addFieldInput.fill('Origine'); + await addFieldInput.press('Enter'); + await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toBeVisible(); + + const origineRow = page.locator('.fields-list .field-row', { hasText: 'Origine' }); + await origineRow.getByRole('button', { name: 'Supprimer' }).click(); + await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toHaveCount(0); + + await page.getByRole('button', { name: /^Créer le template$/i }).click(); + await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`)); + + const templates = await getTemplatesForLore(request, seeded.id); + expect(templates.find((t) => t.name === templateName)).toBeDefined(); + }); +}); diff --git a/web/e2e/tests/lore/template-edit.spec.ts b/web/e2e/tests/lore/template-edit.spec.ts new file mode 100644 index 0000000..3620aff --- /dev/null +++ b/web/e2e/tests/lore/template-edit.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/test'; +import { + seedLoreWithFolder, + seedTemplate, + deleteLore, + getTemplateById, + type SeededLore, + type SeededTemplate, +} from '../../fixtures/api'; + +test.describe('Template edit', () => { + let seeded: SeededLore; + let template: SeededTemplate; + + test.beforeEach(async ({ request }) => { + seeded = await seedLoreWithFolder(request); + template = await seedTemplate(request, { + loreId: seeded.id, + defaultNodeId: seeded.rootFolderId, + fieldNames: ['Nom', 'Description'], + }); + }); + + test.afterEach(async ({ request }) => { + if (seeded?.id) await deleteLore(request, seeded.id); + }); + + test('form is prefilled with the current template data', async ({ page }) => { + await page.goto(`/lore/${seeded.id}/templates/${template.id}`); + + await expect(page.getByLabel(/^Nom$/)).toHaveValue(template.name); + await expect(page.getByLabel(/Dossier par défaut/i)).toHaveValue(seeded.rootFolderId); + await expect(page.locator('.fields-list .field-chip', { hasText: 'Nom' })).toBeVisible(); + await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toBeVisible(); + }); + + test('renames the template and persists to API', async ({ page, request }) => { + const newName = `${template.name} renamed`; + + await page.goto(`/lore/${seeded.id}/templates/${template.id}`); + + const nameInput = page.getByLabel(/^Nom$/); + await nameInput.fill(newName); + await page.getByLabel(/Description/i).fill('Description mise à jour'); + + await page.getByRole('button', { name: /^Sauvegarder$/i }).click(); + + await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`)); + + const persisted = await getTemplateById(request, template.id); + expect(persisted.name).toBe(newName); + expect(persisted.description).toBe('Description mise à jour'); + }); + + test('adds a new field, removes an existing one, and persists the order', async ({ page, request }) => { + await page.goto(`/lore/${seeded.id}/templates/${template.id}`); + + const addInput = page.getByPlaceholder('+ Ajouter un champ'); + await addInput.fill('Stats'); + await addInput.press('Enter'); + await expect(page.locator('.fields-list .field-chip', { hasText: 'Stats' })).toBeVisible(); + + const descriptionRow = page.locator('.fields-list .field-row', { hasText: 'Description' }); + await descriptionRow.getByRole('button', { name: 'Supprimer' }).click(); + await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toHaveCount(0); + + await page.getByRole('button', { name: /^Sauvegarder$/i }).click(); + await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`)); + + const persisted = await getTemplateById(request, template.id); + const names = persisted.fields.map((f) => f.name); + expect(names).toEqual(['Nom', 'Stats']); + }); +}); diff --git a/web/e2e/tests/smoke.spec.ts b/web/e2e/tests/smoke.spec.ts new file mode 100644 index 0000000..c6a954a --- /dev/null +++ b/web/e2e/tests/smoke.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Smoke', () => { + test('app loads without uncaught errors', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + + await page.goto('/'); + + await expect(page.locator('app-sidebar')).toBeVisible(); + await expect(page.locator('main.main-content')).toBeAttached(); + + expect(errors, `Page errors:\n${errors.join('\n')}`).toEqual([]); + }); +}); diff --git a/web/package-lock.json b/web/package-lock.json index 2e6ff6f..8ab487d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.4.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.4.0", + "version": "0.6.1", "dependencies": { "@angular/animations": "^17.0.0", "@angular/common": "^17.0.0", @@ -27,6 +27,7 @@ "@angular-devkit/build-angular": "^17.0.0", "@angular/cli": "^17.0.0", "@angular/compiler-cli": "^17.0.0", + "@playwright/test": "^1.59.1", "typescript": "~5.2.2" } }, @@ -3137,6 +3138,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -9063,6 +9080,53 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "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/postcss": { "version": "8.4.35", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", diff --git a/web/package.json b/web/package.json index e5572f6..4280ac8 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,11 @@ "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development", - "test": "ng test" + "test": "ng test", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", + "e2e:headed": "playwright test --headed", + "e2e:report": "playwright show-report" }, "private": true, "dependencies": { @@ -30,6 +34,7 @@ "@angular-devkit/build-angular": "^17.0.0", "@angular/cli": "^17.0.0", "@angular/compiler-cli": "^17.0.0", + "@playwright/test": "^1.59.1", "typescript": "~5.2.2" } } diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..5794d43 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:8081'; + +export default defineConfig({ + testDir: './e2e/tests', + fullyParallel: true, + forbidOnly: !!process.env['CI'], + retries: process.env['CI'] ? 2 : 0, + workers: process.env['CI'] ? 1 : undefined, + reporter: process.env['CI'] ? [['html', { open: 'never' }], ['list']] : 'html', + use: { + baseURL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/web/src/app/campaigns/arc-edit/arc-edit.component.html b/web/src/app/campaigns/arc-edit/arc-edit.component.html index 9428928..3d7bf68 100644 --- a/web/src/app/campaigns/arc-edit/arc-edit.component.html +++ b/web/src/app/campaigns/arc-edit/arc-edit.component.html @@ -43,8 +43,9 @@
- +
- +
- +
- + + +
- + - diff --git a/web/src/app/lore/template-edit/template-edit.component.html b/web/src/app/lore/template-edit/template-edit.component.html index 265afb1..75188b4 100644 --- a/web/src/app/lore/template-edit/template-edit.component.html +++ b/web/src/app/lore/template-edit/template-edit.component.html @@ -17,13 +17,13 @@
- - + +
- - @@ -31,8 +31,8 @@
- - + +