From 0c6a438feebe0f58ff5eb3684c7307214ca9ba2c Mon Sep 17 00:00:00 2001 From: EunJu JO Date: Wed, 15 May 2024 00:58:12 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20Storybook=20cd=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/chromatic.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/chromatic.yml diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 00000000..fe6b0f94 --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,16 @@ +name: 'Chromatic Deployment' + +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - run: yarn + - uses: chromaui/action@v1 + with: + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} From f9814acda7175f09960a28294090852d88087cd8 Mon Sep 17 00:00:00 2001 From: EunJu JO Date: Thu, 16 May 2024 00:34:01 +0900 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20RefreshToken=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20url=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useTokenRefreshTimer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useTokenRefreshTimer.ts b/src/hooks/useTokenRefreshTimer.ts index 2c81404b..8ba5f6bd 100644 --- a/src/hooks/useTokenRefreshTimer.ts +++ b/src/hooks/useTokenRefreshTimer.ts @@ -29,7 +29,7 @@ export const useTokenRefreshTimer = () => { const refreshTokens = async () => { try { - const response = await axios.get(`${BASE_URL}/user/authorize`, { + const response = await axios.get(`${BASE_URL}/user/refresh`, { headers: { 'Refresh-Token': `${localStorage.getItem('refresh-token')}` }, }); const newAuthTokens = response.headers['access-token']; From b361852b59b3587d88f5a6fae4f578063e04f2ed Mon Sep 17 00:00:00 2001 From: EunJu JO Date: Thu, 16 May 2024 00:35:27 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20=EC=8A=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EB=B6=81=20=EC=A0=84=EC=97=AD=20decorator=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/preview.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 378abe0d..45c95820 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,12 +1,17 @@ import type { Preview } from '@storybook/react'; import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { getClient } from './../src/queryClient'; import '../src/GlobalStyle.tsx'; export const decorators = [ (Story) => ( - - - + + + + + ), ]; From cd883c59d72126cff60dbcaafb029c6bf1f6a96f Mon Sep 17 00:00:00 2001 From: EunJu JO Date: Thu, 16 May 2024 00:38:33 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useTokenRefreshTimer.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/hooks/useTokenRefreshTimer.ts b/src/hooks/useTokenRefreshTimer.ts index 8ba5f6bd..59b5fb0b 100644 --- a/src/hooks/useTokenRefreshTimer.ts +++ b/src/hooks/useTokenRefreshTimer.ts @@ -35,6 +35,9 @@ export const useTokenRefreshTimer = () => { const newAuthTokens = response.headers['access-token']; localStorage.setItem('access-token', newAuthTokens); + const newRefreshToken = response.headers['refresh-token']; + localStorage.setItem('refresh-token', newRefreshToken); + // 새로운 만료 시간 저장 let expirationTime = new Date(new Date().getTime() + JWT_EXPIRY_TIME).toISOString(); localStorage.setItem('expirationTime', expirationTime); From 3bb6b217014539fc1d3581d923009c5bb8beaa50 Mon Sep 17 00:00:00 2001 From: EunJu JO Date: Thu, 16 May 2024 01:07:31 +0900 Subject: [PATCH 05/12] =?UTF-8?q?style:=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useFetchProductList.ts | 4 ++++ src/hooks/useTokenRefreshTimer.ts | 5 +---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/hooks/useFetchProductList.ts b/src/hooks/useFetchProductList.ts index 3f41cf15..cfd06ce6 100644 --- a/src/hooks/useFetchProductList.ts +++ b/src/hooks/useFetchProductList.ts @@ -6,6 +6,7 @@ interface FetchProductListProps { queryKey: string; } const PAGE_SiZE = 10; + const useFetchProductList = ({ path, queryKey }: FetchProductListProps) => { const fetchWishList = async ({ pageParam = 1 }) => { try { @@ -36,6 +37,7 @@ const useFetchProductList = ({ path, queryKey }: FetchProductListProps) => { return { data: [], nextPage: undefined }; } }; + const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery( [`${queryKey}`], ({ pageParam = 1 }) => fetchWishList({ pageParam }), @@ -45,11 +47,13 @@ const useFetchProductList = ({ path, queryKey }: FetchProductListProps) => { }, }, ); + const loadMore = async () => { if (hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }; + return { data, isLoading, hasNextPage, fetchNextPage: loadMore }; }; export default useFetchProductList; diff --git a/src/hooks/useTokenRefreshTimer.ts b/src/hooks/useTokenRefreshTimer.ts index 59b5fb0b..51f8b04c 100644 --- a/src/hooks/useTokenRefreshTimer.ts +++ b/src/hooks/useTokenRefreshTimer.ts @@ -5,7 +5,6 @@ import { useLocation } from 'react-router-dom'; const JWT_EXPIRY_TIME = 1000 * 60 * 60 * 3 - 1000 * 60 * 10; // 3시간 - 10분 const BASE_URL = import.meta.env.VITE_APP_URL; -// 액세스 토큰 만료 시간이 지나면 리프레쉬 토큰으로 재발급 export const useTokenRefreshTimer = () => { const location = useLocation(); @@ -24,7 +23,7 @@ export const useTokenRefreshTimer = () => { const remainingTime = calculateRemainingTime(time); const timer = setTimeout(() => refreshTokens(), remainingTime); - return () => clearTimeout(timer); // 컴포넌트 언마운트 시 타이머 정리 + return () => clearTimeout(timer); }); const refreshTokens = async () => { @@ -38,7 +37,6 @@ export const useTokenRefreshTimer = () => { const newRefreshToken = response.headers['refresh-token']; localStorage.setItem('refresh-token', newRefreshToken); - // 새로운 만료 시간 저장 let expirationTime = new Date(new Date().getTime() + JWT_EXPIRY_TIME).toISOString(); localStorage.setItem('expirationTime', expirationTime); } catch (error) { @@ -58,7 +56,6 @@ export const clearLocalStorage = () => { localStorage.removeItem('userId'); }; -// 남은 시간 유효 시간 계산 export const calculateRemainingTime = (expirationTime: string): number => { const currentTime = new Date().getTime(); const adjExpirationTime = new Date(expirationTime).getTime(); From 968856aa22f0d6cc4acf62744b11128b5bc76d1e Mon Sep 17 00:00:00 2001 From: EunJu JO Date: Thu, 16 May 2024 03:11:43 +0900 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20=EC=9D=B4=EB=A9=94=EC=9D=BC,=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/EditInfoModal/index.tsx | 83 +++++++++++-------------- src/components/EditInfoModal/styles.tsx | 7 ++- src/hooks/useEditInfo.ts | 70 +++++++++++++++++++++ src/pages/EditInfo/index.tsx | 18 ++++-- 4 files changed, 125 insertions(+), 53 deletions(-) create mode 100644 src/hooks/useEditInfo.ts diff --git a/src/components/EditInfoModal/index.tsx b/src/components/EditInfoModal/index.tsx index 12f3b30e..fa5f6b33 100644 --- a/src/components/EditInfoModal/index.tsx +++ b/src/components/EditInfoModal/index.tsx @@ -1,18 +1,27 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import { useEffect } from 'react'; import * as S from './styles'; import { IoClose } from 'react-icons/io5'; import { ModalType } from '@/pages/EditInfo'; +import useEditInfo from '@/hooks/useEditInfo'; interface Props { type: ModalType; onRequestClose?: () => void; - updateInfo?: (type: string, newValue: string) => void; + updateInfo?: (type: string, newValue: string, oldValue: string) => void; } const EditInfoModal = ({ type, onRequestClose, updateInfo }: Props) => { - const [inputValue, setInputValue] = useState(''); - const [typeErrorMessage, setTypeErrorMessage] = useState(''); // 에러 메시지 - const [isCorrect, setIsCorrect] = useState(false); // 입력값이 올바른지 표시 + const { + oldValue, + inputValue, + setInputValue, + typeErrorMessage, + setTypeErrorMessage, + setIsCorrect, + handleChangeInput, + handleChangeOldValue, + isDisabled, + } = useEditInfo({ type }); // 창을 닫을 때 초기화 useEffect(() => { @@ -21,39 +30,9 @@ const EditInfoModal = ({ type, onRequestClose, updateInfo }: Props) => { setIsCorrect(false); }, [onRequestClose]); - const checkEmail = useCallback((e: React.ChangeEvent) => { - const emailRegex = - /([\w-.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([\w-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\]?)$/; - if (!emailRegex.test(e.target.value)) { - setTypeErrorMessage('이메일 형식이 아닙니다.'); - setIsCorrect(false); - } else { - setTypeErrorMessage(''); - setIsCorrect(true); - } - }, []); - - const checkPassword = useCallback((e: React.ChangeEvent) => { - const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,15}$/; - if (!passwordRegex.test(e.target.value)) { - setTypeErrorMessage('비밀번호 형식이 아닙니다. 영문, 숫자 포함 8~15자로 입력해주세요.'); - setIsCorrect(false); - } else { - setTypeErrorMessage(''); - setIsCorrect(true); - } - }, []); - - const handleChangeInput = (e: React.ChangeEvent) => { - setInputValue(e.target.value); - - if (type === 'email') checkEmail(e); - if (type === 'password') checkPassword(e); - }; - const handleUpdate = () => { if (updateInfo && type) { - updateInfo(type, inputValue); + updateInfo(type, inputValue, oldValue); onRequestClose && onRequestClose(); } }; @@ -66,31 +45,41 @@ const EditInfoModal = ({ type, onRequestClose, updateInfo }: Props) => { - 변경할 {type === 'email' ? '이메일' : '비밀번호'} + {type === 'email' ? '이메일' : '비밀번호'} 변경 {type === 'email' && ( )} {type === 'password' && ( - + + + + )} {typeErrorMessage} - + 변경하기 diff --git a/src/components/EditInfoModal/styles.tsx b/src/components/EditInfoModal/styles.tsx index f89c8bb5..f2c24fab 100644 --- a/src/components/EditInfoModal/styles.tsx +++ b/src/components/EditInfoModal/styles.tsx @@ -18,7 +18,7 @@ export const ModalContainer = styled.div` transform: translate(-50%, -50%); width: 32rem; - height: 16rem; + height: 18rem; display: flex; align-items: center; @@ -59,6 +59,11 @@ export const Label = styled.span` font-weight: 700; `; +export const InputContainer = styled(Container)` + padding-bottom: 0; + gap: 0.5rem; +`; + export const InputBox = styled.input` height: 3rem; width: 24rem; diff --git a/src/hooks/useEditInfo.ts b/src/hooks/useEditInfo.ts new file mode 100644 index 00000000..03323360 --- /dev/null +++ b/src/hooks/useEditInfo.ts @@ -0,0 +1,70 @@ +import { ModalType } from '@/pages/EditInfo'; +import { useCallback, useState } from 'react'; + +interface EditInfoProps { + type: ModalType; +} + +const useEditInfo = ({ type }: EditInfoProps) => { + const [oldValue, setOldValue] = useState(''); + const [inputValue, setInputValue] = useState(''); + const [typeErrorMessage, setTypeErrorMessage] = useState(''); + const [isCorrect, setIsCorrect] = useState(false); + + const checkEmail = useCallback((e: React.ChangeEvent) => { + const emailRegex = + /([\w-.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([\w-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\]?)$/; + if (!emailRegex.test(e.target.value)) { + setTypeErrorMessage('이메일 형식이 아닙니다.'); + setIsCorrect(false); + } else { + setTypeErrorMessage(''); + setIsCorrect(true); + } + }, []); + + const checkPassword = useCallback((e: React.ChangeEvent) => { + const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,15}$/; + if (!passwordRegex.test(e.target.value)) { + setTypeErrorMessage('비밀번호 형식이 아닙니다. 영문, 숫자 포함 8~15자로 입력해주세요.'); + setIsCorrect(false); + } else { + setTypeErrorMessage(''); + setIsCorrect(true); + } + }, []); + + const handleChangeInput = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + + if (type === 'email') checkEmail(e); + if (type === 'password') checkPassword(e); + }; + + const handleChangeOldValue = (e: React.ChangeEvent) => { + setOldValue(e.target.value); + }; + + const isDisabled = () => { + if (type === 'password') { + return !isCorrect && oldValue.length > 0; + } + return !isCorrect; + }; + + return { + oldValue, + setOldValue, + inputValue, + setInputValue, + typeErrorMessage, + setTypeErrorMessage, + isCorrect, + setIsCorrect, + handleChangeInput, + handleChangeOldValue, + isDisabled, + }; +}; + +export default useEditInfo; diff --git a/src/pages/EditInfo/index.tsx b/src/pages/EditInfo/index.tsx index 10dce9f0..1e185f09 100644 --- a/src/pages/EditInfo/index.tsx +++ b/src/pages/EditInfo/index.tsx @@ -49,7 +49,7 @@ const EditInfo = () => { ); const mutateInfoChange = useMutation( - (updateInfo: UserInfo) => { + (updateInfo: any) => { return restFetcher({ method: 'PATCH', path: '/users/update', @@ -69,12 +69,20 @@ const EditInfo = () => { }, ); - const handleInfoChange = async (type: string, newValue: string | null) => { + const handleInfoChange = async ( + type: string, + newValue: string | null, + oldValue?: string | null, + ) => { let updateInfo = {}; - if (type === 'email') updateInfo = { email: newValue }; - if (type === 'password') updateInfo = { password: newValue }; + if (type === 'email') { + updateInfo = { email: newValue }; + } + if (type === 'password') { + updateInfo = { oldPassword: oldValue, newPassword: newValue }; + } - await mutateInfoChange.mutateAsync(updateInfo as UserInfo); + await mutateInfoChange.mutateAsync(updateInfo as any); }; const mutateLogout = useMutation(() => { From 9248d349438ffbed85a50f30384b919d3b1b321a Mon Sep 17 00:00:00 2001 From: EunJu JO Date: Thu, 16 May 2024 03:53:01 +0900 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20=EC=88=98=EC=A0=95=EB=90=9C=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EC=8A=A4=ED=86=A0=EB=A6=AC=EB=B6=81?= =?UTF-8?q?=EC=97=90=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/TopNavBar/index.tsx | 3 +- src/hooks/useEditInfo.ts | 2 +- .../BottomNavBar/BottomNavBar.stories.tsx | 23 +++---- src/stories/BottomNavBar/index.tsx | 44 ------------ .../EditInfoModal/EditInfoModal.stories.tsx | 7 +- .../ProductForm/ProductForm.stories.tsx | 58 +++++----------- src/stories/ProductForm/index.tsx | 67 ------------------- src/stories/TopNavBar/TopNavBar.stories.tsx | 7 +- src/stories/TopNavBar/index.tsx | 20 ------ 9 files changed, 37 insertions(+), 194 deletions(-) delete mode 100644 src/stories/BottomNavBar/index.tsx delete mode 100644 src/stories/ProductForm/index.tsx delete mode 100644 src/stories/TopNavBar/index.tsx diff --git a/src/components/TopNavBar/index.tsx b/src/components/TopNavBar/index.tsx index db3c8bc7..894e0be2 100644 --- a/src/components/TopNavBar/index.tsx +++ b/src/components/TopNavBar/index.tsx @@ -8,9 +8,10 @@ interface TopNavBarProps { const TopNavBar: React.FC = ({ page }) => { const navigate = useNavigate(); + return ( - navigate(-1)}> + navigate(-1)} data-testid="back-button"> {page} diff --git a/src/hooks/useEditInfo.ts b/src/hooks/useEditInfo.ts index 03323360..b35cf468 100644 --- a/src/hooks/useEditInfo.ts +++ b/src/hooks/useEditInfo.ts @@ -47,7 +47,7 @@ const useEditInfo = ({ type }: EditInfoProps) => { const isDisabled = () => { if (type === 'password') { - return !isCorrect && oldValue.length > 0; + return !(isCorrect && oldValue.length > 0 && inputValue.length > 0); } return !isCorrect; }; diff --git a/src/stories/BottomNavBar/BottomNavBar.stories.tsx b/src/stories/BottomNavBar/BottomNavBar.stories.tsx index 8c157075..25fa36bf 100644 --- a/src/stories/BottomNavBar/BottomNavBar.stories.tsx +++ b/src/stories/BottomNavBar/BottomNavBar.stories.tsx @@ -1,6 +1,7 @@ import { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from '@storybook/testing-library'; -import BottomNavBar from './index'; +// import BottomNavBar from './index'; +import BottomNavBar from '@/components/BottomNavBar'; import { expect } from '@storybook/test'; const meta: Meta = { @@ -13,23 +14,19 @@ export default meta; type Story = StoryObj; export const Basic: Story = { - args: { - onNavClick: (name) => alert(name), - }, + args: {}, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const homeButton = canvas.getByTestId('home-button'); - const likeButton = canvas.getByTestId('like-button'); - const chatButton = canvas.getByTestId('chat-button'); - const mypageButton = canvas.getByTestId('mypage-button'); + const homeButton = canvas.getAllByRole('button')[0]; + const likeButton = canvas.getAllByRole('button')[1]; + const chatButton = canvas.getAllByRole('button')[2]; + const mypageButton = canvas.getAllByRole('button')[3]; - // 클릭 이벤트 테스트 - // [homeButton, likeButton, chatButton, mypageButton].map(async (button) => - // userEvent.click(button), - // ); + [homeButton, likeButton, chatButton, mypageButton].map(async (button) => + userEvent.click(button, { delay: 1000 }), + ); - // 화면에 버튼이 모두 보이는지 테스트 [homeButton, likeButton, chatButton, mypageButton].map( async (button) => await expect(button).toBeInTheDocument(), ); diff --git a/src/stories/BottomNavBar/index.tsx b/src/stories/BottomNavBar/index.tsx deleted file mode 100644 index fc5d4b4c..00000000 --- a/src/stories/BottomNavBar/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import * as S from '@/components/BottomNavBar/styles'; -import homeImage from '../../assets/Home.svg'; -import heartImage from '../../assets/Heart.svg'; -import chatImage from '../../assets/Chat.svg'; -import mypageImage from '../../assets/mypage.svg'; - -interface BottomNavBarProps { - onNavClick: (nav: string) => void; -} - -const BottomNavBar = ({ onNavClick }: BottomNavBarProps) => { - return ( - -
onNavClick('홈')} data-testid="home-button"> - - 홈 - -
- -
onNavClick('좋아요')} data-testid="like-button"> - - - 좋아요 - -
- -
onNavClick('채팅')} data-testid="chat-button"> - - - 채팅 - -
- -
onNavClick('마이페이지')} data-testid="mypage-button"> - - - 마이페이지 - -
-
- ); -}; - -export default BottomNavBar; diff --git a/src/stories/EditInfoModal/EditInfoModal.stories.tsx b/src/stories/EditInfoModal/EditInfoModal.stories.tsx index 93c96292..f1f23223 100644 --- a/src/stories/EditInfoModal/EditInfoModal.stories.tsx +++ b/src/stories/EditInfoModal/EditInfoModal.stories.tsx @@ -20,7 +20,7 @@ export const EmailModal: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const label = canvas.getByText('변경할 이메일'); + const label = canvas.getByText('이메일 변경'); await expect(label).toBeInTheDocument(); const input = canvas.getAllByRole('textbox')[0]; @@ -40,9 +40,12 @@ export const PasswordModal: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const label = canvas.getByText('변경할 비밀번호'); + const label = canvas.getByText('비밀번호 변경'); await expect(label).toBeInTheDocument(); + const oldPassword = canvas.getByTestId('oldPassword'); + await userEvent.type(oldPassword, 'test1234', { delay: 200 }); + const input = canvas.getByTestId('password'); await userEvent.type(input, 'test1234', { delay: 200 }); await expect(input).toHaveValue('test1234'); diff --git a/src/stories/ProductForm/ProductForm.stories.tsx b/src/stories/ProductForm/ProductForm.stories.tsx index 876d5e40..b4690c0f 100644 --- a/src/stories/ProductForm/ProductForm.stories.tsx +++ b/src/stories/ProductForm/ProductForm.stories.tsx @@ -1,6 +1,6 @@ import { Meta, StoryObj } from '@storybook/react'; import { within } from '@storybook/testing-library'; -import ProductForm from './index'; +import ProductForm from '@/components/ProductForm/ProductForm'; import { Product } from '@/types/product'; import { expect } from '@storybook/test'; @@ -14,34 +14,19 @@ export default meta; type Story = StoryObj; -const ITEM: Product[] = [ - { - id: 1, - productId: 1, - title: '노트북 팝니다', - thumbnailURL: - 'https://techeer-market.s3.ap-northeast-2.amazonaws.com/product/a3861677-5968-4fe6-a27b-0ec781274142-blob', - name: '조은주', - price: 100000, - createdAt: '2023-10-10', - productState: 'SALE', - likes: 1, - views: 2, - }, - { - id: 2, - productId: 2, - title: '화분', - thumbnailURL: - 'https://techeer-market.s3.ap-northeast-2.amazonaws.com/product/a3861677-5968-4fe6-a27b-0ec781274142-blob', - name: '조은주', - price: 1000, - createdAt: '2024-1-10', - productState: 'SALE', - likes: 2, - views: 2, - }, -]; +const ITEM: Product = { + id: 1, + productId: 1, + title: '노트북 팝니다', + thumbnailURL: + 'https://techeer-market.s3.ap-northeast-2.amazonaws.com/product/a3861677-5968-4fe6-a27b-0ec781274142-blob', + name: '조은주', + price: 100000, + createdAt: '2023-10-10', + productState: 'SALE', + likes: 1, + views: 2, +}; export const Basic: Story = { args: { @@ -50,16 +35,9 @@ export const Basic: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText(ITEM[0].title)).toBeInTheDocument(); - expect(canvas.getAllByText(ITEM[0].name)[0]).toBeInTheDocument(); - expect(canvas.getByText(`${Number(ITEM[0].price).toLocaleString()}원`)).toBeInTheDocument(); - }, -}; - -export const WishList: Story = { - args: { - items: ITEM, - location: '/wishlist', + expect(canvas.getByText(ITEM.title)).toBeInTheDocument(); + expect(canvas.getAllByText(ITEM.name)[0]).toBeInTheDocument(); + expect(canvas.getByText(`${Number(ITEM.price).toLocaleString()}원`)).toBeInTheDocument(); }, }; @@ -67,7 +45,6 @@ export const WishList: Story = { export const SalesList_SALE: Story = { args: { items: ITEM, - location: '/saleslist', state: 'SALE', }, }; @@ -76,7 +53,6 @@ export const SalesList_SALE: Story = { export const SalesList_SOLD: Story = { args: { items: ITEM, - location: '/saleslist', state: 'SOLD', }, }; diff --git a/src/stories/ProductForm/index.tsx b/src/stories/ProductForm/index.tsx deleted file mode 100644 index 03ba70a5..00000000 --- a/src/stories/ProductForm/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import Heart from '@/assets/grayHeartIcon.svg'; -import FilledHeart from '@/assets/likedHeart.svg'; -import Chat from '@/assets/chatIcon.svg'; -import Circle from '@/assets/circle.svg'; -import { useState } from 'react'; -import { Product } from '@/types/product'; -import * as S from '@/components/ProductForm/styles'; -import { formatDateToNow } from '@/utils/formatDateToNow'; - -interface ProductProps { - items: Product[]; - state?: string; - location: string; -} - -const ProductForm = ({ items, state, location }: ProductProps) => { - const [dropDown, setDropDown] = useState(0); - - const isWishPage = location === '/wishlist'; - const isSalsePage = location === '/saleslist'; - - return ( - - {items?.map((item) => ( - - - - -
- {item.title} - - {item.name} - {formatDateToNow(item.createdAt)} - -
- {Number(item.price).toLocaleString()}원 -
- - - - - {item.likes} - - - - {item.views} - - {/* 판매 내역 페이지일 경우에만 보이도록 함 */} - {isSalsePage && ( - { - event.stopPropagation(); // 이벤트 버블링 방지 - setDropDown(dropDown === item.productId ? 0 : item.productId); - }} - > - - - )} - -
-
- ))} -
- ); -}; - -export default ProductForm; diff --git a/src/stories/TopNavBar/TopNavBar.stories.tsx b/src/stories/TopNavBar/TopNavBar.stories.tsx index 4a5969ba..9371cfbe 100644 --- a/src/stories/TopNavBar/TopNavBar.stories.tsx +++ b/src/stories/TopNavBar/TopNavBar.stories.tsx @@ -1,6 +1,6 @@ import { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from '@storybook/testing-library'; -import TopNavBar from './index'; +import TopNavBar from '@/components/TopNavBar'; import { expect } from '@storybook/test'; const meta: Meta = { @@ -15,17 +15,14 @@ type Story = StoryObj; export const Basic: Story = { args: { page: '페이지', - onNavBack: () => alert('뒤로가기'), }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const backButton = canvas.getByTestId('back-button'); - // 클릭 이벤트 테스트 - await userEvent.click(backButton); + await userEvent.click(backButton, { delay: 1000 }); - // 화면에 버튼이 보이는지 테스트 await expect(backButton).toBeInTheDocument(); }, }; diff --git a/src/stories/TopNavBar/index.tsx b/src/stories/TopNavBar/index.tsx deleted file mode 100644 index 4f49dee2..00000000 --- a/src/stories/TopNavBar/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import * as S from '@/components/TopNavBar/styles'; -import { SlArrowLeft } from 'react-icons/sl'; - -interface TopNavBarProps { - page: string; - onNavBack?: () => void; -} - -const TopNavBar: React.FC = ({ page, onNavBack }: TopNavBarProps) => { - return ( - - - - - {page} - - ); -}; - -export default TopNavBar; From 7497c908c6e660491b61444d920528879d580296 Mon Sep 17 00:00:00 2001 From: EunJu JO Date: Thu, 16 May 2024 04:37:59 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/LogIn/index.tsx | 4 ++-- src/pages/LogIn/styles.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/LogIn/index.tsx b/src/pages/LogIn/index.tsx index 34acaa1a..05f8c0b0 100644 --- a/src/pages/LogIn/index.tsx +++ b/src/pages/LogIn/index.tsx @@ -127,13 +127,13 @@ const Login = () => { )} - + {/* 로그인 상태 유지 아이디/비밀번호 찾기 - + */} diff --git a/src/pages/LogIn/styles.tsx b/src/pages/LogIn/styles.tsx index 4948ac38..07354bfd 100644 --- a/src/pages/LogIn/styles.tsx +++ b/src/pages/LogIn/styles.tsx @@ -66,12 +66,12 @@ export const FindAccount = styled.button` export const Buttons = styled.div` display: flex; flex-direction: column; + padding-top: 6rem; `; export const LogInButton = styled.button` width: 36rem; height: 5.5rem; border-radius: 10px; - // background: #000; background-color: ${(props) => (props.disabled ? '#ccc' : '#000')}; color: white; border: none; From 15f3a172cfc0f6f49b71b1cfad7beb58bfb44196 Mon Sep 17 00:00:00 2001 From: EunJu JO Date: Thu, 16 May 2024 14:48:55 +0900 Subject: [PATCH 09/12] =?UTF-8?q?chore:=20playwright=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 +- node_modules/.package-lock.json | 96 ++++++ package-lock.json | 151 +++++++++ package.json | 6 +- playwright.config.ts | 74 +++++ tests-examples/demo-todo-app.spec.ts | 437 +++++++++++++++++++++++++++ tests/example.spec.ts | 18 ++ 7 files changed, 788 insertions(+), 2 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests-examples/demo-todo-app.spec.ts create mode 100644 tests/example.spec.ts diff --git a/.gitignore b/.gitignore index 67416c95..e1e23ecc 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,10 @@ src/Router.tsx node_modules/vite/bin/vite.js # snapshots file -__snapshots__ \ No newline at end of file +__snapshots__ + +# playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 6720b7b4..3ddfc8a7 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -2803,6 +2803,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.0.tgz", + "integrity": "sha512-rNX5lbNidamSUorBhB4XZ9SQTjAqfe5M+p37Z8ic0jPFBMo5iCtQz1kRWkEMg+rYOKSlVycpQmpqjSFq7LXOfg==", + "dev": true, + "dependencies": { + "playwright": "1.44.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@radix-ui/number": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", @@ -7650,6 +7665,29 @@ "node": ">=10" } }, + "node_modules/chromatic": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-11.3.2.tgz", + "integrity": "sha512-0PuHl49VvBMoDHEfmNjC/bim9YYNhWF3axTZlFuatC0avwr2Xw4GDqJDG9fArEWN8oM8VtYHkE9D7qc87dmz2w==", + "dev": true, + "bin": { + "chroma": "dist/bin.js", + "chromatic": "dist/bin.js", + "chromatic-cli": "dist/bin.js" + }, + "peerDependencies": { + "@chromatic-com/cypress": "^0.*.* || ^1.0.0", + "@chromatic-com/playwright": "^0.*.* || ^1.0.0" + }, + "peerDependenciesMeta": { + "@chromatic-com/cypress": { + "optional": true + }, + "@chromatic-com/playwright": { + "optional": true + } + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -8488,6 +8526,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-cli": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.4.2.tgz", + "integrity": "sha512-SbUj8l61zIbzyhIbg0FwPJq6+wjbzdn9oEtozQpZ6kW2ihCcapKVZj49oCT3oPM+mgQm+itgvUQcG5szxVrZTA==", + "dependencies": { + "cross-spawn": "^7.0.3", + "dotenv": "^16.3.0", + "dotenv-expand": "^10.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, "node_modules/dotenv-expand": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", @@ -13827,6 +13879,50 @@ "pathe": "^1.1.0" } }, + "node_modules/playwright": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.0.tgz", + "integrity": "sha512-F9b3GUCLQ3Nffrfb6dunPOkE5Mh68tR7zN32L4jCk4FjQamgesGay7/dAAe1WaMEGV04DkdJfcJzjoCKygUaRQ==", + "dev": true, + "dependencies": { + "playwright-core": "1.44.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.0.tgz", + "integrity": "sha512-ZTbkNpFfYcGWohvTTl+xewITm7EOuqIqex0c7dNZ+aXsbrLj0qI8XlGKfPpipjm0Wny/4Lt4CJsWJk1stVS5qQ==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", diff --git a/package-lock.json b/package-lock.json index 4f6295da..db2dbf4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/react-modal": "^3.16.0", "axios": "^1.3.4", "date-fns": "^2.30.0", + "dotenv-cli": "^7.4.2", "jwt-decode": "^3.1.2", "loadash": "^1.0.0", "moment": "^2.30.1", @@ -37,6 +38,7 @@ "vitest": "^1.5.0" }, "devDependencies": { + "@playwright/test": "^1.44.0", "@storybook/addon-essentials": "^7.6.17", "@storybook/addon-interactions": "^7.6.17", "@storybook/addon-links": "^7.6.17", @@ -53,6 +55,7 @@ "@types/styled-components": "^5.1.26", "@typescript-eslint/eslint-plugin": "^5.54.1", "@vitejs/plugin-react": "^3.1.0", + "chromatic": "^11.3.2", "eslint": "^8.36.0", "eslint-config-prettier": "^8.7.0", "eslint-config-standard-with-typescript": "^34.0.0", @@ -3199,6 +3202,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.0.tgz", + "integrity": "sha512-rNX5lbNidamSUorBhB4XZ9SQTjAqfe5M+p37Z8ic0jPFBMo5iCtQz1kRWkEMg+rYOKSlVycpQmpqjSFq7LXOfg==", + "dev": true, + "dependencies": { + "playwright": "1.44.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@radix-ui/number": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", @@ -8226,6 +8244,29 @@ "node": ">=10" } }, + "node_modules/chromatic": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-11.3.2.tgz", + "integrity": "sha512-0PuHl49VvBMoDHEfmNjC/bim9YYNhWF3axTZlFuatC0avwr2Xw4GDqJDG9fArEWN8oM8VtYHkE9D7qc87dmz2w==", + "dev": true, + "bin": { + "chroma": "dist/bin.js", + "chromatic": "dist/bin.js", + "chromatic-cli": "dist/bin.js" + }, + "peerDependencies": { + "@chromatic-com/cypress": "^0.*.* || ^1.0.0", + "@chromatic-com/playwright": "^0.*.* || ^1.0.0" + }, + "peerDependenciesMeta": { + "@chromatic-com/cypress": { + "optional": true + }, + "@chromatic-com/playwright": { + "optional": true + } + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -9064,6 +9105,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-cli": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.4.2.tgz", + "integrity": "sha512-SbUj8l61zIbzyhIbg0FwPJq6+wjbzdn9oEtozQpZ6kW2ihCcapKVZj49oCT3oPM+mgQm+itgvUQcG5szxVrZTA==", + "dependencies": { + "cross-spawn": "^7.0.3", + "dotenv": "^16.3.0", + "dotenv-expand": "^10.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, "node_modules/dotenv-expand": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", @@ -14403,6 +14458,50 @@ "pathe": "^1.1.0" } }, + "node_modules/playwright": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.0.tgz", + "integrity": "sha512-F9b3GUCLQ3Nffrfb6dunPOkE5Mh68tR7zN32L4jCk4FjQamgesGay7/dAAe1WaMEGV04DkdJfcJzjoCKygUaRQ==", + "dev": true, + "dependencies": { + "playwright-core": "1.44.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.0.tgz", + "integrity": "sha512-ZTbkNpFfYcGWohvTTl+xewITm7EOuqIqex0c7dNZ+aXsbrLj0qI8XlGKfPpipjm0Wny/4Lt4CJsWJk1stVS5qQ==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", @@ -20884,6 +20983,15 @@ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "optional": true }, + "@playwright/test": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.0.tgz", + "integrity": "sha512-rNX5lbNidamSUorBhB4XZ9SQTjAqfe5M+p37Z8ic0jPFBMo5iCtQz1kRWkEMg+rYOKSlVycpQmpqjSFq7LXOfg==", + "dev": true, + "requires": { + "playwright": "1.44.0" + } + }, "@radix-ui/number": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", @@ -24453,6 +24561,13 @@ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "dev": true }, + "chromatic": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-11.3.2.tgz", + "integrity": "sha512-0PuHl49VvBMoDHEfmNjC/bim9YYNhWF3axTZlFuatC0avwr2Xw4GDqJDG9fArEWN8oM8VtYHkE9D7qc87dmz2w==", + "dev": true, + "requires": {} + }, "ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -25089,6 +25204,17 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==" }, + "dotenv-cli": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.4.2.tgz", + "integrity": "sha512-SbUj8l61zIbzyhIbg0FwPJq6+wjbzdn9oEtozQpZ6kW2ihCcapKVZj49oCT3oPM+mgQm+itgvUQcG5szxVrZTA==", + "requires": { + "cross-spawn": "^7.0.3", + "dotenv": "^16.3.0", + "dotenv-expand": "^10.0.0", + "minimist": "^1.2.6" + } + }, "dotenv-expand": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", @@ -28982,6 +29108,31 @@ "pathe": "^1.1.0" } }, + "playwright": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.0.tgz", + "integrity": "sha512-F9b3GUCLQ3Nffrfb6dunPOkE5Mh68tR7zN32L4jCk4FjQamgesGay7/dAAe1WaMEGV04DkdJfcJzjoCKygUaRQ==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.44.0" + }, + "dependencies": { + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + } + } + }, + "playwright-core": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.0.tgz", + "integrity": "sha512-ZTbkNpFfYcGWohvTTl+xewITm7EOuqIqex0c7dNZ+aXsbrLj0qI8XlGKfPpipjm0Wny/4Lt4CJsWJk1stVS5qQ==", + "dev": true + }, "polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", diff --git a/package.json b/package.json index 6755acb2..42627055 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "test": "vitest" + "test": "vitest", + "chromatic": "dotenv -e .env npx chromatic --project-token=env.CHROMATIC_PROJECT_TOKEN" }, "dependencies": { "@reduxjs/toolkit": "^1.9.3", @@ -24,6 +25,7 @@ "@types/react-modal": "^3.16.0", "axios": "^1.3.4", "date-fns": "^2.30.0", + "dotenv-cli": "^7.4.2", "jwt-decode": "^3.1.2", "loadash": "^1.0.0", "moment": "^2.30.1", @@ -41,6 +43,7 @@ "vitest": "^1.5.0" }, "devDependencies": { + "@playwright/test": "^1.44.0", "@storybook/addon-essentials": "^7.6.17", "@storybook/addon-interactions": "^7.6.17", "@storybook/addon-links": "^7.6.17", @@ -57,6 +60,7 @@ "@types/styled-components": "^5.1.26", "@typescript-eslint/eslint-plugin": "^5.54.1", "@vitejs/plugin-react": "^3.1.0", + "chromatic": "^11.3.2", "eslint": "^8.36.0", "eslint-config-prettier": "^8.7.0", "eslint-config-standard-with-typescript": "^34.0.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..349e2419 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,74 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // Storybook URL + baseURL: 'http://localhost:6006', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + viewport: { width: 1280, height: 720 }, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 00000000..2fd6016f --- /dev/null +++ b/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,437 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +]; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0] + ]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ page }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe('Item', () => { + + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); + }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); + await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect(todoItem.locator('label', { + hasText: TODO_ITEMS[1], + })).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e; + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction(t => { + return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t); + }, title); +} diff --git a/tests/example.spec.ts b/tests/example.spec.ts new file mode 100644 index 00000000..54a906a4 --- /dev/null +++ b/tests/example.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); From f7d5b11ac2cb2111f6122cf42d0ae4e288baa1cc Mon Sep 17 00:00:00 2001 From: EunJu JO Date: Thu, 16 May 2024 16:31:11 +0900 Subject: [PATCH 10/12] =?UTF-8?q?chore:=20baseUrl=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- playwright.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/playwright.config.ts b/playwright.config.ts index 349e2419..c1a64f86 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ use: { /* Base URL to use in actions like `await page.goto('/')`. */ // Storybook URL + // https://6641c56731dd79e137b0a49d-opxpqwizpb.chromatic.com baseURL: 'http://localhost:6006', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ From 1993127e874f3942ff1430698e1b0da7476c87a8 Mon Sep 17 00:00:00 2001 From: EunJu JO Date: Thu, 16 May 2024 16:31:43 +0900 Subject: [PATCH 11/12] =?UTF-8?q?test:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9D=B8=ED=84=B0=EB=A0=89?= =?UTF-8?q?=EC=85=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/stories/LoginPage/LoginPage.stories.tsx | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/stories/LoginPage/LoginPage.stories.tsx diff --git a/src/stories/LoginPage/LoginPage.stories.tsx b/src/stories/LoginPage/LoginPage.stories.tsx new file mode 100644 index 00000000..dc5ad66e --- /dev/null +++ b/src/stories/LoginPage/LoginPage.stories.tsx @@ -0,0 +1,32 @@ +import { Meta, StoryObj } from '@storybook/react'; +import Login from '@/pages/LogIn'; +import { userEvent, within } from '@storybook/testing-library'; +import { expect } from '@storybook/test'; + +const meta: Meta = { + title: 'Page/Login', + component: Login, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Template: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const emailInput = canvas.getAllByRole('textbox')[0]; + await userEvent.type(emailInput, 'test@test.com', { delay: 100 }); + + const passwordInput = canvas.getByPlaceholderText('비밀번호'); + await userEvent.type(passwordInput, 'test1234', { delay: 100 }); + + const loginButton = canvas.getByRole('button', { name: '로그인' }); + await expect(loginButton).toBeEnabled(); + + const signUpButton = canvas.getByRole('button', { name: '회원가입' }); + await expect(signUpButton).toBeInTheDocument(); + }, +}; From 418500a03398be7e8a155d820343212664614a55 Mon Sep 17 00:00:00 2001 From: EunJu JO Date: Thu, 16 May 2024 21:05:30 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20chromatic.yml=20run=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=96=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/chromatic.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index fe6b0f94..68913ce6 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -9,7 +9,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 - - run: yarn + - run: npm install - uses: chromaui/action@v1 with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}