diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..6afa2add --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,35 @@ +name: Qwerty E2E Workflows +on: + push: + branches: + - master + - dev/e2e +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Cache node modules + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + - name: Install dependencies + run: npm i + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run all workflows + run: npm run test:e2e || echo "status=failure" >> $GITHUB_ENV + continue-on-error: true + - uses: actions/upload-artifact@v3 + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + - name: Check workflows status + run: if [ "$status" = "failure" ]; then exit 1; fi diff --git a/.gitignore b/.gitignore index fad1fb4d..8c7ab4bd 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,8 @@ pnpm-lock.yaml .env stats.html -.idea \ No newline at end of file +.idea +/test-results/ +/blob-report/ +/playwright/.cache/ +playwright-report \ No newline at end of file diff --git a/package.json b/package.json index abdfb325..a61d6e41 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "start": "vite", "build": "cross-env CI=false vite build --base=./", "test": "echo \"No tests\"", + "test:e2e": "playwright test", "lint": "eslint .", "prettier": "prettier --write .", "prepare": "husky install" @@ -80,6 +81,7 @@ }, "devDependencies": { "@iconify/json": "^2.2.56", + "@playwright/test": "^1.40.1", "@svgr/core": "^7.0.0", "@svgr/plugin-jsx": "^7.0.0", "@tailwindcss/forms": "^0.5.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..c893bec3 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,78 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests/e2e', + /* 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: [['list'], ['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('/')`. */ + baseURL: 'https://qwerty.kaiyi.cool', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + timeout: 30 * 1000, // default 30s + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}) diff --git a/tests/e2e/dictionary.spec.ts b/tests/e2e/dictionary.spec.ts new file mode 100644 index 00000000..ead268c4 --- /dev/null +++ b/tests/e2e/dictionary.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from '@playwright/test' + +test.describe('Dictionary manage', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.getByLabel('关闭提示').click() + }) + + test('Homepage default dictionary', async ({ page }) => { + await expect(await page.getByText('CET-4').isVisible()).toBeTruthy() + + await page.getByText('CET-4').hover() + await expect(await page.getByText('词典切换').isVisible()).toBeTruthy() + }) + + test('Switch language', async ({ page }) => { + await page.getByText('CET-4').click() + await page.waitForURL('**/gallery') + + await expect(await page.getByRole('radio', { name: /^英语$/ }).getAttribute('aria-checked')).toBeTruthy() + + await page.getByRole('radio', { name: /^日语$/ }).click() + await expect(await page.getByRole('radio', { name: /^日语$/ }).getAttribute('aria-checked')).toBeTruthy() + await expect( + await page + .getByRole('button', { name: /日语常见词/g }) + .first() + .isVisible(), + ).toBeTruthy() + + await page.getByRole('radio', { name: /^Code$/ }).click() + await expect(await page.getByRole('radio', { name: /^Code$/ }).getAttribute('aria-checked')).toBeTruthy() + await expect( + await page + .getByRole('button', { name: /Coder Dict/g }) + .first() + .isVisible(), + ).toBeTruthy() + }) + + test('Switch category', async ({ page }) => { + await page.getByText('CET-4').click() + await page.waitForURL('**/gallery') + + await expect(await page.getByRole('radio', { name: /^大学英语$/ }).getAttribute('aria-checked')).toBeTruthy() + + await page.getByRole('radio', { name: /^考研$/ }).click() + await expect(await page.getByRole('radio', { name: /^考研$/ }).getAttribute('aria-checked')).toBeTruthy() + await expect(await page.getByRole('button', { name: /考研/g }).first().isVisible()).toBeTruthy() + + await page.getByRole('radio', { name: /^GRE$/ }).click() + await expect(await page.getByRole('radio', { name: /^GRE$/ }).getAttribute('aria-checked')).toBeTruthy() + await expect(await page.getByRole('button', { name: /GRE/g }).first().isVisible()).toBeTruthy() + }) + + test('Switch dictionary', async ({ page }) => { + await page.getByText('CET-4').click() + await page.waitForURL('**/gallery') + + await page + .getByRole('button', { name: /六级巧记速记/g }) + .first() + .click() + await page.getByRole('heading', { name: '第 2 章' }).click() + + await page.waitForURL('**/') + await expect(await page.getByRole('button', { name: '第 2 章' }).first().isVisible()).toBeTruthy() + }) + + test('Close dictionary settings', async ({ page }) => { + await page.getByText('CET-4').click() + await page.waitForURL('**/gallery') + // should use testId + await page.locator('main > div > svg').first().click() + + await page.waitForURL('**/') + await expect(await page.getByText('Start').first().isVisible()).toBeTruthy() + }) + + test('Switch dictionary chapter', async ({ page }) => { + await page.getByText('第 1 章').first().hover() + await expect(await page.getByText('章节切换').isVisible()).toBeTruthy() + + await page.getByText('第 1 章').click() + await page.getByRole('option', { name: '第 2 章' }).click() + + await page.getByText('第 2 章').first().hover() + await expect(await page.getByText('章节切换').isVisible()).toBeTruthy() + }) +}) diff --git a/tests/e2e/main.spec.ts b/tests/e2e/main.spec.ts new file mode 100644 index 00000000..2a36d2d4 --- /dev/null +++ b/tests/e2e/main.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test' + +test.describe('Main page', () => { + test('has title', async ({ page }) => { + await page.goto('/') + + await expect(await page.locator('h1').getByText('Qwerty Learner').isVisible()).toBeTruthy() + }) + + // you should run 'yarn update:snapshots' before this test, create base snapshots for visual comparison + // test('visual comparison', async ({ page }) => { + // await page.goto('/'); + // await expect(page).toHaveScreenshot(); + // }); +}) diff --git a/tests/e2e/practice-list.spec.ts b/tests/e2e/practice-list.spec.ts new file mode 100644 index 00000000..8bd8df74 --- /dev/null +++ b/tests/e2e/practice-list.spec.ts @@ -0,0 +1,26 @@ +import { test, expect, Page } from '@playwright/test' + +test.describe('Practice List', () => { + test.beforeEach(async ({ page }) => { + test.slow() + await page.goto('/') + await page.getByLabel('关闭提示').click() + await page.waitForTimeout(1000) + + // should use testId + await page.locator('#root > div').locator('.fixed').locator('svg').last().click() + await page.waitForTimeout(1000) + }) + + test('Practice list button click to open the list ', async ({ page }) => { + await expect(await page.getByRole('heading', { name: 'CET-4 第 1 章' }).isVisible()).toBeTruthy() + // should use testId + await page.locator('#headlessui-portal-root').locator('div > p', { hasText: 'cancel' }).click() + }) + + test('Close practice list', async ({ page }) => { + await page.getByRole('heading', { name: 'CET-4 第 1 章' }).getByRole('img').click() + await page.waitForTimeout(1000) + await expect(await page.locator('h3', { hasText: 'CET-4 第 1 章' }).isVisible()).not.toBeTruthy() + }) +}) diff --git a/tests/e2e/practice.spec.ts b/tests/e2e/practice.spec.ts new file mode 100644 index 00000000..25c9a085 --- /dev/null +++ b/tests/e2e/practice.spec.ts @@ -0,0 +1,121 @@ +import { test, expect, Page } from '@playwright/test' + +const pressWord = async (page: Page, word: string) => { + const letters = word.split('') + for (const letter of letters) { + await page.keyboard.press(letter) + } +} + +const pressWords = async (page: Page, words: string[]) => { + for (const word of words) { + await pressWord(page, word) + await page.waitForTimeout(300) + } +} + +test.describe('Practice', () => { + test.beforeEach(async ({ page }) => { + test.slow() + await page.goto('/') + await page.getByLabel('关闭提示').click() + }) + + test('Press any key to start', async ({ page }) => { + await expect(await page.getByText('按任意键开始').isVisible()).toBeTruthy() + + await page.keyboard.press('Enter') + await page.waitForTimeout(300) + await expect(await page.locator('p').getByText('按任意键开始').isHidden()).toBeTruthy() + }) + + test('Enter the correct word', async ({ page }) => { + await page.keyboard.press('Enter') + + await pressWord(page, 'cancel') + + await page.locator('div', { hasText: '正确率' }).locator('span', { hasText: '100' }).click() + + // auto show next word: explosive + await expect(await page.locator('span', { hasText: /^e$/ }).first().isVisible()).toBeTruthy() + await expect(await page.locator('span', { hasText: /^x$/ }).isVisible()).toBeTruthy() + await expect(await page.locator('span', { hasText: /^p$/ }).isVisible()).toBeTruthy() + await expect(await page.locator('span', { hasText: /^l$/ }).isVisible()).toBeTruthy() + await expect(await page.locator('span', { hasText: /^o$/ }).isVisible()).toBeTruthy() + await expect(await page.locator('span', { hasText: /^s$/ }).isVisible()).toBeTruthy() + await expect(await page.locator('span', { hasText: /^i$/ }).isVisible()).toBeTruthy() + await expect(await page.locator('span', { hasText: /^v$/ }).isVisible()).toBeTruthy() + await expect(await page.locator('span', { hasText: /^e$/ }).last().isVisible()).toBeTruthy() + }) + + test('Enter the wrong word', async ({ page }) => { + await page.keyboard.press('Enter') + + await pressWord(page, 'canca') + + await page.locator('div', { hasText: '输入数' }).locator('span', { hasText: /^5$/ }).first().click() + await page.locator('div', { hasText: '正确数' }).locator('span', { hasText: /^4$/ }).first().click() + await page.waitForTimeout(500) + await page.locator('div', { hasText: '正确率' }).locator('span', { hasText: /^80$/ }).click() + }) + + test('Enter the correct letter, should show green color', async ({ page }) => { + await page.keyboard.press('Enter') + + await page.keyboard.press('c') + await expect(page.locator('span', { hasText: /^c$/ }).first()).toHaveClass(/text-green-600/) + + await page.keyboard.press('a') + await expect(page.locator('span', { hasText: /^a$/ })).toHaveClass(/text-green-600/) + + await page.keyboard.press('n') + await expect(page.locator('span', { hasText: /^n$/ })).toHaveClass(/text-green-600/) + }) + + test('Enter the wrong letter, should show red color', async ({ page }) => { + await page.keyboard.press('Enter') + await expect(page.locator('span', { hasText: /^c$/ }).first()).toHaveClass(/text-gray-600/) + + await page.keyboard.press('a') + await expect(page.locator('span', { hasText: /^c$/ }).first()).toHaveClass(/text-red-600/) + + await page.waitForTimeout(500) + await expect(page.locator('span', { hasText: /^c$/ }).first()).toHaveClass(/text-gray-600/) + }) + + test('Complete the exercises for 1 chapter, enter the next chapter', async ({ page }) => { + await page.keyboard.press('Enter') + + const chapter1 = [ + 'cancel', + 'explosive', + 'numerous', + 'govern', + 'analyse', + 'discourage', + 'resemble', + 'remote', + 'salary', + 'pollution', + 'pretend', + 'kettle', + 'wreck', + 'drunk', + 'calculate', + 'persistent', + 'sake', + 'conceal', + 'audience', + 'meanwhile', + ] + + await pressWords(page, chapter1) + + await expect(await page.getByText('100%').isVisible()).toBeTruthy + await expect(await page.getByText('表现不错!全对了!').isVisible()).toBeTruthy() + + await page.getByRole('button', { name: '下一章节' }).click() + + await expect(await page.getByText('第 2 章').first().isVisible()).toBeTruthy() + }) +}) diff --git a/tests/e2e/theme.spec.ts b/tests/e2e/theme.spec.ts new file mode 100644 index 00000000..4f019f21 --- /dev/null +++ b/tests/e2e/theme.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test' + +test.describe('Theme switch', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.getByLabel('关闭提示').click() + }) + + test('Has theme switch button', async ({ page }) => { + await expect(await page.getByLabel('开关深色模式').isVisible()).toBeTruthy() + }) + + test('Theme mode switch', async ({ page }) => { + // light to dark + await page.getByLabel('开关深色模式').click() + await expect(await page.locator('html').getAttribute('class')).toBe('dark') + + await page.waitForTimeout(500) + + // dark to light + await page.getByLabel('开关深色模式').click() + await expect(await page.locator('html').getAttribute('class')).toBe('') + }) +}) diff --git a/yarn.lock b/yarn.lock index 8308c798..f4a59199 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1460,6 +1460,13 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@playwright/test@^1.40.1": + version "1.40.1" + resolved "https://registry.npmmirror.com/@playwright/test/-/test-1.40.1.tgz#9e66322d97b1d74b9f8718bacab15080f24cde65" + integrity sha512-EaaawMTOeEItCRvfmkI9v6rBkF1svM8wjl/YPRrg2N2Wmp+4qJYkWtJsbew1szfKKDm6fPLy4YAanBhIlf9dWw== + dependencies: + playwright "1.40.1" + "@radix-ui/number@1.0.0": version "1.0.0" resolved "https://registry.npmmirror.com/@radix-ui/number/-/number-1.0.0.tgz" @@ -3734,7 +3741,7 @@ fs.realpath@^1.0.0: resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@~2.3.2: +fsevents@2.3.2, fsevents@~2.3.2: version "2.3.2" resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -4969,6 +4976,20 @@ pirates@^4.0.1: resolved "https://registry.npmmirror.com/pirates/-/pirates-4.0.5.tgz" integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ== +playwright-core@1.40.1: + version "1.40.1" + resolved "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.40.1.tgz#442d15e86866a87d90d07af528e0afabe4c75c05" + integrity sha512-+hkOycxPiV534c4HhpfX6yrlawqVUzITRKwHAmYfmsVreltEl6fAZJ3DPfLMOODw0H3s1Itd6MDCWmP1fl/QvQ== + +playwright@1.40.1: + version "1.40.1" + resolved "https://registry.npmmirror.com/playwright/-/playwright-1.40.1.tgz#a11bf8dca15be5a194851dbbf3df235b9f53d7ae" + integrity sha512-2eHI7IioIpQ0bS1Ovg/HszsN/XKNwEG1kbzSDDmADpclKc7CyqkHw7Mg2JCz/bbCxg25QUPcjksoMW7JcIFQmw== + dependencies: + playwright-core "1.40.1" + optionalDependencies: + fsevents "2.3.2" + postcss-functions@^3: version "3.0.0" resolved "https://registry.npmmirror.com/postcss-functions/-/postcss-functions-3.0.0.tgz"