mirror of
https://github.com/vercel/commerce.git
synced 2025-05-08 10:47:51 +00:00
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:
parent
f22b1ead60
commit
5a17eaf211
7
.cypress-cucumber-preprocessorrc.json
Normal file
7
.cypress-cucumber-preprocessorrc.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"stepDefinitions": [
|
||||
"cypress/e2e/**/[filepath].ts",
|
||||
"cypress/e2e/common-step-definitions/*.ts",
|
||||
"cypress/support/step_definitions/*.ts"
|
||||
]
|
||||
}
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -36,3 +36,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
#cursor
|
||||
.cursorrules
|
@ -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
|
||||
|
||||
- [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.
|
||||
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
|
||||
|
||||
|
85
Test-Design.md
Normal file
85
Test-Design.md
Normal 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-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)
|
@ -80,9 +80,12 @@ export default async function ProductPage({ params }: { params: { handle: string
|
||||
__html: JSON.stringify(productJsonLd)
|
||||
}}
|
||||
/>
|
||||
<div className="mx-auto max-w-screen-2xl px-4">
|
||||
<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 className="h-full w-full basis-full lg:basis-4/6">
|
||||
<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"
|
||||
data-test="product-details"
|
||||
>
|
||||
<div className="h-full w-full basis-full lg:basis-4/6" data-test="product-image">
|
||||
<Suspense
|
||||
fallback={
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="basis-full lg:basis-2/6">
|
||||
<div className="basis-full lg:basis-2/6" data-test="product-description">
|
||||
<Suspense fallback={null}>
|
||||
<ProductDescription product={product} />
|
||||
</Suspense>
|
||||
@ -116,18 +119,20 @@ async function RelatedProducts({ id }: { id: string }) {
|
||||
if (!relatedProducts.length) return null;
|
||||
|
||||
return (
|
||||
<div className="py-8">
|
||||
<div className="py-8" data-test="product-related">
|
||||
<h2 className="mb-4 text-2xl font-bold">Related Products</h2>
|
||||
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
|
||||
{relatedProducts.map((product) => (
|
||||
<li
|
||||
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"
|
||||
data-test="product-item"
|
||||
>
|
||||
<Link
|
||||
className="relative h-full w-full"
|
||||
href={`/product/${product.handle}`}
|
||||
prefetch={true}
|
||||
data-test="product-item-link"
|
||||
>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
|
@ -12,14 +12,19 @@ export async function Carousel() {
|
||||
const carouselProducts = [...products, ...products, ...products];
|
||||
|
||||
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">
|
||||
{carouselProducts.map((product, i) => (
|
||||
<li
|
||||
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"
|
||||
data-test={`carousel-item-${i}`}
|
||||
>
|
||||
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
|
||||
<Link
|
||||
href={`/product/${product.handle}`}
|
||||
className="relative h-full w-full"
|
||||
data-test="product-link"
|
||||
>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
@ -30,6 +35,7 @@ export async function Carousel() {
|
||||
src={product.featuredImage?.url}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
|
||||
data-test="tile-image"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
|
@ -8,15 +8,20 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
export async function addItem(prevState: any, selectedVariantId: string | undefined) {
|
||||
let cartId = cookies().get('cartId')?.value;
|
||||
console.log('Cart ID before adding item:', cartId);
|
||||
|
||||
if (!cartId || !selectedVariantId) {
|
||||
console.error('Cart ID or selected variant ID is missing');
|
||||
return 'Error adding item to cart';
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Attempting to add item to Shopify cart');
|
||||
await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
|
||||
console.log('Item added to Shopify cart successfully');
|
||||
revalidateTag(TAGS.cart);
|
||||
} catch (e) {
|
||||
console.error('Error adding item to cart:', e);
|
||||
return 'Error adding item to cart';
|
||||
}
|
||||
}
|
||||
|
@ -2,11 +2,10 @@
|
||||
|
||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { addItem } from 'components/cart/actions';
|
||||
import { useProduct } from 'components/product/product-context';
|
||||
import { Product, ProductVariant } from 'lib/shopify/types';
|
||||
import { useFormState } from 'react-dom';
|
||||
import { useCart } from './cart-context';
|
||||
import { addItem } from './actions';
|
||||
|
||||
function SubmitButton({
|
||||
availableForSale,
|
||||
@ -21,19 +20,20 @@ function SubmitButton({
|
||||
|
||||
if (!availableForSale) {
|
||||
return (
|
||||
<button disabled className={clsx(buttonClasses, disabledClasses)}>
|
||||
<button disabled className={clsx(buttonClasses, disabledClasses)} data-test="add-to-cart">
|
||||
Out Of Stock
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
console.log(selectedVariantId);
|
||||
// console.log(selectedVariantId);
|
||||
if (!selectedVariantId) {
|
||||
return (
|
||||
<button
|
||||
aria-label="Please select an option"
|
||||
disabled
|
||||
className={clsx(buttonClasses, disabledClasses)}
|
||||
data-test="add-to-cart"
|
||||
>
|
||||
<div className="absolute left-0 ml-4">
|
||||
<PlusIcon className="h-5" />
|
||||
@ -49,6 +49,7 @@ function SubmitButton({
|
||||
className={clsx(buttonClasses, {
|
||||
'hover:opacity-90': true
|
||||
})}
|
||||
data-test="add-to-cart"
|
||||
>
|
||||
<div className="absolute left-0 ml-4">
|
||||
<PlusIcon className="h-5" />
|
||||
@ -62,27 +63,37 @@ export function AddToCart({ product }: { product: Product }) {
|
||||
const { variants, availableForSale } = product;
|
||||
const { addCartItem } = useCart();
|
||||
const { state } = useProduct();
|
||||
const [message, formAction] = useFormState(addItem, null);
|
||||
|
||||
// Trouver le variant correspondant à l'état actuel du produit
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()])
|
||||
);
|
||||
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
||||
const selectedVariantId = variant?.id || defaultVariantId;
|
||||
const actionWithVariant = formAction.bind(null, selectedVariantId);
|
||||
const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!;
|
||||
const defaultVariant = variants.length === 1 ? variants[0] : undefined;
|
||||
const selectedVariant = variant || defaultVariant;
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
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 (
|
||||
<form
|
||||
action={async () => {
|
||||
addCartItem(finalVariant, product);
|
||||
await actionWithVariant();
|
||||
}}
|
||||
>
|
||||
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
|
||||
<p aria-live="polite" className="sr-only" role="status">
|
||||
{message}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<SubmitButton
|
||||
availableForSale={availableForSale}
|
||||
selectedVariantId={selectedVariant?.id} // Utilisation de l'ID du variant dans le bouton
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
@ -50,9 +57,10 @@ function createOrUpdateCartItem(
|
||||
): CartItem {
|
||||
const quantity = existingItem ? existingItem.quantity + 1 : 1;
|
||||
const totalAmount = calculateItemCost(quantity, variant.price.amount);
|
||||
const itemId = variant.id ?? existingItem?.id;
|
||||
|
||||
return {
|
||||
id: existingItem?.id,
|
||||
id: itemId,
|
||||
quantity,
|
||||
cost: {
|
||||
totalAmount: {
|
||||
@ -131,6 +139,10 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
|
||||
}
|
||||
case 'ADD_ITEM': {
|
||||
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 updatedItem = createOrUpdateCartItem(existingItem, variant, product);
|
||||
|
||||
@ -156,11 +168,15 @@ export function CartProvider({
|
||||
const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer);
|
||||
|
||||
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
|
||||
updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType } });
|
||||
startTransition(() => {
|
||||
updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType } });
|
||||
});
|
||||
};
|
||||
|
||||
const addCartItem = (variant: ProductVariant, product: Product) => {
|
||||
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
|
||||
startTransition(() => {
|
||||
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
|
||||
});
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
|
@ -3,7 +3,7 @@
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { removeItem } from 'components/cart/actions';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import { useFormState } from 'react-dom';
|
||||
import { useActionState } from 'react';
|
||||
|
||||
export function DeleteItemButton({
|
||||
item,
|
||||
@ -12,7 +12,7 @@ export function DeleteItemButton({
|
||||
item: CartItem;
|
||||
optimisticUpdate: any;
|
||||
}) {
|
||||
const [message, formAction] = useFormState(removeItem, null);
|
||||
const [message, formAction] = useActionState(removeItem, null);
|
||||
const merchandiseId = item.merchandise.id;
|
||||
const actionWithVariant = formAction.bind(null, merchandiseId);
|
||||
|
||||
|
@ -4,7 +4,7 @@ import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { updateItemQuantity } from 'components/cart/actions';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import { useFormState } from 'react-dom';
|
||||
import { useActionState } from 'react';
|
||||
|
||||
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
||||
return (
|
||||
@ -17,6 +17,7 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
||||
'ml-auto': type === 'minus'
|
||||
}
|
||||
)}
|
||||
data-test={`cart-quantity-${type}-button`}
|
||||
>
|
||||
{type === 'plus' ? (
|
||||
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||
@ -36,7 +37,7 @@ export function EditItemQuantityButton({
|
||||
type: 'plus' | 'minus';
|
||||
optimisticUpdate: any;
|
||||
}) {
|
||||
const [message, formAction] = useFormState(updateItemQuantity, null);
|
||||
const [message, formAction] = useActionState(updateItemQuantity, null);
|
||||
const payload = {
|
||||
merchandiseId: item.merchandise.id,
|
||||
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||
|
@ -49,7 +49,7 @@ export default function CartModal() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button aria-label="Open cart" onClick={openCart}>
|
||||
<button aria-label="Open cart" onClick={openCart} data-test="cart-open-button">
|
||||
<OpenCart quantity={cart?.totalQuantity} />
|
||||
</button>
|
||||
<Transition show={isOpen}>
|
||||
@ -63,7 +63,11 @@ export default function CartModal() {
|
||||
leaveFrom="opacity-100 backdrop-blur-[.5px]"
|
||||
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
|
||||
as={Fragment}
|
||||
@ -74,10 +78,15 @@ export default function CartModal() {
|
||||
leaveFrom="translate-x-0"
|
||||
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">
|
||||
<p className="text-lg font-semibold">My Cart</p>
|
||||
<button aria-label="Close cart" onClick={closeCart}>
|
||||
<p className="text-lg font-semibold" data-test="cart-title">
|
||||
My Cart
|
||||
</p>
|
||||
<button aria-label="Close cart" onClick={closeCart} data-test="cart-close-button">
|
||||
<CloseCart />
|
||||
</button>
|
||||
</div>
|
||||
@ -85,7 +94,9 @@ export default function CartModal() {
|
||||
{!cart || cart.lines.length === 0 ? (
|
||||
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
|
||||
<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 className="flex h-full flex-col justify-between overflow-hidden p-1">
|
||||
@ -112,12 +123,13 @@ export default function CartModal() {
|
||||
<li
|
||||
key={i}
|
||||
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="absolute z-40 -ml-1 -mt-2">
|
||||
<DeleteItemButton item={item} optimisticUpdate={updateCartItem} />
|
||||
</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">
|
||||
<Image
|
||||
className="h-full w-full object-cover"
|
||||
@ -128,6 +140,7 @@ export default function CartModal() {
|
||||
item.merchandise.product.title
|
||||
}
|
||||
src={item.merchandise.product.featuredImage.url}
|
||||
data-test="cart-product-image"
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
@ -160,7 +173,12 @@ export default function CartModal() {
|
||||
optimisticUpdate={updateCartItem}
|
||||
/>
|
||||
<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>
|
||||
<EditItemQuantityButton
|
||||
item={item}
|
||||
@ -174,7 +192,10 @@ export default function CartModal() {
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
<p>Taxes</p>
|
||||
<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"
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
data-test="checkout-button"
|
||||
>
|
||||
{pending ? <LoadingDots className="bg-white" /> : 'Proceed to Checkout'}
|
||||
</button>
|
||||
|
@ -6,20 +6,24 @@ import Link from 'next/link';
|
||||
function ThreeItemGridItem({
|
||||
item,
|
||||
size,
|
||||
priority
|
||||
priority,
|
||||
index
|
||||
}: {
|
||||
item: Product;
|
||||
size: 'full' | 'half';
|
||||
priority?: boolean;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
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
|
||||
className="relative block aspect-square h-full w-full"
|
||||
href={`/product/${item.handle}`}
|
||||
prefetch={true}
|
||||
data-test="product-link"
|
||||
>
|
||||
<GridTileImage
|
||||
src={item.featuredImage.url}
|
||||
@ -52,10 +56,13 @@ export async function ThreeItemGrid() {
|
||||
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
||||
|
||||
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)]">
|
||||
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
|
||||
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
|
||||
<ThreeItemGridItem size="half" item={thirdProduct} />
|
||||
<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)]"
|
||||
data-test="three-item-grid"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ export function GridTileImage({
|
||||
'border-neutral-200 dark:border-neutral-800': !active
|
||||
}
|
||||
)}
|
||||
data-test="tile-container"
|
||||
>
|
||||
{props.src ? (
|
||||
<Image
|
||||
@ -34,6 +35,7 @@ export function GridTileImage({
|
||||
'transition duration-300 ease-in-out group-hover:scale-105': isInteractive
|
||||
})}
|
||||
{...props}
|
||||
data-test="tile-image"
|
||||
/>
|
||||
) : null}
|
||||
{label ? (
|
||||
|
@ -17,9 +17,18 @@ const 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'
|
||||
})}
|
||||
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">
|
||||
<h3 className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight">{title}</h3>
|
||||
<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"
|
||||
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
|
||||
className="flex-none rounded-full bg-blue-600 p-2 text-white"
|
||||
amount={amount}
|
||||
|
@ -15,7 +15,7 @@ export default async function Footer() {
|
||||
const copyrightName = COMPANY_NAME || SITE_NAME || '';
|
||||
|
||||
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>
|
||||
<Link className="flex items-center gap-2 text-black md:pt-1 dark:text-white" href="/">
|
||||
|
@ -13,7 +13,7 @@ export async function Navbar() {
|
||||
const menu = await getMenu('next-js-frontend-header-menu');
|
||||
|
||||
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">
|
||||
<Suspense fallback={null}>
|
||||
<MobileMenu menu={menu} />
|
||||
@ -25,6 +25,7 @@ export async function Navbar() {
|
||||
href="/"
|
||||
prefetch={true}
|
||||
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
|
||||
data-test="navbar-logo"
|
||||
>
|
||||
<LogoSquare />
|
||||
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">
|
||||
|
@ -36,6 +36,7 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||
onClick={openMobileMenu}
|
||||
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"
|
||||
data-test="mobile-menu-button"
|
||||
>
|
||||
<Bars3Icon className="h-4" />
|
||||
</button>
|
||||
|
@ -17,6 +17,7 @@ export default function Search() {
|
||||
autoComplete="off"
|
||||
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"
|
||||
data-test="search-input"
|
||||
/>
|
||||
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
||||
<MagnifyingGlassIcon className="h-4" />
|
||||
|
@ -26,6 +26,7 @@ function PathFilterItem({ item }: { item: PathFilterItem }) {
|
||||
'underline underline-offset-4': active
|
||||
}
|
||||
)}
|
||||
data-test="search-filter-item"
|
||||
>
|
||||
{item.title}
|
||||
</DynamicTag>
|
||||
|
@ -3,7 +3,7 @@ import clsx from 'clsx';
|
||||
const Price = ({
|
||||
amount,
|
||||
className,
|
||||
currencyCode = 'USD',
|
||||
currencyCode = 'EUR',
|
||||
currencyCodeClassName
|
||||
}: {
|
||||
amount: string;
|
||||
@ -11,13 +11,16 @@ const Price = ({
|
||||
currencyCode: string;
|
||||
currencyCodeClassName?: string;
|
||||
} & React.ComponentProps<'p'>) => (
|
||||
<p suppressHydrationWarning={true} className={className}>
|
||||
<p suppressHydrationWarning={true} className={className} data-test="price-amount">
|
||||
{`${new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
currencyDisplay: 'narrowSymbol'
|
||||
}).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>
|
||||
);
|
||||
|
||||
|
@ -8,8 +8,13 @@ export function ProductDescription({ product }: { product: Product }) {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white">
|
||||
<h1 className="mb-2 text-5xl font-medium" data-test="product-title">
|
||||
{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
|
||||
amount={product.priceRange.maxVariantPrice.amount}
|
||||
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
|
||||
|
@ -81,6 +81,7 @@ export function VariantSelector({
|
||||
!isAvailableForSale
|
||||
}
|
||||
)}
|
||||
data-test={`${option.name}-${value}`}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
|
@ -1,10 +1,28 @@
|
||||
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({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:3000',
|
||||
setupNodeEvents(on, config) {
|
||||
// implement node event listeners here
|
||||
}
|
||||
specPattern: '**/*.feature',
|
||||
setupNodeEvents
|
||||
}
|
||||
});
|
||||
|
23
cypress/e2e/common-step-definitions/common.ts
Normal file
23
cypress/e2e/common-step-definitions/common.ts
Normal 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');
|
||||
});
|
64
cypress/e2e/features/cart/add-to-cart.feature
Normal file
64
cypress/e2e/features/cart/add-to-cart.feature
Normal 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 |
|
209
cypress/e2e/features/cart/add-to-cart.ts
Normal file
209
cypress/e2e/features/cart/add-to-cart.ts
Normal 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');
|
||||
});
|
||||
});
|
0
cypress/e2e/features/cart/view-cart.feature
Normal file
0
cypress/e2e/features/cart/view-cart.feature
Normal file
0
cypress/e2e/features/checkout/checkout.feature
Normal file
0
cypress/e2e/features/checkout/checkout.feature
Normal file
0
cypress/e2e/features/checkout/checkout.ts
Normal file
0
cypress/e2e/features/checkout/checkout.ts
Normal file
13
cypress/e2e/features/product/product-display.feature
Normal file
13
cypress/e2e/features/product/product-display.feature
Normal 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
|
41
cypress/e2e/features/product/product-display.ts
Normal file
41
cypress/e2e/features/product/product-display.ts
Normal 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
|
||||
});
|
||||
});
|
0
cypress/e2e/features/product/search-product.feature
Normal file
0
cypress/e2e/features/product/search-product.feature
Normal file
0
cypress/e2e/features/user/user-login.feature
Normal file
0
cypress/e2e/features/user/user-login.feature
Normal file
7
cypress/e2e/features/user/user-login.ts
Normal file
7
cypress/e2e/features/user/user-login.ts
Normal 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
182
cypress/e2e/header.cy.ts
Normal 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"]',
|
||||
'2 924,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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@ -1,5 +0,0 @@
|
||||
describe('Home Page', () => {
|
||||
it('displays all 3 products on the home page', () => {
|
||||
cy.visit('/');
|
||||
});
|
||||
});
|
41
cypress/e2e/homepage.cy.ts
Normal file
41
cypress/e2e/homepage.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
@ -1,5 +1,61 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
"collections": [
|
||||
"Automated Collection",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
6
cypress/fixtures/product-query.json
Normal file
6
cypress/fixtures/product-query.json
Normal 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": ""
|
||||
}
|
||||
}
|
@ -1,37 +1,62 @@
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************
|
||||
// This example commands.ts shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// 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>
|
||||
// drag(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>
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
import { destructPrice, omitSubstrings as getFloat } from '../utils/price';
|
||||
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
// login(email: string, password: string): Chainable<void>
|
||||
// drag(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>
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@ -14,7 +14,7 @@
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands'
|
||||
import './commands';
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
// require('./commands')
|
||||
|
7
cypress/support/step_definitions/common.ts
Normal file
7
cypress/support/step_definitions/common.ts
Normal 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('/');
|
||||
});
|
59
cypress/utils/constants.ts
Normal file
59
cypress/utils/constants.ts
Normal 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
19
cypress/utils/price.ts
Normal 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();
|
||||
};
|
0
cypress/utils/selectors.ts
Normal file
0
cypress/utils/selectors.ts
Normal file
16
cypress/utils/url.ts
Normal file
16
cypress/utils/url.ts
Normal 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
10
docs/tests/homepage.md
Normal 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
99
docs/tests/test-design.md
Normal 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
|
||||
```
|
||||
|
||||
[](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)
|
@ -214,15 +214,22 @@ export async function addToCart(
|
||||
cartId: string,
|
||||
lines: { merchandiseId: string; quantity: number }[]
|
||||
): Promise<Cart> {
|
||||
const res = await shopifyFetch<ShopifyAddToCartOperation>({
|
||||
query: addToCartMutation,
|
||||
variables: {
|
||||
cartId,
|
||||
lines
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
return reshapeCart(res.body.data.cartLinesAdd.cart);
|
||||
try {
|
||||
const res = await shopifyFetch<ShopifyAddToCartOperation>({
|
||||
query: addToCartMutation,
|
||||
variables: {
|
||||
cartId,
|
||||
lines
|
||||
},
|
||||
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);
|
||||
} 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> {
|
||||
|
20
package.json
20
package.json
@ -10,32 +10,32 @@
|
||||
"start": "next start",
|
||||
"prettier": "prettier --write --ignore-unknown .",
|
||||
"prettier:check": "prettier --check --ignore-unknown .",
|
||||
"test": "pnpm prettier:check",
|
||||
"cypress:open": "cypress open",
|
||||
"cypress:run": "cypress run"
|
||||
"test": "pnpm prettier:check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@heroicons/react": "^2.1.5",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.3.1",
|
||||
"next": "15.0.0-canary.113",
|
||||
"react": "19.0.0-rc-3208e73e-20240730",
|
||||
"react-dom": "19.0.0-rc-3208e73e-20240730",
|
||||
"next": "15.0.0-canary.155",
|
||||
"react": "19.0.0-rc-206df66e-20240912",
|
||||
"react-dom": "19.0.0-rc-206df66e-20240912",
|
||||
"sonner": "^1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@badeball/cypress-cucumber-preprocessor": "^20.1.2",
|
||||
"@bahmutov/cypress-esbuild-preprocessor": "^2.2.3",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/node": "20.14.12",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/node": "22.5.5",
|
||||
"@types/react": "18.3.5",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"cypress": "^13.13.3",
|
||||
"cypress": "^13.14.2",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "5.5.4"
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
|
2638
pnpm-lock.yaml
generated
2638
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2015",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "dom"],
|
||||
"downlevelIteration": true,
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
@ -9,20 +9,27 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"module": "esnext",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"types": ["cypress", "node", "@types/node", "@badeball/cypress-cucumber-preprocessor"],
|
||||
"plugins": [
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user