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();
+});