diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 00000000..68913ce6 --- /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: npm install + - uses: chromaui/action@v1 + with: + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} 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/.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) => ( - - - + + + + + ), ]; 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..c1a64f86 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,75 @@ +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 + // https://6641c56731dd79e137b0a49d-opxpqwizpb.chromatic.com + 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/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/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 new file mode 100644 index 00000000..b35cf468 --- /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 && inputValue.length > 0); + } + return !isCorrect; + }; + + return { + oldValue, + setOldValue, + inputValue, + setInputValue, + typeErrorMessage, + setTypeErrorMessage, + isCorrect, + setIsCorrect, + handleChangeInput, + handleChangeOldValue, + isDisabled, + }; +}; + +export default useEditInfo; 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 2c81404b..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,18 +23,20 @@ export const useTokenRefreshTimer = () => { const remainingTime = calculateRemainingTime(time); const timer = setTimeout(() => refreshTokens(), remainingTime); - return () => clearTimeout(timer); // 컴포넌트 언마운트 시 타이머 정리 + return () => clearTimeout(timer); }); 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']; 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); } catch (error) { @@ -55,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(); 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(() => { 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; 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/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(); + }, +}; 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; 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(); +});