diff --git a/frontend/.gitignore b/frontend/.gitignore index fba10303e..9c9e79733 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -8,3 +8,7 @@ storybook-static # Sentry Config File .env.sentry-build-plugin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/frontend/e2e/example.spec.ts b/frontend/e2e/example.spec.ts new file mode 100644 index 000000000..54a906a4e --- /dev/null +++ b/frontend/e2e/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(); +}); diff --git a/frontend/e2e/slack-reporter.ts b/frontend/e2e/slack-reporter.ts new file mode 100644 index 000000000..444602723 --- /dev/null +++ b/frontend/e2e/slack-reporter.ts @@ -0,0 +1,326 @@ +/* eslint-disable class-methods-use-this */ +import type { Reporter, FullConfig, Suite, TestCase, TestResult, FullResult } from '@playwright/test/reporter'; +import path from 'path'; + +const getSlackMessage = ({ + all, + passed, + failed, + skipped, + duration, + result, +}: { + all: string; + passed: string; + failed: string; + skipped: string; + duration: string; + result: string; +}) => ({ + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: 'πŸƒ E2E ν…ŒμŠ€νŠΈκ°€ μ‹€ν–‰λ˜μ—ˆμŠ΅λ‹ˆλ‹€: ', + emoji: true, + }, + }, + { + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [], + }, + { + type: 'rich_text_list', + style: 'bullet', + elements: [ + { + type: 'rich_text_section', + elements: [ + { + type: 'text', + text: 'μ‹€ν–‰ μ‹œκ°: ', + }, + { + type: 'text', + text: `${new Date().toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: true, + })}`, + }, + ], + }, + { + type: 'rich_text_section', + elements: [ + { + type: 'text', + text: 'ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€ 수: ', + }, + { + type: 'text', + text: all, + }, + ], + }, + ], + }, + ], + }, + { + type: 'divider', + }, + { + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [ + { + type: 'text', + text: 'SUMMARY', + style: { + bold: true, + }, + }, + ], + }, + ], + }, + { + type: 'rich_text', + elements: [ + { + type: 'rich_text_list', + style: 'bullet', + indent: 0, + border: 0, + elements: [ + { + type: 'rich_text_section', + elements: [ + { + type: 'emoji', + name: 'hourglass', + unicode: '231b', + style: { + bold: true, + }, + }, + { + type: 'text', + text: ' ν…ŒμŠ€νŠΈ μ‹€ν–‰ μ‹œκ°„: ', + style: { + bold: true, + }, + }, + { + type: 'text', + text: duration, + }, + ], + }, + { + type: 'rich_text_section', + elements: [ + { + type: 'emoji', + name: 'package', + unicode: '1f4e6', + style: { + bold: true, + }, + }, + { + type: 'text', + text: ' ν…ŒμŠ€νŠΈ κ²°κ³Ό: ', + style: { + bold: true, + }, + }, + ], + }, + ], + }, + { + type: 'rich_text_list', + style: 'bullet', + indent: 1, + border: 0, + elements: [ + { + type: 'rich_text_section', + elements: [ + { + type: 'emoji', + name: 'white_check_mark', + unicode: '2705', + }, + { + type: 'text', + text: ' 성곡: ', + }, + { + type: 'text', + text: passed, + }, + ], + }, + { + type: 'rich_text_section', + elements: [ + { + type: 'emoji', + name: 'x', + unicode: '274c', + }, + { + type: 'text', + text: ' μ‹€νŒ¨: ', + }, + { + type: 'text', + text: failed, + }, + ], + }, + { + type: 'rich_text_section', + elements: [ + { + type: 'emoji', + name: 'fast_forward', + unicode: '23e9', + }, + { + type: 'text', + text: ' κ±΄λ„ˆλœ€: ', + }, + { + type: 'text', + text: skipped, + }, + ], + }, + ], + }, + ], + }, + { + type: 'divider', + }, + { + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [ + { + type: 'text', + text: result, + }, + ], + }, + ], + }, + ], +}); + +class MyReporter implements Reporter { + all = 0; + + passed = 0; + + failed = 0; + + skipped = 0; + + failsMessage = ''; + + onBegin(_: FullConfig, suite: Suite) { + this.all = suite.allTests().length; + } + + onTestEnd(test: TestCase, result: TestResult) { + const testDuration = `${(result.duration / 1000).toFixed(1)}s`; + const fileName = path.basename(test.location.file); + const testTitle = test.title; + + switch (result.status) { + case 'failed': + case 'timedOut': + this.addFailMessage( + `✘ ${fileName}:${test.location.line}:${test.location.column} β€Ί ${testTitle} (${testDuration})`, + ); + this.failed += 1; + break; + case 'skipped': + this.addFailMessage( + `⚠️ ${fileName}:${test.location.line}:${test.location.column} β€Ί ${testTitle} (${testDuration})`, + ); + this.skipped += 1; + break; + case 'passed': + this.passed += 1; + break; + default: + break; + } + } + + async onEnd(result: FullResult) { + const blockKit = await this.getBlockKit(result); + const webhookUrl = await process.env.SLACK_WEBHOOK_URL; + + if (!webhookUrl) { + console.error('SLACK_WEBHOOK_URL ν™˜κ²½ λ³€μˆ˜κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.'); + return; + } + + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(blockKit), + }); + + if (!response.ok) { + console.error('Slack λ©”μ‹œμ§€ 전솑 μ‹€νŒ¨:', response.statusText); + } else { + console.log('Slack λ©”μ‹œμ§€ 전솑 성곡'); + } + } catch (error) { + console.error('Slack λ©”μ‹œμ§€ 전솑 쀑 μ—λŸ¬ λ°œμƒ:', error); + } + } + + private addFailMessage(message: string) { + this.failsMessage += `\n${message}`; + } + + private async getBlockKit(result: FullResult) { + const { duration } = result; + + const resultBlockKit = getSlackMessage({ + all: `${this.all}`, + passed: `${this.passed}개`, + failed: `${this.failed}개`, + skipped: `${this.skipped}개`, + duration: `${(duration / 1000).toFixed(1)}s`, + result: `${this.failsMessage ? `ν†΅κ³Όν•˜μ§€ λͺ»ν•œ ν…ŒμŠ€νŠΈ\n${this.failsMessage}` : 'πŸ‘ λͺ¨λ“  ν…ŒμŠ€νŠΈκ°€ μ„±κ³΅μ μœΌλ‘œ ν†΅κ³Όν–ˆμŠ΅λ‹ˆλ‹€!'}`, + }); + + return resultBlockKit; + } +} +export default MyReporter; diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 67c692f16..b415373b0 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -22,6 +22,7 @@ module.exports = { testEnvironmentOptions: { customExportConditions: [''], }, + testPathIgnorePatterns: ['/e2e/'], setupFilesAfterEnv: ['/setupTests.ts'], testMatch: ['**/__tests__/**/*.+(ts|tsx|js)', '**/?(*.)+(spec|test).+(ts|tsx|js)'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 005ab205b..5d675a671 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "@chromatic-com/storybook": "^1.6.1", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.6.0", + "@playwright/test": "^1.47.2", "@storybook/addon-essentials": "^8.2.1", "@storybook/addon-interactions": "^8.2.1", "@storybook/addon-links": "^8.2.1", @@ -3802,6 +3803,21 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.2.tgz", + "integrity": "sha512-jTXRsoSPONAs8Za9QEQdyjFn+0ZQFjCiIztAIF6bi1HqhBzG9Ma7g1WotyiGqFSBRZjIEqMdT8RUlbk1QVhzCQ==", + "dev": true, + "dependencies": { + "playwright": "1.47.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.28", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", @@ -19048,6 +19064,50 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.2.tgz", + "integrity": "sha512-nx1cLMmQWqmA3UsnjaaokyoUpdVaaDhJhMoxX2qj3McpjnsqFHs516QAKYhqHAgOP+oCFTEOCOAaD1RgD/RQfA==", + "dev": true, + "dependencies": { + "playwright-core": "1.47.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.2.tgz", + "integrity": "sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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/frontend/package.json b/frontend/package.json index 574c823c6..4c9bf5328 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "test": "jest", "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "e2e": "npx playwright test" }, "dependencies": { "@emotion/react": "^11.11.4", @@ -27,6 +28,7 @@ "@chromatic-com/storybook": "^1.6.1", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.6.0", + "@playwright/test": "^1.47.2", "@storybook/addon-essentials": "^8.2.1", "@storybook/addon-interactions": "^8.2.1", "@storybook/addon-links": "^8.2.1", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 000000000..c5b61df34 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,70 @@ +import { defineConfig, devices } from '@playwright/test'; + +import dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '.env.local') }); + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + // reporter: [['./e2e/slack-reporter.ts'], ['html'], ['list']], + reporter: [['html'], ['list']], + + use: { + /* `await page.goto('/')`와 같은 μ•‘μ…˜μ—μ„œ μ‚¬μš©ν•  κΈ°λ³Έ URL μ„€μ • */ + baseURL: process.env.DOMAIN_URL, + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* μ†ŒμŠ€ μ½”λ“œμ— test.onlyλ₯Ό 남겨둔 경우, CIμ—μ„œ λΉŒλ“œλ₯Ό μ‹€νŒ¨ μ²˜λ¦¬ν•©λ‹ˆλ‹€. */ + // forbidOnly: !!process.env.CI, + /* CI ν™˜κ²½μ—μ„œλ§Œ μž¬μ‹œλ„ μ„€μ • */ + // retries: process.env.CI ? 2 : 0, + /* CI ν™˜κ²½μ—μ„œ 병렬 ν…ŒμŠ€νŠΈ μ‹€ν–‰ λΉ„ν™œμ„±ν™” */ + // workers: process.env.CI ? 1 : undefined, + + /* λͺ¨λ°”일 λ·°ν¬νŠΈμ— λŒ€ν•œ ν…ŒμŠ€νŠΈ */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* λΈŒλžœλ””λ“œ λΈŒλΌμš°μ €μ— λŒ€ν•œ ν…ŒμŠ€νŠΈ */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* ν…ŒμŠ€νŠΈλ₯Ό μ‹œμž‘ν•˜κΈ° 전에 둜컬 개발 μ„œλ²„ μ‹€ν–‰ */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts index 3818afe95..0529e78de 100644 --- a/frontend/src/env.d.ts +++ b/frontend/src/env.d.ts @@ -8,5 +8,6 @@ declare namespace NodeJS { PROD_URL: string; DEV_URL: string; DOMAIN_URL: string; + SLACK_WEBHOOK_URL: string; } }