Gherkin use case for cart features with Cypress + Cumcumber + typescript

Gherkin use case for cart features with Cypress + Cumcumber + typescript
This commit is contained in:
Yassin 2024-09-25 16:51:59 +02:00
parent f22b1ead60
commit 5a17eaf211
53 changed files with 3423 additions and 574 deletions

View File

@ -0,0 +1,7 @@
{
"stepDefinitions": [
"cypress/e2e/**/[filepath].ts",
"cypress/e2e/common-step-definitions/*.ts",
"cypress/support/step_definitions/*.ts"
]
}

3
.gitignore vendored
View File

@ -36,3 +36,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
#cursor
.cursorrules

View File

@ -33,6 +33,7 @@ Vercel is happy to partner and work with any commerce provider to help them get
Integrations enable upgraded or additional functionality for Next.js Commerce Integrations enable upgraded or additional functionality for Next.js Commerce
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/)) - [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration. - Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger. - Search runs entirely in the browser for smaller catalogs or on a CDN for larger.

85
Test-Design.md Normal file
View File

@ -0,0 +1,85 @@
# Component Hierarchy and Data-Test Attributes
Voici un schéma Mermaid qui représente les composants et les hiérarchies des attributs data-test pour le composant `ThreeItemGrid`.
```mermaid
%%{init: {'theme': 'dark'}}%%
graph TD
%% Utilisation d'un vert plus foncé (#339966) avec du texte blanc (#fff) pour améliorer le contraste
classDef grid-item-class fill:#339966,stroke:#aaa,stroke-width:2px,color:#fff;
%% Bleu foncé (#336699) avec texte blanc (#fff) pour les conteneurs
classDef container-class fill:#336699,stroke:#aaa,stroke-width:2px,color:#fff;
%% Rouge foncé (#cc3333) avec texte blanc (#fff) pour les composants React
classDef react-component-class fill:#cc3333,stroke:#aaa,stroke-width:2px,color:#fff;
%% Subgraph représentant la structure globale de la grille
subgraph ThreeItemGrid
style ThreeItemGrid fill:#333,stroke:#aaa,stroke-width:2px;
%% Grid Item avec taille configurable
ThreeItemGridItem[⚛️ Three Item Grids]
class ThreeItemGridItem react-component-class
GridItem[🗂️ data-test: three-item-grid]
ThreeItemGridItem --> GridItem
class GridItem container-class
GridItemSize[🔍 data-test: grid-item <br>⬛ full / ⬜ half]
GridItem --> GridItemSize
class GridItemSize grid-item-class
%% Grid Product Link pour GridItemSize
GridItemSize --> GridProductLink[🔍 data-test: grid-product-link]
class GridProductLink grid-item-class
GridProductLink --> GridTileImage[⚛️ GridTileImage]
class GridTileImage react-component-class
GridTileImage --> GridTileContainer[🗂️ data-test: grid-tile-container]
class GridTileContainer container-class
GridTileContainer --> GridTileImageItem[🔍 data-test: grid-tile-image]
class GridTileImageItem grid-item-class
GridTileContainer --> Label[⚛️ Label]
class Label react-component-class
Label --> LabelContainer[🗂️ data-test: label-container]
class LabelContainer container-class
LabelContainer --> LabelContentWrapper[🗂️ data-test: label-content-wrapper]
class LabelContentWrapper container-class
LabelContentWrapper --> LabelTitleText[🔍 data-test: label-title-text]
class LabelTitleText grid-item-class
LabelContentWrapper --> Price[⚛️ Price]
class Price react-component-class
Price --> PriceAmount[🔍 data-test: price-amount]
class PriceAmount grid-item-class
Price --> PriceCurrencyCode[🔍 data-test: price-currency-code]
class PriceCurrencyCode grid-item-class
end
%% Subgraph pour la légende
subgraph Légende
direction LR
ReactComp[⚛️ Composants React]
class ReactComp react-component-class
DataTestCont[🗂️ Conteneur data-test]
class DataTestCont container-class
TestableElem[🔍 Éléments testables]
class TestableElem grid-item-class
end
```
[![](https://mermaid.ink/img/pako:eNqVVs1u20YQfpUFA0ENIKaWlcoWWwRo5aII4ACBraJAKx9W5FBaeLlLLJeJHUGX3nooeilQoCnQs4E8QO96E79A-wid3RUp_oh2woNE7sx837czQ-6svVBG4AVer7dmgumArPt6BQn0A9KPqLrubza93lzMxVLRdEVmZ-a-1yPfa8ZZRjWTgkT9XJA3oDRJeZ6RWIpwe0c-ezIaTSbj8VNC30BIopxouNFAFpyKEK1xHD8lqcwVocn2jjOpQBEOJJRCK5ppmIuQ0yw7g5gsFYt8piHx7RKJGefBDn-QaSWvIXhCKd3d-29ZpFfBcXozCCWXKjBkX-6Uf8Mhr2ocjyeTncYugRwyKwsE5Cqr6DKLlAlQDV0G81N1Xch8CXthYTjC66OEJanMqNAZuQAa6oo8ZZ596yBA6JpIh_-pIi_zhWsEBana3mWIisyEU4LBeahzBWTJ5YJiISMw61g6zrGWWRE5WymAl1jL77Coc0HwyvQt-tcMZSoflmh1GQjUZsNM_C5n1BCbEsVsmSu6MCqMa43H_P90_-f7f__5zRkcgjFlV87fZa0VdTi7LsT9lvj__f3Hz4Ygopr6GjJ8zbSBcy1tmvuqQxrx_RclTlVO6dBowUP8l-wdoIbff60KKF8p8tVCvbj_8J7EOefkc3L_4S-yojy-qoPUlBjEQ2rMevNlbVbotZIRdgo5Z-LadXEbtYZXEO8CTdzh3aTOwefocdWUV4nuUth0K4hnjMPLhC6haJTaYouptHxch-zdq3zToqyHmsfqxw8w-GX1D6ooQdpdUuffO7b2vGvgA-m2AtjDKbCd80C62-TndAG8SLR9qKHblccT69xKvAezyY1HRybr0V1pbHjVaFHeD_jdSx_jNtt46xwPK9gDPSqj4lpqmTHNYYbnSKuYToM2dt8cNG36MrarlF3MrxULy7fGPtTA7crjxXRuJd7XicxFexupsfnUGtssLqhLf4NhmisFIryd4mzUwRPuXHwzP7XpqghtUhBR8zx1xzklfHu3RHP1wDwvlwxJxBSEduw6v3Ar9tSfYvqKRE8bE0FNXundlXfnfIYbnuF-TVHLxp0WM9A-HzXsalBXkxq7OYu_5cV3ZfsLbjoBI1fvjI2jtxLSkUxv4CWgEsoinGXXJnju2Tl27gV4i5pxpuRzby426EpzLS9vRegFOLTAwFM4eq28IKY8w6c8xd3BGaOY_qRcTan4UcqkCIGIaaleueHZztDWxQvW3o0XHB-Nnx0_H05Ono-GJ6fDk4F36wVHm4H3zkIMnx25a3h8OpycfjEab_4HDOwk4w?type=png)](https://mermaid-js.github.io/mermaid-live-editor/edit#pako:eNqVVs1u20YQfpUFA0ENIKaWlcoWWwRo5aII4ACBraJAKx9W5FBaeLlLLJeJHUGX3nooeilQoCnQs4E8QO96E79A-wid3RUp_oh2woNE7sx837czQ-6svVBG4AVer7dmgumArPt6BQn0A9KPqLrubza93lzMxVLRdEVmZ-a-1yPfa8ZZRjWTgkT9XJA3oDRJeZ6RWIpwe0c-ezIaTSbj8VNC30BIopxouNFAFpyKEK1xHD8lqcwVocn2jjOpQBEOJJRCK5ppmIuQ0yw7g5gsFYt8piHx7RKJGefBDn-QaSWvIXhCKd3d-29ZpFfBcXozCCWXKjBkX-6Uf8Mhr2ocjyeTncYugRwyKwsE5Cqr6DKLlAlQDV0G81N1Xch8CXthYTjC66OEJanMqNAZuQAa6oo8ZZ596yBA6JpIh_-pIi_zhWsEBana3mWIisyEU4LBeahzBWTJ5YJiISMw61g6zrGWWRE5WymAl1jL77Coc0HwyvQt-tcMZSoflmh1GQjUZsNM_C5n1BCbEsVsmSu6MCqMa43H_P90_-f7f__5zRkcgjFlV87fZa0VdTi7LsT9lvj__f3Hz4Ygopr6GjJ8zbSBcy1tmvuqQxrx_RclTlVO6dBowUP8l-wdoIbff60KKF8p8tVCvbj_8J7EOefkc3L_4S-yojy-qoPUlBjEQ2rMevNlbVbotZIRdgo5Z-LadXEbtYZXEO8CTdzh3aTOwefocdWUV4nuUth0K4hnjMPLhC6haJTaYouptHxch-zdq3zToqyHmsfqxw8w-GX1D6ooQdpdUuffO7b2vGvgA-m2AtjDKbCd80C62-TndAG8SLR9qKHblccT69xKvAezyY1HRybr0V1pbHjVaFHeD_jdSx_jNtt46xwPK9gDPSqj4lpqmTHNYYbnSKuYToM2dt8cNG36MrarlF3MrxULy7fGPtTA7crjxXRuJd7XicxFexupsfnUGtssLqhLf4NhmisFIryd4mzUwRPuXHwzP7XpqghtUhBR8zx1xzklfHu3RHP1wDwvlwxJxBSEduw6v3Ar9tSfYvqKRE8bE0FNXundlXfnfIYbnuF-TVHLxp0WM9A-HzXsalBXkxq7OYu_5cV3ZfsLbjoBI1fvjI2jtxLSkUxv4CWgEsoinGXXJnju2Tl27gV4i5pxpuRzby426EpzLS9vRegFOLTAwFM4eq28IKY8w6c8xd3BGaOY_qRcTan4UcqkCIGIaaleueHZztDWxQvW3o0XHB-Nnx0_H05Ono-GJ6fDk4F36wVHm4H3zkIMnx25a3h8OpycfjEab_4HDOwk4w)

View File

@ -80,9 +80,12 @@ export default async function ProductPage({ params }: { params: { handle: string
__html: JSON.stringify(productJsonLd) __html: JSON.stringify(productJsonLd)
}} }}
/> />
<div className="mx-auto max-w-screen-2xl px-4"> <div className="mx-auto max-w-screen-2xl px-4" data-test="product-page">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black"> <div
<div className="h-full w-full basis-full lg:basis-4/6"> className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black"
data-test="product-details"
>
<div className="h-full w-full basis-full lg:basis-4/6" data-test="product-image">
<Suspense <Suspense
fallback={ fallback={
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" /> <div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
@ -97,7 +100,7 @@ export default async function ProductPage({ params }: { params: { handle: string
</Suspense> </Suspense>
</div> </div>
<div className="basis-full lg:basis-2/6"> <div className="basis-full lg:basis-2/6" data-test="product-description">
<Suspense fallback={null}> <Suspense fallback={null}>
<ProductDescription product={product} /> <ProductDescription product={product} />
</Suspense> </Suspense>
@ -116,18 +119,20 @@ async function RelatedProducts({ id }: { id: string }) {
if (!relatedProducts.length) return null; if (!relatedProducts.length) return null;
return ( return (
<div className="py-8"> <div className="py-8" data-test="product-related">
<h2 className="mb-4 text-2xl font-bold">Related Products</h2> <h2 className="mb-4 text-2xl font-bold">Related Products</h2>
<ul className="flex w-full gap-4 overflow-x-auto pt-1"> <ul className="flex w-full gap-4 overflow-x-auto pt-1">
{relatedProducts.map((product) => ( {relatedProducts.map((product) => (
<li <li
key={product.handle} key={product.handle}
className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5" className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
data-test="product-item"
> >
<Link <Link
className="relative h-full w-full" className="relative h-full w-full"
href={`/product/${product.handle}`} href={`/product/${product.handle}`}
prefetch={true} prefetch={true}
data-test="product-item-link"
> >
<GridTileImage <GridTileImage
alt={product.title} alt={product.title}

View File

@ -12,14 +12,19 @@ export async function Carousel() {
const carouselProducts = [...products, ...products, ...products]; const carouselProducts = [...products, ...products, ...products];
return ( return (
<div className="w-full overflow-x-auto pb-6 pt-1"> <div className="w-full overflow-x-auto pb-6 pt-1" data-test="carousel-container">
<ul className="flex animate-carousel gap-4"> <ul className="flex animate-carousel gap-4">
{carouselProducts.map((product, i) => ( {carouselProducts.map((product, i) => (
<li <li
key={`${product.handle}${i}`} key={`${product.handle}${i}`}
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3" className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
data-test={`carousel-item-${i}`}
>
<Link
href={`/product/${product.handle}`}
className="relative h-full w-full"
data-test="product-link"
> >
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
<GridTileImage <GridTileImage
alt={product.title} alt={product.title}
label={{ label={{
@ -30,6 +35,7 @@ export async function Carousel() {
src={product.featuredImage?.url} src={product.featuredImage?.url}
fill fill
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw" sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
data-test="tile-image"
/> />
</Link> </Link>
</li> </li>

View File

@ -8,15 +8,20 @@ import { redirect } from 'next/navigation';
export async function addItem(prevState: any, selectedVariantId: string | undefined) { export async function addItem(prevState: any, selectedVariantId: string | undefined) {
let cartId = cookies().get('cartId')?.value; let cartId = cookies().get('cartId')?.value;
console.log('Cart ID before adding item:', cartId);
if (!cartId || !selectedVariantId) { if (!cartId || !selectedVariantId) {
console.error('Cart ID or selected variant ID is missing');
return 'Error adding item to cart'; return 'Error adding item to cart';
} }
try { try {
console.log('Attempting to add item to Shopify cart');
await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]); await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
console.log('Item added to Shopify cart successfully');
revalidateTag(TAGS.cart); revalidateTag(TAGS.cart);
} catch (e) { } catch (e) {
console.error('Error adding item to cart:', e);
return 'Error adding item to cart'; return 'Error adding item to cart';
} }
} }

View File

@ -2,11 +2,10 @@
import { PlusIcon } from '@heroicons/react/24/outline'; import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { addItem } from 'components/cart/actions';
import { useProduct } from 'components/product/product-context'; import { useProduct } from 'components/product/product-context';
import { Product, ProductVariant } from 'lib/shopify/types'; import { Product, ProductVariant } from 'lib/shopify/types';
import { useFormState } from 'react-dom';
import { useCart } from './cart-context'; import { useCart } from './cart-context';
import { addItem } from './actions';
function SubmitButton({ function SubmitButton({
availableForSale, availableForSale,
@ -21,19 +20,20 @@ function SubmitButton({
if (!availableForSale) { if (!availableForSale) {
return ( return (
<button disabled className={clsx(buttonClasses, disabledClasses)}> <button disabled className={clsx(buttonClasses, disabledClasses)} data-test="add-to-cart">
Out Of Stock Out Of Stock
</button> </button>
); );
} }
console.log(selectedVariantId); // console.log(selectedVariantId);
if (!selectedVariantId) { if (!selectedVariantId) {
return ( return (
<button <button
aria-label="Please select an option" aria-label="Please select an option"
disabled disabled
className={clsx(buttonClasses, disabledClasses)} className={clsx(buttonClasses, disabledClasses)}
data-test="add-to-cart"
> >
<div className="absolute left-0 ml-4"> <div className="absolute left-0 ml-4">
<PlusIcon className="h-5" /> <PlusIcon className="h-5" />
@ -49,6 +49,7 @@ function SubmitButton({
className={clsx(buttonClasses, { className={clsx(buttonClasses, {
'hover:opacity-90': true 'hover:opacity-90': true
})} })}
data-test="add-to-cart"
> >
<div className="absolute left-0 ml-4"> <div className="absolute left-0 ml-4">
<PlusIcon className="h-5" /> <PlusIcon className="h-5" />
@ -62,27 +63,37 @@ export function AddToCart({ product }: { product: Product }) {
const { variants, availableForSale } = product; const { variants, availableForSale } = product;
const { addCartItem } = useCart(); const { addCartItem } = useCart();
const { state } = useProduct(); const { state } = useProduct();
const [message, formAction] = useFormState(addItem, null);
// Trouver le variant correspondant à l'état actuel du produit
const variant = variants.find((variant: ProductVariant) => const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()]) variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()])
); );
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined; const defaultVariant = variants.length === 1 ? variants[0] : undefined;
const selectedVariantId = variant?.id || defaultVariantId; const selectedVariant = variant || defaultVariant;
const actionWithVariant = formAction.bind(null, selectedVariantId); const handleSubmit = async (event: React.FormEvent) => {
const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!; event.preventDefault();
event.stopPropagation(); // Assure-toi d'arrêter la propagation des événements
if (selectedVariant) {
addCartItem(selectedVariant, product); // Appel avec l'objet ProductVariant
// Appel côté serveur pour ajouter au panier via Shopify
try {
await addItem(null, selectedVariant.id); // Appel côté serveur
} catch (error) {
console.error('Error adding item to server cart:', error);
}
} else {
console.error('No variant selected');
}
};
return ( return (
<form <form onSubmit={handleSubmit}>
action={async () => { <SubmitButton
addCartItem(finalVariant, product); availableForSale={availableForSale}
await actionWithVariant(); selectedVariantId={selectedVariant?.id} // Utilisation de l'ID du variant dans le bouton
}} />
>
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form> </form>
); );
} }

View File

@ -1,7 +1,14 @@
'use client'; 'use client';
import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types'; import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types';
import React, { createContext, use, useContext, useMemo, useOptimistic } from 'react'; import React, {
createContext,
startTransition,
use,
useContext,
useMemo,
useOptimistic
} from 'react';
type UpdateType = 'plus' | 'minus' | 'delete'; type UpdateType = 'plus' | 'minus' | 'delete';
@ -50,9 +57,10 @@ function createOrUpdateCartItem(
): CartItem { ): CartItem {
const quantity = existingItem ? existingItem.quantity + 1 : 1; const quantity = existingItem ? existingItem.quantity + 1 : 1;
const totalAmount = calculateItemCost(quantity, variant.price.amount); const totalAmount = calculateItemCost(quantity, variant.price.amount);
const itemId = variant.id ?? existingItem?.id;
return { return {
id: existingItem?.id, id: itemId,
quantity, quantity,
cost: { cost: {
totalAmount: { totalAmount: {
@ -131,6 +139,10 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
} }
case 'ADD_ITEM': { case 'ADD_ITEM': {
const { variant, product } = action.payload; const { variant, product } = action.payload;
if (!variant.id) {
return currentCart; // Ne pas ajouter l'article si l'ID est manquant
}
const existingItem = currentCart.lines.find((item) => item.merchandise.id === variant.id); const existingItem = currentCart.lines.find((item) => item.merchandise.id === variant.id);
const updatedItem = createOrUpdateCartItem(existingItem, variant, product); const updatedItem = createOrUpdateCartItem(existingItem, variant, product);
@ -156,11 +168,15 @@ export function CartProvider({
const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer); const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer);
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => { const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
startTransition(() => {
updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType } }); updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType } });
});
}; };
const addCartItem = (variant: ProductVariant, product: Product) => { const addCartItem = (variant: ProductVariant, product: Product) => {
startTransition(() => {
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } }); updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
});
}; };
const value = useMemo( const value = useMemo(

View File

@ -3,7 +3,7 @@
import { XMarkIcon } from '@heroicons/react/24/outline'; import { XMarkIcon } from '@heroicons/react/24/outline';
import { removeItem } from 'components/cart/actions'; import { removeItem } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from 'lib/shopify/types';
import { useFormState } from 'react-dom'; import { useActionState } from 'react';
export function DeleteItemButton({ export function DeleteItemButton({
item, item,
@ -12,7 +12,7 @@ export function DeleteItemButton({
item: CartItem; item: CartItem;
optimisticUpdate: any; optimisticUpdate: any;
}) { }) {
const [message, formAction] = useFormState(removeItem, null); const [message, formAction] = useActionState(removeItem, null);
const merchandiseId = item.merchandise.id; const merchandiseId = item.merchandise.id;
const actionWithVariant = formAction.bind(null, merchandiseId); const actionWithVariant = formAction.bind(null, merchandiseId);

View File

@ -4,7 +4,7 @@ import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { updateItemQuantity } from 'components/cart/actions'; import { updateItemQuantity } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types'; import type { CartItem } from 'lib/shopify/types';
import { useFormState } from 'react-dom'; import { useActionState } from 'react';
function SubmitButton({ type }: { type: 'plus' | 'minus' }) { function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
return ( return (
@ -17,6 +17,7 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
'ml-auto': type === 'minus' 'ml-auto': type === 'minus'
} }
)} )}
data-test={`cart-quantity-${type}-button`}
> >
{type === 'plus' ? ( {type === 'plus' ? (
<PlusIcon className="h-4 w-4 dark:text-neutral-500" /> <PlusIcon className="h-4 w-4 dark:text-neutral-500" />
@ -36,7 +37,7 @@ export function EditItemQuantityButton({
type: 'plus' | 'minus'; type: 'plus' | 'minus';
optimisticUpdate: any; optimisticUpdate: any;
}) { }) {
const [message, formAction] = useFormState(updateItemQuantity, null); const [message, formAction] = useActionState(updateItemQuantity, null);
const payload = { const payload = {
merchandiseId: item.merchandise.id, merchandiseId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1 quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1

View File

@ -49,7 +49,7 @@ export default function CartModal() {
return ( return (
<> <>
<button aria-label="Open cart" onClick={openCart}> <button aria-label="Open cart" onClick={openCart} data-test="cart-open-button">
<OpenCart quantity={cart?.totalQuantity} /> <OpenCart quantity={cart?.totalQuantity} />
</button> </button>
<Transition show={isOpen}> <Transition show={isOpen}>
@ -63,7 +63,11 @@ export default function CartModal() {
leaveFrom="opacity-100 backdrop-blur-[.5px]" leaveFrom="opacity-100 backdrop-blur-[.5px]"
leaveTo="opacity-0 backdrop-blur-none" leaveTo="opacity-0 backdrop-blur-none"
> >
<div className="fixed inset-0 bg-black/30" aria-hidden="true" /> <div
className="fixed inset-0 bg-black/30"
aria-hidden="true"
data-test="cart-modal-container"
/>
</Transition.Child> </Transition.Child>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
@ -74,10 +78,15 @@ export default function CartModal() {
leaveFrom="translate-x-0" leaveFrom="translate-x-0"
leaveTo="translate-x-full" leaveTo="translate-x-full"
> >
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl md:w-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white"> <Dialog.Panel
className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl md:w-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white"
data-test="cart-side-container"
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-lg font-semibold">My Cart</p> <p className="text-lg font-semibold" data-test="cart-title">
<button aria-label="Close cart" onClick={closeCart}> My Cart
</p>
<button aria-label="Close cart" onClick={closeCart} data-test="cart-close-button">
<CloseCart /> <CloseCart />
</button> </button>
</div> </div>
@ -85,7 +94,9 @@ export default function CartModal() {
{!cart || cart.lines.length === 0 ? ( {!cart || cart.lines.length === 0 ? (
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden"> <div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
<ShoppingCartIcon className="h-16" /> <ShoppingCartIcon className="h-16" />
<p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p> <p className="mt-6 text-center text-2xl font-bold" data-test="cart-empty">
Your cart is empty.
</p>
</div> </div>
) : ( ) : (
<div className="flex h-full flex-col justify-between overflow-hidden p-1"> <div className="flex h-full flex-col justify-between overflow-hidden p-1">
@ -112,12 +123,13 @@ export default function CartModal() {
<li <li
key={i} key={i}
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700" className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
data-test="cart-item"
> >
<div className="relative flex w-full flex-row justify-between px-1 py-4"> <div className="relative flex w-full flex-row justify-between px-1 py-4">
<div className="absolute z-40 -ml-1 -mt-2"> <div className="absolute z-40 -ml-1 -mt-2">
<DeleteItemButton item={item} optimisticUpdate={updateCartItem} /> <DeleteItemButton item={item} optimisticUpdate={updateCartItem} />
</div> </div>
<div className="flex flex-row"> <div className="flex flex-row" data-test="cart-product-container">
<div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800"> <div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
<Image <Image
className="h-full w-full object-cover" className="h-full w-full object-cover"
@ -128,6 +140,7 @@ export default function CartModal() {
item.merchandise.product.title item.merchandise.product.title
} }
src={item.merchandise.product.featuredImage.url} src={item.merchandise.product.featuredImage.url}
data-test="cart-product-image"
/> />
</div> </div>
<Link <Link
@ -160,7 +173,12 @@ export default function CartModal() {
optimisticUpdate={updateCartItem} optimisticUpdate={updateCartItem}
/> />
<p className="w-6 text-center"> <p className="w-6 text-center">
<span className="w-full text-sm">{item.quantity}</span> <span
className="w-full text-sm"
data-test="cart-product-quantity"
>
{item.quantity}
</span>
</p> </p>
<EditItemQuantityButton <EditItemQuantityButton
item={item} item={item}
@ -174,7 +192,10 @@ export default function CartModal() {
); );
})} })}
</ul> </ul>
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400"> <div
className="py-4 text-sm text-neutral-500 dark:text-neutral-400"
data-test="payment-information"
>
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700"> <div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
<p>Taxes</p> <p>Taxes</p>
<Price <Price
@ -217,6 +238,7 @@ function CheckoutButton() {
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100" className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
type="submit" type="submit"
disabled={pending} disabled={pending}
data-test="checkout-button"
> >
{pending ? <LoadingDots className="bg-white" /> : 'Proceed to Checkout'} {pending ? <LoadingDots className="bg-white" /> : 'Proceed to Checkout'}
</button> </button>

View File

@ -6,20 +6,24 @@ import Link from 'next/link';
function ThreeItemGridItem({ function ThreeItemGridItem({
item, item,
size, size,
priority priority,
index
}: { }: {
item: Product; item: Product;
size: 'full' | 'half'; size: 'full' | 'half';
priority?: boolean; priority?: boolean;
index: number;
}) { }) {
return ( return (
<div <div
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'} className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
data-test={index !== undefined ? `grid-item-${size}-${index}` : `grid-item-${size}`}
> >
<Link <Link
className="relative block aspect-square h-full w-full" className="relative block aspect-square h-full w-full"
href={`/product/${item.handle}`} href={`/product/${item.handle}`}
prefetch={true} prefetch={true}
data-test="product-link"
> >
<GridTileImage <GridTileImage
src={item.featuredImage.url} src={item.featuredImage.url}
@ -52,10 +56,13 @@ export async function ThreeItemGrid() {
const [firstProduct, secondProduct, thirdProduct] = homepageItems; const [firstProduct, secondProduct, thirdProduct] = homepageItems;
return ( return (
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]"> <section
<ThreeItemGridItem size="full" item={firstProduct} priority={true} /> className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]"
<ThreeItemGridItem size="half" item={secondProduct} priority={true} /> data-test="three-item-grid"
<ThreeItemGridItem size="half" item={thirdProduct} /> >
<ThreeItemGridItem size="full" item={firstProduct} index={0} priority={true} />
<ThreeItemGridItem size="half" item={secondProduct} index={1} priority={true} />
<ThreeItemGridItem size="half" item={thirdProduct} index={2} />
</section> </section>
); );
} }

View File

@ -27,6 +27,7 @@ export function GridTileImage({
'border-neutral-200 dark:border-neutral-800': !active 'border-neutral-200 dark:border-neutral-800': !active
} }
)} )}
data-test="tile-container"
> >
{props.src ? ( {props.src ? (
<Image <Image
@ -34,6 +35,7 @@ export function GridTileImage({
'transition duration-300 ease-in-out group-hover:scale-105': isInteractive 'transition duration-300 ease-in-out group-hover:scale-105': isInteractive
})} })}
{...props} {...props}
data-test="tile-image"
/> />
) : null} ) : null}
{label ? ( {label ? (

View File

@ -17,9 +17,18 @@ const Label = ({
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', { className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', {
'lg:px-20 lg:pb-[35%]': position === 'center' 'lg:px-20 lg:pb-[35%]': position === 'center'
})} })}
data-test="label-container"
> >
<div className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white"> <div
<h3 className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight">{title}</h3> className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white"
data-test="label-content-wrapper"
>
<h3
className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight"
data-test="label-title-text"
>
{title}
</h3>
<Price <Price
className="flex-none rounded-full bg-blue-600 p-2 text-white" className="flex-none rounded-full bg-blue-600 p-2 text-white"
amount={amount} amount={amount}

View File

@ -15,7 +15,7 @@ export default async function Footer() {
const copyrightName = COMPANY_NAME || SITE_NAME || ''; const copyrightName = COMPANY_NAME || SITE_NAME || '';
return ( return (
<footer className="text-sm text-neutral-500 dark:text-neutral-400"> <footer className="text-sm text-neutral-500 dark:text-neutral-400" data-test="footer">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm md:flex-row md:gap-12 md:px-4 min-[1320px]:px-0 dark:border-neutral-700"> <div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm md:flex-row md:gap-12 md:px-4 min-[1320px]:px-0 dark:border-neutral-700">
<div> <div>
<Link className="flex items-center gap-2 text-black md:pt-1 dark:text-white" href="/"> <Link className="flex items-center gap-2 text-black md:pt-1 dark:text-white" href="/">

View File

@ -13,7 +13,7 @@ export async function Navbar() {
const menu = await getMenu('next-js-frontend-header-menu'); const menu = await getMenu('next-js-frontend-header-menu');
return ( return (
<nav className="relative flex items-center justify-between p-4 lg:px-6"> <nav className="relative flex items-center justify-between p-4 lg:px-6" data-test="navbar">
<div className="block flex-none md:hidden"> <div className="block flex-none md:hidden">
<Suspense fallback={null}> <Suspense fallback={null}>
<MobileMenu menu={menu} /> <MobileMenu menu={menu} />
@ -25,6 +25,7 @@ export async function Navbar() {
href="/" href="/"
prefetch={true} prefetch={true}
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6" className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
data-test="navbar-logo"
> >
<LogoSquare /> <LogoSquare />
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block"> <div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">

View File

@ -36,6 +36,7 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
onClick={openMobileMenu} onClick={openMobileMenu}
aria-label="Open mobile menu" aria-label="Open mobile menu"
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors md:hidden dark:border-neutral-700 dark:text-white" className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors md:hidden dark:border-neutral-700 dark:text-white"
data-test="mobile-menu-button"
> >
<Bars3Icon className="h-4" /> <Bars3Icon className="h-4" />
</button> </button>

View File

@ -17,6 +17,7 @@ export default function Search() {
autoComplete="off" autoComplete="off"
defaultValue={searchParams?.get('q') || ''} defaultValue={searchParams?.get('q') || ''}
className="text-md w-full rounded-lg border bg-white px-4 py-2 text-black placeholder:text-neutral-500 md:text-sm dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400" className="text-md w-full rounded-lg border bg-white px-4 py-2 text-black placeholder:text-neutral-500 md:text-sm dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
data-test="search-input"
/> />
<div className="absolute right-0 top-0 mr-3 flex h-full items-center"> <div className="absolute right-0 top-0 mr-3 flex h-full items-center">
<MagnifyingGlassIcon className="h-4" /> <MagnifyingGlassIcon className="h-4" />

View File

@ -26,6 +26,7 @@ function PathFilterItem({ item }: { item: PathFilterItem }) {
'underline underline-offset-4': active 'underline underline-offset-4': active
} }
)} )}
data-test="search-filter-item"
> >
{item.title} {item.title}
</DynamicTag> </DynamicTag>

View File

@ -3,7 +3,7 @@ import clsx from 'clsx';
const Price = ({ const Price = ({
amount, amount,
className, className,
currencyCode = 'USD', currencyCode = 'EUR',
currencyCodeClassName currencyCodeClassName
}: { }: {
amount: string; amount: string;
@ -11,13 +11,16 @@ const Price = ({
currencyCode: string; currencyCode: string;
currencyCodeClassName?: string; currencyCodeClassName?: string;
} & React.ComponentProps<'p'>) => ( } & React.ComponentProps<'p'>) => (
<p suppressHydrationWarning={true} className={className}> <p suppressHydrationWarning={true} className={className} data-test="price-amount">
{`${new Intl.NumberFormat(undefined, { {`${new Intl.NumberFormat(undefined, {
style: 'currency', style: 'currency',
currency: currencyCode, currency: currencyCode,
currencyDisplay: 'narrowSymbol' currencyDisplay: 'narrowSymbol'
}).format(parseFloat(amount))}`} }).format(parseFloat(amount))}`}
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{`${currencyCode}`}</span> <span
className={clsx('ml-1 inline', currencyCodeClassName)}
data-test="price-currency-code"
>{`${currencyCode}`}</span>
</p> </p>
); );

View File

@ -8,8 +8,13 @@ export function ProductDescription({ product }: { product: Product }) {
return ( return (
<> <>
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700"> <div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
<h1 className="mb-2 text-5xl font-medium">{product.title}</h1> <h1 className="mb-2 text-5xl font-medium" data-test="product-title">
<div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white"> {product.title}
</h1>
<div
className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white"
data-test="price-container"
>
<Price <Price
amount={product.priceRange.maxVariantPrice.amount} amount={product.priceRange.maxVariantPrice.amount}
currencyCode={product.priceRange.maxVariantPrice.currencyCode} currencyCode={product.priceRange.maxVariantPrice.currencyCode}

View File

@ -81,6 +81,7 @@ export function VariantSelector({
!isAvailableForSale !isAvailableForSale
} }
)} )}
data-test={`${option.name}-${value}`}
> >
{value} {value}
</button> </button>

View File

@ -1,10 +1,28 @@
import { defineConfig } from 'cypress'; import { defineConfig } from 'cypress';
import createBundler from '@bahmutov/cypress-esbuild-preprocessor';
import { addCucumberPreprocessorPlugin } from '@badeball/cypress-cucumber-preprocessor';
import createEsbuildPlugin from '@badeball/cypress-cucumber-preprocessor/esbuild';
async function setupNodeEvents(
on: Cypress.PluginEvents,
config: Cypress.PluginConfigOptions
): Promise<Cypress.PluginConfigOptions> {
await addCucumberPreprocessorPlugin(on, config);
on(
'file:preprocessor',
createBundler({
plugins: [createEsbuildPlugin(config)]
})
);
return config;
}
export default defineConfig({ export default defineConfig({
e2e: { e2e: {
baseUrl: 'http://localhost:3000', baseUrl: 'http://localhost:3000',
setupNodeEvents(on, config) { specPattern: '**/*.feature',
// implement node event listeners here setupNodeEvents
}
} }
}); });

View File

@ -0,0 +1,23 @@
import { Given } from '@badeball/cypress-cucumber-preprocessor';
import { toSlug, verifyRequest } from 'cypress/utils/url';
Given('I am on the product detail page for {string}', (productName: string) => {
const slugifiedProductName = toSlug(productName);
cy.intercept('GET', `/product/${slugifiedProductName}`).as('getProduct');
cy.intercept('POST', '/product/*').as('postProduct');
// Visite de la page produit
cy.visit(`/product/${slugifiedProductName}`);
// Femrer la notification de NextJs
cy.get('body > main > section')
.find('button')
.should('exist')
.and('be.visible')
.and('not.be.disabled')
.trigger('click');
// Vérifie la réponse du produit
verifyRequest('@getProduct');
verifyRequest('@postProduct');
});

View File

@ -0,0 +1,64 @@
Feature: Add product to cart
As a customer
I want to add products to my shopping cart
So that I can purchase them later
Scenario: Add a product to the cart from the product page
Given I am on the product detail page for "<product-name>"
When I select "<color>" color of "<product-name>"
And I click "Add to Cart"
Then the product should be added to my shopping cart
Examples:
| color | product-name |
| Ice |The Complete Snowboard|
| Dawn |The Complete Snowboard|
| Powder |The Complete Snowboard|
| Electric |The Complete Snowboard|
| Sunset |The Complete Snowboard|
Scenario: Check that the cart total updates after adding a product
Given I am on the product detail page for "<product-name>"
When I click "Add to Cart"
And the "<product-name>" costs "<amount>" "<code-currency>"
Then the total in the shopping cart should be updated to "<amount>" "<code-currency>"
Examples:
| product-name | amount | code-currency |
| The Compare at Price Snowboard |785,95 | EUR |
Scenario: Cannot add a product to the cart without selecting a required option
Given I am on the product detail page for "<product-name>"
When I do not select a color
Then I Can not add the product in the shopping cart
Examples:
| product-name |
|The Complete Snowboard|
Scenario: Add a product to the cart that is out of stock
Given I am on the product detail page for "<product-name>"
And the "<product-name>" is out of stock
When I click "Add to Cart"
Then the product should not be added to my shopping cart
And "Add to Cart" button should have the name "Out Of Stock"
Examples:
| product-name |
|The Out of Stock Snowboard|
Scenario: Add multiple quantities of a product to the cart
Given I am on the product detail page for "<product-name>"
When I select "<color>" color of "<product-name>"
And I click "Add to Cart"
And I select "<quantity>" quantities
Then the cart should contain "<quantity>" items of "<product-name>"
Examples:
| color | product-name |quantity |
| Ice |The Complete Snowboard| 2 |
| Dawn |The Complete Snowboard| 2 |
| Powder |The Complete Snowboard| 2 |
| Electric |The Complete Snowboard| 2 |
| Sunset |The Complete Snowboard| 2 |

View File

@ -0,0 +1,209 @@
import { Given, When, Then } from '@badeball/cypress-cucumber-preprocessor';
import { toSlug, verifyRequest } from 'cypress/utils/url';
Given(
'the {string} is out of stock',
(productName: string) => {
// Charger la fixture
cy.fixture('product-query.json').then((query) => {
// Insérer le nom du produit dans la requête
query.variables.productName = productName; // Nom du produit à interroger
// Faire la requête GraphQL
cy.request({
method: 'POST',
url: 'testify-automation.myshopify.com/api/2024-07/graphql', // URL complète de l'API GraphQL de Shopify
body: query,
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': '8bc822f670ae4977b83b0975fa80054b' // Token d'accès
},
failOnStatusCode: false // Empêche Cypress d'échouer automatiquement
}).then((response) => {
// Afficher la réponse dans la console pour le débogage
console.log('response : ', response);
// Vérifier le statut de la réponse
expect(response.status).to.eq(200);
// Vérifier si le produit existe dans la réponse
const product = response.body.data?.products?.edges[0]?.node;
expect(product).to.exist;
// Assertion pour le nom du produit
expect(product).to.have.property('title');
expect(product.title).to.equal(productName);
// Assertion du stock du produit
expect(product).to.have.property('totalInventory');
expect(product.totalInventory).to.equal(0);
});
});
}
);
When('I select {string} color of {string}', (expectedColor: string, productName: string) => {
const slugifiedProductName = toSlug(productName);
// Intercepter les requêtes pour sélectionner une variante et changer la couleur
cy.intercept('GET', `/product\/${slugifiedProductName}\?color=*`).as('getColorVariant');
// cy.intercept('POST', `/product\/${slugifiedProductName}`).as('selectColorVariant');
// Vérifier l'état du bouton "Add to Cart" avant la sélection de la couleur
cy.getBySel('add-to-cart').should('exist').and('be.visible').and('be.disabled');
// Vérifie la couleur attendue et sélectionne-la
cy.getBySel(`Color-${expectedColor}`)
.should('exist')
.and('be.visible')
.and('be.enabled')
.invoke('text')
.should('equal', `${expectedColor}`);
cy.getBySel(`Color-${expectedColor}`).click();
// Vérifie les requêtes après sélection de la couleur
// verifyRequest('@postProduct');
verifyRequest('@getColorVariant');
// verifyRequest('@selectColorVariant');
});
When(
'the {string} costs {string} {string}',
(productName: string, amount: string, currencyCode: string) => {
// Charger la fixture
cy.fixture('product-query.json').then((query) => {
// Insérer le nom du produit dans la requête
query.variables.productName = productName; // Nom du produit à interroger
// Faire la requête GraphQL
cy.request({
method: 'POST',
url: 'testify-automation.myshopify.com/api/2024-07/graphql', // URL complète de l'API GraphQL de Shopify
body: query,
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': '8bc822f670ae4977b83b0975fa80054b' // Token d'accès
},
failOnStatusCode: false // Empêche Cypress d'échouer automatiquement
}).then((response) => {
// Afficher la réponse dans la console pour le débogage
console.log('response : ', response);
// Vérifier le statut de la réponse
expect(response.status).to.eq(200);
// Vérifier si le produit existe dans la réponse
const product = response.body.data?.products?.edges[0]?.node;
expect(product).to.exist;
// Assertion pour le nom du produit
expect(product).to.have.property('title');
expect(product.title).to.equal(productName);
// Vérifier si les variantes existent
const variant = product.variants?.edges[0]?.node;
expect(variant).to.exist;
// Vérifier les informations sur le prix
const price = variant.price;
expect(price).to.have.property('amount');
expect(price.amount).to.equal(amount.replace(',', '.'));
expect(price).to.have.property('currencyCode');
expect(price.currencyCode).to.equal(currencyCode);
// Logs supplémentaires pour vérifier les variantes et les prix
product.variants.edges.forEach((variant: any) => {
cy.log(`Variant: ${variant.node.title}`);
cy.log(`Price: ${variant.node.price.amount} ${variant.node.price.currencyCode}`);
});
});
});
}
);
When('I click {string}', (buttonName: string) => {
// cy.intercept('POST', '/product/*').as('createNewCart');
const slugifiedButtonName = toSlug(buttonName);
// Vérifie l'existence et l'état du bouton
cy.getBySel(slugifiedButtonName).should('exist').and('be.visible');
// Vérifie que le cookie `cartId` est bien présent et défini
// cy.wait('@postProduct');
// cy.wait('@createNewCart');
cy.getCookie('cartId').should('exist').its('value').should('not.be.undefined');
// Clique sur le bouton
cy.getBySel(slugifiedButtonName).click({ force: true });
});
When('I do not select a color', () => {
cy.get('[data-test^="Color-"]').should('exist').and('be.visible');
});
When('I select {string} quantities', (quantity: string) => {
cy.getBySel('cart-quantity-plus-button').click();
Array.from({ length: parseInt(quantity) }).forEach(() => {});
});
Then(
'the cart should contain {string} items of {string}',
(quantity: string, productName: string) => {
cy.getBySel('cart-product-container')
.invoke('text')
.then((productProperty) => {
expect(productProperty).contains(productName);
});
// Vérifier que la quantité affichée correspond à la quantité sélectionnée
cy.getBySel('cart-product-quantity').should('have.text', quantity);
}
);
Then('the product should be added to my shopping cart', () => {
// Vérifie que le panier contient bien le produit
cy.getBySel('cart-open-button').invoke('text').should('equal', '1');
cy.getBySel('cart-item').should('have.length', 1);
});
Then(
'the total in the shopping cart should be updated to {string} {string}',
(amount: string, codeCurrency: string) => {
cy.get('[data-test="cart-side-container"] [data-test="price-amount"]')
.should('exist')
.and('be.visible')
.invoke('text')
.then((text) => {
// Vérifier que le texte contient la devise
expect(text).to.include(codeCurrency);
});
cy.getAmount(
'[data-test="payment-information"] > :nth-child(3) > [data-test="price-amount"]',
amount
);
}
);
Then('the product should not be added to my shopping cart', () => {
cy.getBySel('cart-open-button').click();
cy.getBySel('cart-item').should('not.exist');
cy.getBySel('cart-empty')
.should('exist')
.and('be.visible')
.invoke('text')
.then((label) => {
expect(label).to.be.equal('Your cart is empty.');
});
cy.getBySel('cart-close-button').click();
});
Then('I Can not add the product in the shopping cart', () => {
cy.getBySel('add-to-cart').should('exist').and('be.visible').and('be.disabled');
});
Then('"Add to Cart" button should have the name "Out Of Stock"', () => {
cy.getBySel('add-to-cart')
.should('exist')
.invoke('text')
.then((label) => {
expect(label).to.equals('Out Of Stock');
});
});

View File

@ -0,0 +1,13 @@
Feature: Display products on homepage
As a customer
I want to see the list of available products with their names and prices
So that I can decide what to buy
Scenario: Display list of products on the homepage
Given I am on the homepage
When the page loads
Then I should see a list of products
And each product should have an image
And each product should have a name
And each product should have a price
And the price should be displayed in EUR with two decimal points

View File

@ -0,0 +1,41 @@
import { When, Then } from '@badeball/cypress-cucumber-preprocessor';
// Étape pour attendre le chargement de la page
When('the page loads', () => {
cy.get('main').should('exist').and('be.visible'); // Attend 2 secondes pour que la page soit complètement chargée
});
// Étape pour vérifier que la liste des produits est visible
Then('I should see a list of products', () => {
cy.getBySel('product-link').should('exist').and('be.visible'); // Vérifie que la liste des produits est visible
});
// Étape pour vérifier que chaque produit a une image
Then('each product should have an image', () => {
cy.getBySel('tile-image').each(($el) => {
cy.wrap($el).should('have.attr', 'src').and('not.be.empty'); // Vérifie que chaque produit a une image
});
});
// Étape pour vérifier que chaque produit a un nom
Then('each product should have a name', () => {
cy.getBySel('label-title-text').each(($el) => {
cy.wrap($el).should('not.be.empty'); // Vérifie que chaque produit a un nom
});
});
// Étape pour vérifier que chaque produit a un prix
Then('each product should have a price', () => {
cy.getBySel('price-amount').each(($el) => {
cy.wrap($el).should('not.be.empty'); // Vérifie que chaque produit a un prix
});
});
// Étape pour vérifier que le prix est au format EUR avec deux décimales
Then('the price should be displayed in EUR with two decimal points', () => {
cy.getBySel('price-amount').each(($el) => {
cy.wrap($el)
.invoke('text')
.should('match', /^[€$£]?(\d{1,3}(,\d{3})*|\d+)\.\d{2}[A-Z]{3}$/); // Vérifie le format EUR avec deux décimales
});
});

View File

@ -0,0 +1,7 @@
import { Given } from '@badeball/cypress-cucumber-preprocessor';
// Étape pour aller à la page d'accueil
Given('I am on the homepage', () => {
cy.visit('/');
});

182
cypress/e2e/header.cy.ts Normal file
View File

@ -0,0 +1,182 @@
import { DATA_TEST_SELECTORS } from 'cypress/utils/constants';
describe('Header Tests', () => {
beforeEach(() => {
cy.visit('/');
// Femrer la notification de NextJs
cy.get('body > main > section')
.find('button')
.should('exist')
.and('be.visible')
.and('not.be.disabled')
.trigger('click');
});
it('should display the correct grid items', () => {
cy.visit('/');
cy.getBySel('navbar').should('be.visible');
cy.getBySel('navbar-logo').should('be.visible');
cy.getBySel('navbar-logo').should('have.attr', 'href', '/');
cy.getBySel('navbar-logo').click();
cy.location('pathname').should('eq', '/');
cy.getBySel('search-input').click().type('{enter}');
cy.location('pathname').should('eq', '/search');
cy.getBySel('search-filter-item').each((item: JQuery<HTMLElement>) => {
const text = item.text();
if (text === 'Hydrogen') {
cy.wrap(item).click();
}
});
cy.location('pathname').should('eq', '/search/hydrogen');
});
it('users can add products to the cart with quantity eq 1 and another product with quantity eq 2', () => {
/*
Aller vers le premier produit que nous allons ajouter au panier
Vérifier que nous atterisson sur le bon chemin du produit
Vérifier qu'on récupére bien les élements suivants :
- Image
- Titre
- Description
*/
cy.get(DATA_TEST_SELECTORS.productCarouselSection.items(2).product.link).click();
cy.location('pathname').should('eq', '/product/the-inventory-not-tracked-snowboard');
cy.getBySel('product-page').should('exist');
cy.getBySel('product-details').should('exist');
cy.getBySel('product-details').within(() => {
cy.getBySel('product-image').should('exist');
});
cy.getBySel('product-description').should('exist');
cy.getBySel('product-title')
.invoke('text')
.then((title: string) => {
expect(title).to.equal('The Inventory Not Tracked Snowboard');
});
/*
Préparer l'interception de l'appel au serveur afin d'être sûr que l'action est fini avant denclencher une nouvelle
- Nous allons jouer le scénario suivant :
1. Nous allons prendre un seul premier produit et le mettre au panier et vérifier qu'il a :
- Une image
- Un nom produit
- Un prix avec devise
- Quantité attendu
2. Nous allons prendre un deuxième produit en double et vérifier les mêmes informations que le premier produit.
*/
cy.intercept('POST', '/product/the-inventory-not-tracked-snowboard').as('addItemToCart'); // Associer une requête réseau
cy.getBySel('submit-button').should('exist').invoke('text').should('equal', 'Add To Cart');
cy.wait(500);
cy.getBySel('submit-button').click(); // J'actionne l'ajout au panier'
cy.wait('@addItemToCart'); // Attendre que la requête réseau soit terminée
cy.get('[id^="headlessui-dialog-panel"]').should('exist').and('be.visible'); // Vérifier que la model est présente
// Vérification du produit
cy.getBySel('cart-item').should('exist').and('have.length', 1); // Vérifier qu'on un seul produit dans le panier'
cy.getBySel('cart-product-quantity').eq(0).invoke('text').should('equal', '1'); // Vérifier qu'on a pris un seul (quantité) produit'
/* Vérification de l'image qu'elle
- Existe
- Visible
- est chargée
- Défini pour l'accessibilité
*/
cy.getBySel('cart-product-image')
.should('exist') // Vérifie que l'élément existe
.and('be.visible') // Vérifie que l'élément est visible
.then(($img) => {
// Vérifier que l'élément est bien une image
const img = $img[0] as HTMLImageElement;
// Utiliser 'expect' pour vérifier les propriétés de l'image
expect(img).to.have.property('naturalWidth').that.is.greaterThan(0); // Vérifier la largeur naturelle
expect(img).to.have.property('naturalHeight').that.is.greaterThan(0); // Vérifier la hauteur naturelle
expect(img).to.have.property('width').that.is.greaterThan(0); // Vérifier la largeur naturelle
expect(img).to.have.property('height').that.is.greaterThan(0); // Vérifier la hauteur naturelle
expect(img.src).to.not.be.empty; // Vérifier que l'URL de l'image n'est pas vide
expect(img.alt).to.not.be.empty; // Vérifier que l'attribut alt n'est pas vide
});
// Fermer la modal et vérifier qu'il y a 5 images'
cy.getBySel('cart-close-button').click(); // Fermer la modal pour pouvoir séléctionner les produits en dehors
cy.getBySel('product-related').within((product) => {
// Séléctionner la section aux produits liés
cy.wrap(product).find('h2').invoke('text').should('equal', 'Related Products'); // Vérifier le titre des produits présentés
cy.getBySel('product-item').should('have.length', 5);
cy.getBySel('tile-image')
.should('exist')
.and('be.visible')
.then(($img) => {
const img = $img[0] as HTMLImageElement;
// Utiliser 'expect' pour vérifier les propriétés de l'image
expect(img).to.have.property('naturalWidth').that.is.greaterThan(0); // Vérifier la largeur naturelle
expect(img).to.have.property('naturalHeight').that.is.greaterThan(0); // Vérifier la hauteur naturelle
expect(img).to.have.property('width').that.is.greaterThan(0); // Vérifier la largeur naturelle
expect(img).to.have.property('height').that.is.greaterThan(0); // Vérifier la hauteur naturelle
expect(img.src).to.not.be.empty; // Vérifier que l'URL de l'image n'est pas vide
expect(img.alt).to.not.be.empty; // Vérifier que l'attribut alt n'est pas vide
});
});
cy.getBySel('product-item-link').eq(2).click();
cy.intercept('POST', '/product/the-collection-snowboard-oxygen').as('addItem2ToCart'); // Associer une requête réseau
cy.getBySel('submit-button').should('exist').invoke('text').should('equal', 'Add To Cart');
cy.wait(100);
cy.getBySel('submit-button').click(); // J'actionne l'ajout au panier'
cy.wait('@addItem2ToCart'); // Attendre que la requête réseau soit terminée
cy.getBySel('cart-side-container').should('exist').and('be.visible'); // Vérifier que la model est présente
// Vérification du produit
cy.getBySel('cart-item').should('exist').and('have.length', 2); // Vérifier qu'on un seul produit dans le panier'
// cy.getBySel('cart-quantity-plus-button').eq(1).click().click(); // Augmenter la quantité de 2
cy.getBySel('cart-quantity-plus-button').eq(1).click().click();
cy.getBySel('cart-product-quantity')
.should('exist')
.and('be.visible')
.eq(1)
.invoke('text')
.should('equal', '3'); // Vérifier qu'on a une quantité de 3'
cy.getBySel('cart-quantity-minus-button').eq(1).click(); // Diminier la quantité de 1
cy.getBySel('cart-product-quantity')
.should('exist')
.and('be.visible')
.eq(1)
.invoke('text')
.should('equal', '2'); // Vérifier qu'on a une quantité de 2'
// Vérifier le montant total avec le checkout
cy.getAmount(
'[data-test="payment-information"] > :nth-child(3) > [data-test="price-amount"]',
'2924,90',
'€',
'EUR'
);
cy.getBySel('checkout-button').click();
cy.origin('https://testify-automation.myshopify.com/password', () => {
cy.url().should('equal', 'https://testify-automation.myshopify.com/password');
cy.get('#password').type('skifro');
// cy.get('button').click();
});
});
it.only('Vérifier les cookies', () => {
cy.getAllCookies().then((cookies) => {
if (cookies.length > 0) {
cookies.forEach((cookie) => {
cy.log(`Nom du cookie : ${cookie.name}`);
cy.log(`Valeur du cookie : ${cookie.value}`);
console.log(`Nom du cookie : ${cookie.name}`);
console.log(`Valeur du cookie : ${cookie.value}`);
});
// Tu peux accéder à ses propriétés comme `cookie.name`, `cookie.value`, etc.}
} else {
cy.log('Pas de cookie trouvés');
}
});
});
});

View File

@ -1,5 +0,0 @@
describe('Home Page', () => {
it('displays all 3 products on the home page', () => {
cy.visit('/');
});
});

View File

@ -0,0 +1,41 @@
import { DATA_TEST_SELECTORS } from 'cypress/utils/constants';
describe('Three Item Grid Tests', () => {
beforeEach(() => {
// Visiting the home page
cy.visit('/');
});
// List all selector that will be used for the home page
const threeGridItem = () => cy.get(DATA_TEST_SELECTORS.productGridSection.items().container);
const fullSizeGridItem = () =>
cy.get(DATA_TEST_SELECTORS.productGridSection.items('full', 0).container);
// First test case: checks if the grid displays the correct items
it('Verify all grid item', () => {
threeGridItem().should('exist').and('be.visible').and('have.length', 3);
// Checking if the first 'full size' grid item exists
fullSizeGridItem().should('exist');
// Verifying the price of the first full-size grid item (good to verify both amount and currency)
const fullSizeGridItemPrice = DATA_TEST_SELECTORS.productGridSection.items('full', 0).product
.price.amount;
cy.verifyProductPrice(
fullSizeGridItemPrice,
'€', // Currency symbol to be checked
'749.95', // Expected price amount
'EUR' // Expected currency code
);
});
// Second test case: checks if the user can navigate to the correct product page
it('should navigate to the correct product page', () => {
// Clicking the first product link in the product carousel section
cy.get(DATA_TEST_SELECTORS.productCarouselSection.items(0).product.link).click();
// Asserting that the correct product page is loaded by checking the URL
cy.url().should('include', '/product/');
cy.url().should('include', '/product/the-collection-snowboard-oxygen');
});
});

View File

@ -1,5 +1,61 @@
{ {
"name": "Using fixtures to represent data", "collections": [
"email": "hello@cypress.io", "Automated Collection",
"body": "Fixtures are a great way to mock data for responses to routes" "hidden-homepage-carousel",
"hidden-homepage-featured-items",
"Home page",
"Hydrogen"
],
"products": [
{
"published": true,
"name": "Selling Plans Ski Wax",
"grams": 56.69904625,
"price": 24.95,
"gift_card": false,
"image_src": "https://cdn.shopify.com/s/files/1/0841/2905/5048/files/snowboard_wax.png?v=1724052616",
"variant_weight_unit": "oz",
"status": "active"
},
{
"published": null,
"name": "Special Selling Plans Ski Wax",
"grams": 70.8738078125,
"price": 49.95,
"gift_card": null,
"image_src": "https://cdn.shopify.com/s/files/1/0841/2905/5048/files/wax-special.png?v=1724052616",
"variant_weight_unit": "oz",
"status": null
},
{
"published": null,
"name": "Sample Selling Plans Ski Wax",
"grams": 14.1747615625,
"price": 9.95,
"gift_card": null,
"image_src": "https://cdn.shopify.com/s/files/1/0841/2905/5048/files/sample-normal-wax.png?v=1724052616",
"variant_weight_unit": "oz",
"status": null
},
{
"published": true,
"name": "Default Title",
"grams": 0.0,
"price": 749.95,
"gift_card": false,
"image_src": "https://cdn.shopify.com/s/files/1/0841/2905/5048/files/Main_b13ad453-477c-4ed1-9b43-81f3345adfd6.jpg?v=1724052614",
"variant_weight_unit": "kg",
"status": "active"
},
{
"published": true,
"name": "Default Title",
"grams": 0.0,
"price": 1025.0,
"gift_card": false,
"image_src": "https://cdn.shopify.com/s/files/1/0841/2905/5048/files/Main_d624f226-0a89-4fe1-b333-0d1548b43c06.jpg?v=1724052611",
"variant_weight_unit": "kg",
"status": "active"
}
]
} }

View File

@ -0,0 +1,6 @@
{
"query": "query getProduct($productName: String!) { products(first: 1, query: $productName) { edges { node { title totalInventory variants(first: 10) { edges { node { title price { amount currencyCode } } } } } } } }",
"variables": {
"productName": ""
}
}

View File

@ -1,37 +1,62 @@
/// <reference types="cypress" /> import { destructPrice, omitSubstrings as getFloat } from '../utils/price';
// ***********************************************
// This example commands.ts shows you how to declare global {
// create various custom commands and overwrite namespace Cypress {
// existing commands. interface Chainable {
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void> // login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element> // drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element> // dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element> // visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// } getBySel(
// } selector: string,
// } ...args: Partial<
Cypress.Loggable & Cypress.Timeoutable & Cypress.Withinable & Cypress.Shadow
>[]
): Chainable<JQuery<HTMLElement>>;
verifyProductPrice(
selector: string,
expectedCurrencySymbol: string,
expectedPrice: string,
expectedCurrencyCode: string
): Chainable<void>;
getAmount(selector: string, priceExpected: string, ...substrings: string[]): Chainable<void>;
}
}
}
Cypress.Commands.add('getBySel', (selector, ...args) => {
return cy.get(`[data-test="${selector}"]`, ...args);
});
Cypress.Commands.add(
'verifyProductPrice',
(
selector: string,
expectedCurrencySymbol: string,
expectedPrice: string,
expectedCurrencyCode: string
) => {
cy.get(selector)
.should('be.visible')
.invoke('text')
.then((amount) => {
const { currencySymbol, price, currencyCode } = destructPrice(amount);
expect(currencySymbol).to.equal(expectedCurrencySymbol);
expect(price).to.equal(expectedPrice.toString());
expect(currencyCode).to.equal(expectedCurrencyCode);
});
}
);
Cypress.Commands.add('getAmount', (selector: string, amountExpected: string) => {
cy.get(selector)
.should('exist')
.and('be.visible')
.invoke('text')
.then((text) => {
const amount = text.replace(/[^\d,\.]/g, '');
expect(amount).to.be.equal(amountExpected);
});
});

View File

@ -14,7 +14,7 @@
// *********************************************************** // ***********************************************************
// Import commands.js using ES2015 syntax: // Import commands.js using ES2015 syntax:
import './commands' import './commands';
// Alternatively you can use CommonJS syntax: // Alternatively you can use CommonJS syntax:
// require('./commands') // require('./commands')

View File

@ -0,0 +1,7 @@
import { Given } from '@badeball/cypress-cucumber-preprocessor';
// Étape pour aller à la page d'accueil
Given('I am on the homepage', () => {
cy.visit('/');
});

View File

@ -0,0 +1,59 @@
//---------------------------------------------------------------------------------------------------------------------------------------------------------------\\
/**
* Generates test identifiers for a grid item, reflecting the product elements.
* @param itemSize - The size of the grid item ('full' or 'half').
* @param itemIndex - Optional index of the item in the grid.
* @returns An object containing test identifiers mapped to product elements in the grid.
*/
function generateGridItemTestIds(itemSize?: 'full' | 'half' | undefined, itemIndex?: number) {
const size = itemSize !== undefined ? `-${itemSize}` : '';
const suffix = itemIndex !== undefined ? `-${itemIndex}` : '';
const itemSelector = `[data-test^="grid-item${size}${suffix}"]`;
return {
container: itemSelector,
product: {
link: `${itemSelector} > [data-test="product-link"]`,
image: `${itemSelector} > [data-test="product-link"] > [data-test="tile-container"] > [data-test="tile-image"]`,
name: `${itemSelector} > [data-test="product-link"] > [data-test="tile-container"] > [data-test="label-container"] > [data-test="label-content-wrapper"] > [data-test="label-title-text"]`,
price: {
amount: `${itemSelector} > [data-test="product-link"] > [data-test="tile-container"] > [data-test="label-container"] > [data-test="label-content-wrapper"] > [data-test="price-amount"]`,
currencyCode: `${itemSelector} > [data-test="product-link"] > [data-test="tile-container"] > [data-test="label-container"] > [data-test="label-content-wrapper"] > [data-test="price-currency-code"]`
}
}
};
}
/**
* Generates test identifiers for a carousel item, reflecting the product elements.
* @param itemIndex - The index of the item in the carousel.
* @returns An object containing test identifiers mapped to product elements in the carousel.
*/
function generateCarouselItemTestIds(itemIndex: number) {
const itemSelector = `[data-test="carousel-item-${itemIndex}"]`;
return {
container: itemSelector,
product: {
link: `${itemSelector} > [data-test="product-link"]`,
image: `${itemSelector} > [data-test="tile-container"] > [data-test="tile-image"]`,
name: `${itemSelector} > [data-test="tile-container"] > [data-test="label-container"] > [data-test="label-content-wrapper"] > [data-test="label-title-text"]`,
price: {
amount: `${itemSelector} > [data-test="tile-container"] > [data-test="label-container"] > [data-test="label-content-wrapper"] > [data-test="price-amount"]`,
currencyCode: `${itemSelector} > [data-test="tile-container"] > [data-test="label-container"] > [data-test="label-content-wrapper"] > [data-test="price-currency-code"]`
}
}
};
}
export const DATA_TEST_SELECTORS = {
productGridSection: {
container: '[data-test="three-item-grid"]',
items: (size?: 'full' | 'half' | undefined, index?: number) =>
generateGridItemTestIds(size, index)
},
productCarouselSection: {
container: '[data-test="carousel-container"]',
items: (index: number) => generateCarouselItemTestIds(index)
}
} as const;

19
cypress/utils/price.ts Normal file
View File

@ -0,0 +1,19 @@
export const destructPrice = (
amount: string
): { currencySymbol?: string; price?: string; currencyCode?: string } => {
const currencySymbol = amount.substring(0, 1);
const price = amount.substring(1, amount.length - 3);
const currencyCode = amount.slice(-3);
return { currencySymbol, price, currencyCode };
};
export const omitSubstrings = (str: string, ...substrings: string[]): string => {
let result = str;
substrings.forEach((substring) => {
const regex = new RegExp(substring, 'g');
result = result.replace(regex, '');
});
return result.trim();
};

View File

16
cypress/utils/url.ts Normal file
View File

@ -0,0 +1,16 @@
export function toSlug(input: string): string {
// 1. Convertir la chaîne en minuscules
let slug = input.toLowerCase();
// 2. Remplacer les caractères accentués ou spéciaux par leurs équivalents non accentués
slug = slug.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
// 3. Remplacer tous les caractères non alphanumériques par des tirets
slug = slug.replace(/[^a-z0-9]+/g, '-');
// 4. Supprimer les tirets de début ou de fin
return slug.replace(/^-+|-+$/g, '');
}
export const verifyRequest = (alias: string) => {
cy.wait(alias).its('response.statusCode').should('eq', 200);
};

10
docs/tests/homepage.md Normal file
View File

@ -0,0 +1,10 @@
Feature: Affichage des produits sur la page d'accueil
Scenario: Vérification de l'affichage des produits avec nom, image et prix
Given I am on the homepage
When the page is loaded
Then I should see a list of products
And each product should have an image
And each product should have a name
And each product should have a price
And the price should be displayed in EUR with two decimal points

99
docs/tests/test-design.md Normal file
View File

@ -0,0 +1,99 @@
# Conception des Tests
## Hiérarchie des Composants et Attributs Data-Test
### Home page
#### ThreeItemGrid Component
Voici un schéma Mermaid qui représente les composants et les hiérarchies des attributs data-test pour le composant `ThreeItemGrid`.
```mermaid
%%{init: {'theme': 'dark'}}%%
graph TD
%% Using a darker green (#339966) with white text (#fff) for better contrast
classDef grid-item-class fill:#339966,stroke:#aaa,stroke-width:2px,color:#fff;
%% Dark blue (#336699) with white text (#fff) for containers
classDef container-class fill:#336699,stroke:#aaa,stroke-width:2px,color:#fff;
%% Dark red (#cc3333) with white text (#fff) for React components
classDef react-component-class fill:#cc3333,stroke:#aaa,stroke-width:2px,color:#fff;
%% Subgraph representing the overall grid structure
subgraph ThreeItemGrid
style ThreeItemGrid fill:#333,stroke:#aaa,stroke-width:2px;
%% Grid Item with configurable size
ThreeItemGridItem[⚛️ Three Item Grids]
class ThreeItemGridItem react-component-class
GridItem[🗂️ data-test: three-item-grid]
ThreeItemGridItem --> GridItem
class GridItem container-class
GridItemSize[🔍 data-test: grid-item <br>⬛ full / ⬜ half]
GridItem --> GridItemSize
class GridItemSize grid-item-class
%% Grid Product Link for GridItemSize
GridItemSize --> GridProductLink[🔍 data-test: grid-product-link]
class GridProductLink grid-item-class
GridProductLink --> GridTileImage[⚛️ GridTileImage]
class GridTileImage react-component-class
GridTileImage --> GridTileContainer[🗂️ data-test: grid-tile-container]
class GridTileContainer container-class
GridTileContainer --> GridTileImageItem[🔍 data-test: grid-tile-image]
class GridTileImageItem grid-item-class
GridTileContainer --> Label[⚛️ Label]
class Label react-component-class
Label --> LabelContainer[🗂️ data-test: label-container]
class LabelContainer container-class
LabelContainer --> LabelContentWrapper[🗂️ data-test: label-content-wrapper]
class LabelContentWrapper container-class
LabelContentWrapper --> LabelTitleText[🔍 data-test: label-title-text]
class LabelTitleText grid-item-class
LabelContentWrapper --> Price[⚛️ Price]
class Price react-component-class
Price --> PriceAmount[🔍 data-test: price-amount]
class PriceAmount grid-item-class
PriceAmount --> PriceCurrencyCode[🔍 data-test: price-currency-code]
class PriceCurrencyCode grid-item-class
end
%% Adding explanatory notes as special nodes
Note1[Note: This component represents a set of products displayed in a grid format.]
Note2[Note: This is the main container of the grid that encompasses all product elements.]
Note3["Note: Defines the size of the item in the grid. Two sizes are possible:
⬛ full or ⬜ half."]
Note4[Note: Link to the specific product detail page.]
Note5[Note: Component that displays the main image of the product in the grid.]
Note6[Note: Container for the product image, labels, and other information.]
Note7[Note: Component to display the textual information of the product, such as the title and price.]
Note8[Note: Displays the product name in the grid.]
Note9[Note: Displays the product price amount with the currency.]
ThreeItemGridItem --> Note1
GridItem --> Note2
GridItemSize --> Note3
GridProductLink --> Note4
GridTileImage --> Note5
GridTileContainer --> Note6
Label --> Note7
LabelTitleText --> Note8
PriceAmount --> Note9
```
[![Grid Product Display](https://mermaid.ink/img/pako:eNqdV92K4zYUfhXhJUwX4rTO3yZuWVhmoCzslqWTUuh4LhRbTsTYkpHkzmSHuel92ZuFQrfQF9gH6PPMC7SP0CPJVvyTZGiHYSbROef7Pp1zrCPfezFPiBd6g8E9ZVSF6P5MbUlOzkJ0lmBxc_bwMBhELGIbgYstWl3oz4MB-kFStkEYaR8i0EYQwtAXzyaT5XI-f45uqdqi2y1VBClyp8CSpulzlHKB1kQpiIg5UwJLFbE4w1JekBRAaOJDSO6bJZTSLAsryKFUgt-Q8BnGuPrs39JEbcNxcTeMecZFqDm-rvRdgC60zkpiRM3ny-VJUVoNpowI2dDjFjt6NNr_0iNIAqRxPIGfk3K-JzhWwJ8XnBGmmqKENvnO1JJmkf-rtMtybYsrSCGIBFBdWugCxH8mAmeZKQwCpDJWpSARk3XEagt1fw0V-xY8IobgR6pdRtoGl7jT0oweDQGaTJiOt1mCSqR0Uwq8BmxJ3xPr2GLR_68ef__0918frMHGa5O8tv42V72owzmt1Tjsf_787RcNnmCFfUUkPCxKQ9mW1Tm6PiIL-f5Lh9OU4hw6rdblvoQtA__HX5vk7nFB36zFy8fPn1BaQrG-RI-f_0BbnKXXbZCWikuXxLYSvd59ELt1eSd4Aq2A3lB2Y9q1D9qCq3mrOB12eDOFdfAz8LjuqmtEHxPYdauJVzQjr3O8IXV_tBZ7TM7ydGPsXZtc53U1D_WM0a7Ay3dFP6jAgZxqjrZjb79V3x5ItRFAT2_fNM2JVPfJ3-A1yeokmy8tdLNyOqnWxWGdzGSmPY5ksR19LIUdrxYtSPsRDrniKW69hVvreFjBHuhJGQ1Xp2VFVUZWMCB6hbQalLb7eoL06V3ssTIeY34naOyeFvOlBW5WThfSujisVzkvWX8Lhbb52Bj7DDbomPami-M5L4UgLN6dw7XmCFtcufj66tMnbSL0qQlLqrH5Kkn0nCR3RYYZVlzsEOPAg7BEsiAxxRksJASCvoP14Er_DWE4ULmf7PuZC3FIEoV4iqpjUKKESgDfwa2BMjCbMQznbY7V6Nqijpuo8Kundg4dtu80DahXTbDaYoVgc0APu9FaYWBUdIhkcO8DITX25CryLDrcOwDKouvpW2Oa4QNkNf4IrW658QBkQVDBpaQwskPkphNMi3o8jSKvYppWuzCHtuKWR6cwpbGTlxDYEKiFg6lWOKvizl06zQartDWyYQ66WnUN2BReAc4dYJ09Pd5aQRppaB89OUSYJYiDXQCarQzlrEZ70ZfHa3EGVD-0JbRJI7YjcohkGW91Sxl__TgbTtPJNc-i4rlo7rsWzHBODm11eSrIwCP7WNobmLbWD46GiNjhW45p9Yj11sb7NXcnME1m17tT23SFNbVHrCn73tA-u00JI7afIaYK1cL-LKwti4h1zxCTGW_o5QTqQRN4MbrXB0TkmZeiyAvhIyMlvLlkkRexB3DFpeKXOxZ7IVyQydATvNxsvTDFmYRvZQHnD7mgGC7MuVstMPuJ87wOIQmF8-OtfRMzL2TGxQvvvTsvnM1HwXwcTCazxXQ8nwXjobfzwmA8G71YLMdfzaaz6XQ5DsYPQ--9AQ1GwXKynC4XwWIeBMF09vAvo7n2MQ?type=png)](https://mermaid-js.github.io/mermaid-live-editor/edit#pako:eNqdV92K4zYUfhXhJUwX4rTO3yZuWVhmoCzslqWTUuh4LhRbTsTYkpHkzmSHuel92ZuFQrfQF9gH6PPMC7SP0CPJVvyTZGiHYSbROef7Pp1zrCPfezFPiBd6g8E9ZVSF6P5MbUlOzkJ0lmBxc_bwMBhELGIbgYstWl3oz4MB-kFStkEYaR8i0EYQwtAXzyaT5XI-f45uqdqi2y1VBClyp8CSpulzlHKB1kQpiIg5UwJLFbE4w1JekBRAaOJDSO6bJZTSLAsryKFUgt-Q8BnGuPrs39JEbcNxcTeMecZFqDm-rvRdgC60zkpiRM3ny-VJUVoNpowI2dDjFjt6NNr_0iNIAqRxPIGfk3K-JzhWwJ8XnBGmmqKENvnO1JJmkf-rtMtybYsrSCGIBFBdWugCxH8mAmeZKQwCpDJWpSARk3XEagt1fw0V-xY8IobgR6pdRtoGl7jT0oweDQGaTJiOt1mCSqR0Uwq8BmxJ3xPr2GLR_68ef__0918frMHGa5O8tv42V72owzmt1Tjsf_787RcNnmCFfUUkPCxKQ9mW1Tm6PiIL-f5Lh9OU4hw6rdblvoQtA__HX5vk7nFB36zFy8fPn1BaQrG-RI-f_0BbnKXXbZCWikuXxLYSvd59ELt1eSd4Aq2A3lB2Y9q1D9qCq3mrOB12eDOFdfAz8LjuqmtEHxPYdauJVzQjr3O8IXV_tBZ7TM7ydGPsXZtc53U1D_WM0a7Ay3dFP6jAgZxqjrZjb79V3x5ItRFAT2_fNM2JVPfJ3-A1yeokmy8tdLNyOqnWxWGdzGSmPY5ksR19LIUdrxYtSPsRDrniKW69hVvreFjBHuhJGQ1Xp2VFVUZWMCB6hbQalLb7eoL06V3ssTIeY34naOyeFvOlBW5WThfSujisVzkvWX8Lhbb52Bj7DDbomPami-M5L4UgLN6dw7XmCFtcufj66tMnbSL0qQlLqrH5Kkn0nCR3RYYZVlzsEOPAg7BEsiAxxRksJASCvoP14Er_DWE4ULmf7PuZC3FIEoV4iqpjUKKESgDfwa2BMjCbMQznbY7V6Nqijpuo8Kundg4dtu80DahXTbDaYoVgc0APu9FaYWBUdIhkcO8DITX25CryLDrcOwDKouvpW2Oa4QNkNf4IrW658QBkQVDBpaQwskPkphNMi3o8jSKvYppWuzCHtuKWR6cwpbGTlxDYEKiFg6lWOKvizl06zQartDWyYQ66WnUN2BReAc4dYJ09Pd5aQRppaB89OUSYJYiDXQCarQzlrEZ70ZfHa3EGVD-0JbRJI7YjcohkGW91Sxl__TgbTtPJNc-i4rlo7rsWzHBODm11eSrIwCP7WNobmLbWD46GiNjhW45p9Yj11sb7NXcnME1m17tT23SFNbVHrCn73tA-u00JI7afIaYK1cL-LKwti4h1zxCTGW_o5QTqQRN4MbrXB0TkmZeiyAvhIyMlvLlkkRexB3DFpeKXOxZ7IVyQydATvNxsvTDFmYRvZQHnD7mgGC7MuVstMPuJ87wOIQmF8-OtfRMzL2TGxQvvvTsvnM1HwXwcTCazxXQ8nwXjobfzwmA8G71YLMdfzaaz6XQ5DsYPQ--9AQ1GwXKynC4XwWIeBMF09vAvo7n2MQ)

View File

@ -214,6 +214,7 @@ export async function addToCart(
cartId: string, cartId: string,
lines: { merchandiseId: string; quantity: number }[] lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> { ): Promise<Cart> {
try {
const res = await shopifyFetch<ShopifyAddToCartOperation>({ const res = await shopifyFetch<ShopifyAddToCartOperation>({
query: addToCartMutation, query: addToCartMutation,
variables: { variables: {
@ -222,7 +223,13 @@ export async function addToCart(
}, },
cache: 'no-store' cache: 'no-store'
}); });
console.log('Response from Shopify:', res.status); // Log après réception de la réponse
return reshapeCart(res.body.data.cartLinesAdd.cart); return reshapeCart(res.body.data.cartLinesAdd.cart);
} catch (error) {
console.error('Error during add to cart:', error); // Log en cas d'erreur
throw new Error('Failed to add item to cart');
}
} }
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> { export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {

View File

@ -10,32 +10,32 @@
"start": "next start", "start": "next start",
"prettier": "prettier --write --ignore-unknown .", "prettier": "prettier --write --ignore-unknown .",
"prettier:check": "prettier --check --ignore-unknown .", "prettier:check": "prettier --check --ignore-unknown .",
"test": "pnpm prettier:check", "test": "pnpm prettier:check"
"cypress:open": "cypress open",
"cypress:run": "cypress run"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.1.2", "@headlessui/react": "^2.1.2",
"@heroicons/react": "^2.1.5", "@heroicons/react": "^2.1.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"geist": "^1.3.1", "geist": "^1.3.1",
"next": "15.0.0-canary.113", "next": "15.0.0-canary.155",
"react": "19.0.0-rc-3208e73e-20240730", "react": "19.0.0-rc-206df66e-20240912",
"react-dom": "19.0.0-rc-3208e73e-20240730", "react-dom": "19.0.0-rc-206df66e-20240912",
"sonner": "^1.5.0" "sonner": "^1.5.0"
}, },
"devDependencies": { "devDependencies": {
"@badeball/cypress-cucumber-preprocessor": "^20.1.2",
"@bahmutov/cypress-esbuild-preprocessor": "^2.2.3",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"@types/node": "20.14.12", "@types/node": "22.5.5",
"@types/react": "18.3.3", "@types/react": "18.3.5",
"@types/react-dom": "18.3.0", "@types/react-dom": "18.3.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"cypress": "^13.13.3", "cypress": "^13.14.2",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"prettier": "3.3.3", "prettier": "3.3.3",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.6", "tailwindcss": "^3.4.6",
"typescript": "5.5.4" "typescript": "5.6.2"
} }
} }

2638
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2015", "target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["ES2022", "dom"],
"downlevelIteration": true, "downlevelIteration": true,
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
@ -9,20 +9,27 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node", "moduleResolution": "node",
"module": "esnext",
"resolveJsonModule": true, "resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"baseUrl": ".", "baseUrl": ".",
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"types": ["cypress", "node", "@types/node", "@badeball/cypress-cucumber-preprocessor"],
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
} }
],
"paths": {
"@badeball/cypress-cucumber-preprocessor/*": [
"./node_modules/@badeball/cypress-cucumber-preprocessor/dist/subpath-entrypoints/*"
] ]
}
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "cypress/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }