Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus
Some checks failed
E2E Tests / e2e (push) Failing after 5m30s

This commit is contained in:
2026-04-25 00:25:53 +02:00
parent 2764228abf
commit 6c6bd20f0d
26 changed files with 1365 additions and 54 deletions

281
web/e2e/fixtures/api.ts Normal file
View File

@@ -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<SeededLore> {
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<void> {
await request.delete(`/api/lores/${loreId}`).catch(() => undefined);
}
export async function getTemplatesForLore(
request: APIRequestContext,
loreId: string,
): Promise<Array<{ id: string; name: string }>> {
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<SeededTemplate> {
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<void> {
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<SeededCampaign> {
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<SeededArc> {
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<SeededChapter> {
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<SeededScene> {
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<Array<{ id: string; name: string; loreId: string | null }>> {
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<Array<{ id: string; title: string; nodeId: string; templateId: string }>> {
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<SeededPage> {
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<string, string>;
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();
}

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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']);
});
});

View File

@@ -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([]);
});
});