Compare commits
5 Commits
5cab15422d
...
6bed715b7f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bed715b7f | ||
|
|
dbf8c8856b | ||
|
|
62127a33f5 | ||
|
|
2fffe4a368 | ||
|
|
c9054e5b4d |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,3 +22,8 @@ logs
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Playwright
|
||||
e2e/.auth/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
172
app/components/model-types/ConversionModal.vue
Normal file
172
app/components/model-types/ConversionModal.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<dialog class="modal" :class="{ 'modal-open': open }">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold text-base-content">
|
||||
Convertir la catégorie
|
||||
</h3>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="checking" class="mt-4 flex items-center gap-2 text-sm text-info">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
Vérification de la conversion…
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="checkError" class="mt-4 text-sm text-error">
|
||||
{{ checkError }}
|
||||
</div>
|
||||
|
||||
<!-- Blocked state -->
|
||||
<template v-else-if="checkResult && !checkResult.canConvert">
|
||||
<p class="mt-3 text-sm text-base-content/70">
|
||||
La conversion de « {{ modelType?.name }} » est impossible pour les raisons suivantes :
|
||||
</p>
|
||||
<ul class="mt-3 space-y-1">
|
||||
<li
|
||||
v-for="(blocker, i) in checkResult.blockers"
|
||||
:key="i"
|
||||
class="flex items-start gap-2 rounded-lg border border-error/20 bg-error/5 px-3 py-2 text-sm text-error"
|
||||
>
|
||||
<IconLucideCircleX class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{{ blocker }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Eligible state -->
|
||||
<template v-else-if="checkResult && checkResult.canConvert">
|
||||
<div class="mt-3 rounded-lg border border-warning/20 bg-warning/5 px-4 py-3">
|
||||
<p class="text-sm font-medium text-warning">
|
||||
{{ directionLabel }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
{{ checkResult.itemCount }} élément(s) seront convertis. Cette opération est irréversible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="checkResult.names.length > 0"
|
||||
class="mt-3 rounded-xl border border-base-200 bg-base-100"
|
||||
>
|
||||
<p class="px-4 pt-3 text-sm font-medium text-base-content/70">
|
||||
Éléments concernés :
|
||||
</p>
|
||||
<ul class="max-h-48 divide-y divide-base-200 overflow-y-auto px-4 pb-3">
|
||||
<li
|
||||
v-for="(name, i) in checkResult.names"
|
||||
:key="i"
|
||||
class="py-1.5 text-sm text-base-content"
|
||||
>
|
||||
{{ name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="convertError" class="mt-3 text-sm text-error">
|
||||
{{ convertError }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:disabled="converting"
|
||||
@click="emit('close')"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
<button
|
||||
v-if="checkResult?.canConvert"
|
||||
type="button"
|
||||
class="btn btn-warning"
|
||||
:disabled="converting"
|
||||
@click="doConvert"
|
||||
>
|
||||
<span v-if="converting" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
Convertir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import IconLucideCircleX from '~icons/lucide/circle-x';
|
||||
import {
|
||||
checkConversion,
|
||||
convertCategory,
|
||||
type ConversionCheck,
|
||||
type ModelType,
|
||||
} from '~/services/modelTypes';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
modelType: ModelType | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'converted'): void;
|
||||
}>();
|
||||
|
||||
const checking = ref(false);
|
||||
const checkError = ref<string | null>(null);
|
||||
const checkResult = ref<ConversionCheck | null>(null);
|
||||
const converting = ref(false);
|
||||
const convertError = ref<string | null>(null);
|
||||
|
||||
const directionLabel = computed(() => {
|
||||
if (!checkResult.value) return '';
|
||||
return checkResult.value.direction === 'piece_to_component'
|
||||
? 'Conversion : Catégorie de pièce → Catégorie de composant'
|
||||
: 'Conversion : Catégorie de composant → Catégorie de pièce';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (isOpen) => {
|
||||
if (!isOpen || !props.modelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
checking.value = true;
|
||||
checkError.value = null;
|
||||
checkResult.value = null;
|
||||
convertError.value = null;
|
||||
|
||||
try {
|
||||
checkResult.value = await checkConversion(props.modelType.id);
|
||||
} catch (err: any) {
|
||||
checkError.value =
|
||||
err?.data?.message || err?.message || 'Erreur lors de la vérification.';
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const doConvert = async () => {
|
||||
if (!props.modelType) return;
|
||||
|
||||
converting.value = true;
|
||||
convertError.value = null;
|
||||
|
||||
try {
|
||||
const result = await convertCategory(props.modelType.id);
|
||||
|
||||
if (!result.success) {
|
||||
convertError.value = result.error || 'La conversion a échoué.';
|
||||
return;
|
||||
}
|
||||
|
||||
emit('converted');
|
||||
} catch (err: any) {
|
||||
convertError.value =
|
||||
err?.data?.message || err?.message || 'Erreur lors de la conversion.';
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -29,12 +29,21 @@
|
||||
:total="total"
|
||||
:limit="limit"
|
||||
:offset="offset"
|
||||
:category="selectedCategory"
|
||||
@related="openRelatedModal"
|
||||
@edit="openEditPage"
|
||||
@delete="confirmDelete"
|
||||
@convert="openConversionModal"
|
||||
@update:offset="onOffsetChange"
|
||||
/>
|
||||
|
||||
<ModelTypesConversionModal
|
||||
:open="conversionModalOpen"
|
||||
:model-type="conversionTarget"
|
||||
@close="closeConversionModal"
|
||||
@converted="onConverted"
|
||||
/>
|
||||
|
||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
||||
<div class="modal-box max-w-3xl">
|
||||
<h3 class="text-lg font-bold text-base-content">
|
||||
@@ -96,6 +105,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue"
|
||||
import { useHead, useRouter } from "#imports";
|
||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||
import ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
|
||||
import { useApi } from "~/composables/useApi";
|
||||
import { useUrlState } from "~/composables/useUrlState";
|
||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
||||
@@ -484,6 +494,26 @@ const closeRelatedModal = () => {
|
||||
relatedModalOpen.value = false;
|
||||
};
|
||||
|
||||
const conversionModalOpen = ref(false);
|
||||
const conversionTarget = ref<ModelType | null>(null);
|
||||
|
||||
const openConversionModal = (item: ModelType) => {
|
||||
conversionTarget.value = item;
|
||||
conversionModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeConversionModal = () => {
|
||||
conversionModalOpen.value = false;
|
||||
};
|
||||
|
||||
const onConverted = () => {
|
||||
conversionModalOpen.value = false;
|
||||
invalidateEntityTypeCache("PIECE");
|
||||
invalidateEntityTypeCache("COMPONENT");
|
||||
showSuccess("Catégorie convertie avec succès.");
|
||||
refresh();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => searchInput.value,
|
||||
(value) => {
|
||||
|
||||
@@ -48,6 +48,15 @@
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</button>
|
||||
<button
|
||||
v-if="showConvertButton"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-warning"
|
||||
@click="emit('convert', item)"
|
||||
>
|
||||
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||
Convertir
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||
Éditer
|
||||
</button>
|
||||
@@ -78,6 +87,15 @@
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</button>
|
||||
<button
|
||||
v-if="showConvertButton"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-warning"
|
||||
@click="emit('convert', item)"
|
||||
>
|
||||
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||
Convertir
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||
Éditer
|
||||
</button>
|
||||
@@ -118,6 +136,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import IconLucideInbox from '~icons/lucide/inbox';
|
||||
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
|
||||
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -126,15 +145,21 @@ const props = defineProps<{
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
category?: ModelCategory;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'related', item: ModelType): void;
|
||||
(e: 'edit', item: ModelType): void;
|
||||
(e: 'delete', item: ModelType): void;
|
||||
(e: 'convert', item: ModelType): void;
|
||||
(e: 'update:offset', offset: number): void;
|
||||
}>();
|
||||
|
||||
const showConvertButton = computed(() =>
|
||||
props.category === 'PIECE' || props.category === 'COMPONENT',
|
||||
);
|
||||
|
||||
const categoryDictionary: Record<ModelCategory, string> = {
|
||||
COMPONENT: 'Composants',
|
||||
PIECE: 'Pièces',
|
||||
|
||||
@@ -34,7 +34,7 @@ export function useMachineCreatePage() {
|
||||
// Composable calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines()
|
||||
const { createMachine, createMachineFromType, reconfigureSkeleton, addMissingCustomFields, deleteMachine } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
||||
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
||||
@@ -340,17 +340,24 @@ export function useMachineCreatePage() {
|
||||
: await createMachineFromType(baseMachineData as any, type)
|
||||
|
||||
if (result.success) {
|
||||
if (hasRequirements && result.data?.id) {
|
||||
const skeletonResult: any = await reconfigureSkeleton(result.data.id, {
|
||||
const machineId = result.data?.id
|
||||
if (hasRequirements && machineId) {
|
||||
const skeletonResult: any = await reconfigureSkeleton(machineId, {
|
||||
componentLinks,
|
||||
pieceLinks,
|
||||
productLinks,
|
||||
} as any)
|
||||
if (!skeletonResult.success) {
|
||||
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants')
|
||||
// Rollback: delete the orphaned machine
|
||||
await deleteMachine(machineId).catch(() => {})
|
||||
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants. La machine n\'a pas été créée.')
|
||||
return
|
||||
}
|
||||
}
|
||||
// Initialize custom fields for the machine type
|
||||
if (machineId) {
|
||||
await addMissingCustomFields(machineId, { showToast: false }).catch(() => {})
|
||||
}
|
||||
newMachine.name = ''
|
||||
newMachine.siteId = ''
|
||||
newMachine.typeMachineId = ''
|
||||
@@ -386,9 +393,9 @@ export function useMachineCreatePage() {
|
||||
await Promise.all([
|
||||
loadSites(),
|
||||
loadMachineTypes(),
|
||||
loadComposants(),
|
||||
loadPieces(),
|
||||
loadProducts(),
|
||||
loadComposants({ itemsPerPage: 200, force: true }),
|
||||
loadPieces({ itemsPerPage: 200, force: true }),
|
||||
loadProducts({ itemsPerPage: 200, force: true }),
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -69,6 +69,25 @@ const badgeClass = (type: ChangeType) => {
|
||||
}
|
||||
|
||||
const releases: Release[] = [
|
||||
{
|
||||
version: 'v1.6.1',
|
||||
date: '2026-02-12',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Suivi d\'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents' },
|
||||
{ type: 'feat', text: 'Traçabilité des conversions de catégories dans le journal d\'activité (action « convert » avec direction, nombre et noms des éléments)' },
|
||||
{ type: 'feat', text: 'Endpoint historique machine : GET /api/machines/{id}/history' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.6.0',
|
||||
date: '2026-02-12',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs' },
|
||||
{ type: 'feat', text: 'Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms' },
|
||||
{ type: 'feat', text: 'Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée' },
|
||||
{ type: 'chore', text: 'Passage php-cs-fixer sur l\'ensemble des contrôleurs et entités du backend' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.5.0',
|
||||
date: '2026-02-11',
|
||||
|
||||
@@ -219,3 +219,33 @@ export function getModelType(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||
signal: opts.signal,
|
||||
})).then(normalizeModelType);
|
||||
}
|
||||
|
||||
export interface ConversionCheck {
|
||||
canConvert: boolean;
|
||||
direction: 'piece_to_component' | 'component_to_piece' | null;
|
||||
itemCount: number;
|
||||
names: string[];
|
||||
blockers: string[];
|
||||
}
|
||||
|
||||
export interface ConversionResult {
|
||||
success: boolean;
|
||||
convertedCount: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function checkConversion(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||
const requestFetch = useRequestFetch();
|
||||
return requestFetch<ConversionCheck>(`${ENDPOINT}/${id}/conversion-check`, createOptions({
|
||||
method: 'GET',
|
||||
signal: opts.signal,
|
||||
}));
|
||||
}
|
||||
|
||||
export function convertCategory(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||
const requestFetch = useRequestFetch();
|
||||
return requestFetch<ConversionResult>(`${ENDPOINT}/${id}/convert`, createOptions({
|
||||
method: 'POST',
|
||||
signal: opts.signal,
|
||||
}));
|
||||
}
|
||||
|
||||
35
e2e/auth.setup.ts
Normal file
35
e2e/auth.setup.ts
Normal file
@@ -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 })
|
||||
})
|
||||
166
e2e/product-category-crud.spec.ts
Normal file
166
e2e/product-category-crud.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
299
e2e/product-crud.spec.ts
Normal file
299
e2e/product-crud.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
37
playwright.config.ts
Normal file
37
playwright.config.ts
Normal file
@@ -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'],
|
||||
},
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user