diff --git a/.github/workflows/playwright copy.yml b/.github/workflows/playwright copy.yml new file mode 100644 index 00000000..057a1fb4 --- /dev/null +++ b/.github/workflows/playwright copy.yml @@ -0,0 +1,165 @@ +name: Playwright Test Preparation + +pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + get-changed-files: + runs-on: ubuntu-latest + outputs: + changed-files: ${{ steps.changed_files.outputs.changed-files }} + steps: + - name: Get Changed Files + id: changed_files + uses: actions/github-script@v6 + with: + script: | + const { context } = require('@actions/github'); + const fs = require('fs'); + const changedFiles = fs.readFileSync(context.payload.pull_request.patch_url, 'utf-8') + .match(/^\+(?:\s*\w+\/)*(\S+\.ts)$/gm) + .map(line => line.replace('+', '').trim()); + core.setOutput('changed-files', JSON.stringify(changedFiles)); + + analyze-and-trigger: + runs-on: ubuntu-latest + needs: get-changed-files + steps: + - name: Call Test Selection API + id: test_selection + run: | + changed_files=${{ steps.changed_files.outputs.changed-files }} + tests=$(curl -X POST 'https://tests-selection.azurewebsites.net/api' \ + -H 'Content-Type: application/json' \ + -d "$changed_files") + echo "::set-output name=selected-tests::$tests" + + - name: Analyze Test Timings and Determine Shard Count + uses: actions/github-script@v6 + run: | + const testsData = JSON.parse('${{ steps.test_selection.outputs.selected-tests }}'); + const totalTestDuration = testsData.reduce((sum, test) => sum + test.duration, 0); + const targetDurationPerShard = 5 * 60; // assuming 5 minutes per shard + const optimalShardCount = Math.ceil(totalTestDuration / targetDurationPerShard); + const shardIndices = []; + for (let i = 1; i <= optimalShardCount; i++) { + shardIndices.push(i); + } + core.setOutput('optimal_shard_count', optimalShardCount); + core.setOutput('shard_indices', JSON.stringify(shardIndices)); + + - name: Trigger Workflow 2 with Shard Count + uses: benc-uk/workflow-dispatch@v1 + with: + workflow: playwright.yml + inputs: '{"shardCount": "${{ optimal_shard_count }}"}' + + +name: Run Playwright Tests + +on: + workflow_dispatch: + inputs: + shardCount: + description: 'Number of shards to use' + required: true + type: string + shardIndices: + description: 'Array of shard indices to execute' + required: true + type: string + +jobs: + check-dependencies: + runs-on: ubuntu-latest + needs: get-changed-files + outputs: + should-install-fresh-deps: ${{ steps.check_deps.outputs.should-install }} + steps: + - name: Check if package files changed + id: check_deps + run: | + changed_files=(${CHANGED_FILES}) + if [[ " ${changed_files[*]} " =~ "package.json" || " ${changed_files[*]} " =~ "package-lock.json" ]]; then + echo "::set-output name=should-install::true" + else + echo "::set-output name=should-install::false" + fi + + install-and-cache-node-deps: + runs-on: ubuntu-latest + needs: check-dependencies + if: steps.check_deps.outputs.should-install == 'true' + steps: + - name: Install dependencies (Vue app) + working-directory: vue-app + run: npm ci + + - name: Cache Node Dependencies + uses: actions/cache@v3 + with: + path: vue-app/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + build-and-start-vue: + runs-on: ubuntu-latest + needs: check-dependencies + if: steps.check_deps.outputs.should-install == 'false' + steps: + - name: Use cached Node dependencies + uses: actions/cache@v3 + with: + path: vue-app/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Start Vue app + run: npm run dev & # Runs in background + + - name: Make sure Vue server is up and running + run: | + until $(curl --output /dev/null --silent --head --fail http://localhost:3000); do + printf '.' + sleep 3 + done + echo "Vue server is up and running!" + + playwright-setup: + runs-on: ubuntu-latest + steps: + - name: Cache Playwright Dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install Playwright (if not cached) + if: steps.cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps + + run-playwright-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shardIndex: ${{ fromJson(github.event.inputs.outputs.shard_indices) }} + shardTotal: [ ${{ github.event.inputs.shardCount }} ] + timeout-minutes: 10 + needs: [call-test-selection-api, get-changed-files, playwright-setup, build-and-start-vue] + env: + CI: true + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: Run Playwright Tests + run: | + tests=${{ steps.test_selection.outputs.selected-tests }} + npx playwright test $tests --max-failures=2 --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..357b37ee --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,146 @@ +name: Clear Playwright Cache + +on: + schedule: + - cron: '0 0 */7 * *' # runs weekly + +jobs: + clear-cache: + runs-on: ubuntu-latest + steps: + - uses: actions/cache@v3 + with: + path: ~/.cache/ms-playwright + restore-keys: | + ${{ runner.os }}-playwright- + + +name: run e2e tests for vue + +pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + get-changed-files: + runs-on: ubuntu-latest + outputs: + changed-files: ${{ steps.changed_files.outputs.changed-files }} + steps: + - name: Get Changed Files + id: changed_files + uses: actions/github-script@v6 + with: + script: | + const { context } = require('@actions/github'); + const fs = require('fs'); + const changedFiles = fs.readFileSync(context.payload.pull_request.patch_url, 'utf-8') + .match(/^\+(?:\s*\w+\/)*(\S+\.ts)$/gm) + .map(line => line.replace('+', '').trim()); + core.setOutput('changed-files', JSON.stringify(changedFiles)); + + check-dependencies: + runs-on: ubuntu-latest + needs: get-changed-files + outputs: + should-install-fresh-deps: ${{ steps.check_deps.outputs.should-install }} + steps: + - name: Check if package files changed + id: check_deps + run: | + changed_files=(${CHANGED_FILES}) + if [[ " ${changed_files[*]} " =~ "package.json" || " ${changed_files[*]} " =~ "package-lock.json" ]]; then + echo "::set-output name=should-install::true" + else + echo "::set-output name=should-install::false" + fi + + install-and-cache-node-deps: + runs-on: ubuntu-latest + needs: check-dependencies + if: steps.check_deps.outputs.should-install == 'true' + steps: + - name: Install dependencies (Vue app) + working-directory: vue-app + run: npm ci + + - name: Cache Node Dependencies + uses: actions/cache@v3 + with: + path: vue-app/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + build-and-start-vue: + runs-on: ubuntu-latest + needs: check-dependencies + if: steps.check_deps.outputs.should-install == 'false' + steps: + - name: Use cached Node dependencies + uses: actions/cache@v3 + with: + path: vue-app/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Start Vue app + run: npm run dev & # Runs in background + + - name: Make sure Vue server is up and running + run: | + until $(curl --output /dev/null --silent --head --fail http://localhost:3000); do + printf '.' + sleep 3 + done + echo "Vue server is up and running!" + + playwright-setup: + runs-on: ubuntu-latest + steps: + - name: Cache Playwright Dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install Playwright (if not cached) + if: steps.cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps + + call-test-selection-api: + runs-on: ubuntu-latest + needs: [get-changed-files] + steps: + - name: Call the Test Selection API + id: test_selection + run: | + changed_files=${{ steps.changed_files.outputs.changed-files }} + tests=$(curl -X POST 'https://tests-selection.azurewebsites.net/api' \ + -H 'Content-Type: application/json' \ + -d "$changed_files") + echo "selected-tests::$tests" + + run-playwright-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4] + shardTotal: [4] + timeout-minutes: 10 + needs: [call-test-selection-api, get-changed-files, playwright-setup, build-and-start-vue] + env: + CI: true + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: Run Playwright Tests + run: | + tests=${{ steps.test_selection.outputs.selected-tests }} + npx playwright test $tests --max-failures=2 --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} diff --git a/curriculum-front/.github/workflows/playwright.yml b/curriculum-front/.github/workflows/playwright.yml new file mode 100644 index 00000000..467190be --- /dev/null +++ b/curriculum-front/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/curriculum-front/.gitignore b/curriculum-front/.gitignore index bc11d85d..bbf9dad0 100644 --- a/curriculum-front/.gitignore +++ b/curriculum-front/.gitignore @@ -18,3 +18,7 @@ yarn-error.log* *.njsproj *.sln *.sw? +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/curriculum-front/e2e/example.spec.js b/curriculum-front/e2e/example.spec.js new file mode 100644 index 00000000..40eddb86 --- /dev/null +++ b/curriculum-front/e2e/example.spec.js @@ -0,0 +1,19 @@ +// @ts-check +const { test, expect } = require('@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/curriculum-front/package-lock.json b/curriculum-front/package-lock.json index 7f8a0b40..83944f16 100644 --- a/curriculum-front/package-lock.json +++ b/curriculum-front/package-lock.json @@ -21,6 +21,8 @@ "vuex-persist": "2.2.0" }, "devDependencies": { + "@playwright/test": "^1.43.1", + "@types/node": "^20.12.7", "@vue/cli-plugin-babel": "^3.0.1", "@vue/cli-plugin-eslint": "^3.0.1", "@vue/cli-plugin-pwa": "^3.0.1", @@ -2568,6 +2570,21 @@ "node": ">= 6" } }, + "node_modules/@playwright/test": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", + "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", + "dev": true, + "dependencies": { + "playwright": "1.43.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@saeris/vue-spinners": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@saeris/vue-spinners/-/vue-spinners-1.0.8.tgz", @@ -2921,10 +2938,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "12.12.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.14.tgz", - "integrity": "sha512-u/SJDyXwuihpwjXy7hOOghagLEV1KdAST6syfnOk6QZAMzZuWZqXy5aYYZbh8Jdpd4escVFP0MvftHNDb9pruA==", - "dev": true + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/normalize-package-data": { "version": "2.4.0", @@ -17514,6 +17534,50 @@ "node": ">=4" } }, + "node_modules/playwright": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", + "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", + "dev": true, + "dependencies": { + "playwright-core": "1.43.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", + "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", + "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/please-upgrade-node": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", @@ -21364,6 +21428,12 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", @@ -25605,6 +25675,15 @@ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", "dev": true }, + "@playwright/test": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", + "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", + "dev": true, + "requires": { + "playwright": "1.43.1" + } + }, "@saeris/vue-spinners": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@saeris/vue-spinners/-/vue-spinners-1.0.8.tgz", @@ -25911,10 +25990,13 @@ "dev": true }, "@types/node": { - "version": "12.12.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.14.tgz", - "integrity": "sha512-u/SJDyXwuihpwjXy7hOOghagLEV1KdAST6syfnOk6QZAMzZuWZqXy5aYYZbh8Jdpd4escVFP0MvftHNDb9pruA==", - "dev": true + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } }, "@types/normalize-package-data": { "version": "2.4.0", @@ -37487,6 +37569,31 @@ "find-up": "^2.1.0" } }, + "playwright": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", + "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.43.1" + }, + "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.43.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", + "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", + "dev": true + }, "please-upgrade-node": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", @@ -40617,6 +40724,12 @@ "is-typedarray": "^1.0.0" } }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", diff --git a/curriculum-front/package.json b/curriculum-front/package.json index 7669935d..9e1d3795 100644 --- a/curriculum-front/package.json +++ b/curriculum-front/package.json @@ -23,6 +23,8 @@ "vuex-persist": "2.2.0" }, "devDependencies": { + "@playwright/test": "^1.43.1", + "@types/node": "^20.12.7", "@vue/cli-plugin-babel": "^3.0.1", "@vue/cli-plugin-eslint": "^3.0.1", "@vue/cli-plugin-pwa": "^3.0.1", @@ -51,4 +53,4 @@ "git add" ] } -} \ No newline at end of file +} diff --git a/curriculum-front/playwright.config.js b/curriculum-front/playwright.config.js new file mode 100644 index 00000000..132835e5 --- /dev/null +++ b/curriculum-front/playwright.config.js @@ -0,0 +1,79 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +module.exports = defineConfig({ + testDir: './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: '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: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* 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/curriculum-front/tests-examples/demo-todo-app.spec.js b/curriculum-front/tests-examples/demo-todo-app.spec.js new file mode 100644 index 00000000..e2eb87ce --- /dev/null +++ b/curriculum-front/tests-examples/demo-todo-app.spec.js @@ -0,0 +1,449 @@ +// @ts-check +const { test, expect } = require('@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) { + // 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'); + } +} + +/** + * @param {import('@playwright/test').Page} page + * @param {number} expected + */ + async function checkNumberOfTodosInLocalStorage(page, expected) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +/** + * @param {import('@playwright/test').Page} page + * @param {number} expected + */ + async function checkNumberOfCompletedTodosInLocalStorage(page, expected) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).filter(i => i.completed).length === e; + }, expected); +} + +/** + * @param {import('@playwright/test').Page} page + * @param {string} title + */ +async function checkTodosInLocalStorage(page, title) { + return await page.waitForFunction(t => { + return JSON.parse(localStorage['react-todos']).map(i => i.title).includes(t); + }, title); +}