diff --git a/.github/workflows/web-develop.yml b/.github/workflows/web-develop.yml index 1c62d5ad6..395f65bac 100644 --- a/.github/workflows/web-develop.yml +++ b/.github/workflows/web-develop.yml @@ -58,6 +58,17 @@ jobs: - name: Run tests and generate coverage file run: npm run ci:test + - 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 + - name: Upload coverage reports to Codecov continue-on-error: true uses: codecov/codecov-action@v4.0.1 diff --git a/.github/workflows/web-tag.yml b/.github/workflows/web-tag.yml index 57aef2c3d..ca3ece7ae 100644 --- a/.github/workflows/web-tag.yml +++ b/.github/workflows/web-tag.yml @@ -50,6 +50,17 @@ jobs: - name: Run API tests run: npm run api:test + - 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 + - name: Shut down database run: docker stop github_action_postgresql diff --git a/.github/workflows/web-test.yml b/.github/workflows/web-test.yml index 4fbba74b4..46737e3a6 100644 --- a/.github/workflows/web-test.yml +++ b/.github/workflows/web-test.yml @@ -45,24 +45,19 @@ jobs: - name: Run NextJS build run: npm run build - # - name: Run Cypress tests - # run: npm run cy:test - - # ... Generate LCOV files or download it from a different job - name: Run tests and generate coverage file run: npm run ci:test - # - name: Setup LCOV - # uses: hrishikesh-kadam/setup-lcov@v1 - # - name: Report code coverage - # uses: zgosalvez/github-actions-report-lcov@v3 - # with: - # coverage-files: ./app/lcov*.info - # minimum-coverage: 40 - # artifact-name: code-coverage-report - # github-token: ${{ secrets.GITHUB_TOKEN }} - # working-directory: ./app - # update-comment: true + - 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 - name: Upload coverage reports to Codecov continue-on-error: true diff --git a/app/.gitignore b/app/.gitignore index efa93ea74..5df1908a6 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -35,4 +35,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts - +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/app/README.md b/app/README.md index 3a624864a..b72d1d6a7 100644 --- a/app/README.md +++ b/app/README.md @@ -95,3 +95,15 @@ Then, you can run the app in a container: ```bash docker run -p 3000:3000 ghcr.io/open-earth-foundation/citycatalyst ``` + +### End to end testing + +We use Playwright to run automated E2E tests. + +Setup: `npx playwright install --with-deps` + +Run: `npm run e2e:test` + +### API unit tests + +Run: `npm run api:test` diff --git a/app/cypress/e2e/signup.cy.ts b/app/cypress/e2e/signup.cy.ts deleted file mode 100644 index a71a42e31..000000000 --- a/app/cypress/e2e/signup.cy.ts +++ /dev/null @@ -1,58 +0,0 @@ -describe("Signup page", () => { - it("redirects to dashboard after entering correct data", () => { - // prevent API from being called (response stub) - cy.intercept("POST", "/api/v0/auth/register", { - statusCode: 200, - }).as("register"); - - cy.visit("/auth/signup"); - cy.contains("Sign Up"); - cy.get('input[name="name"]').type("Test Account"); - cy.get('input[name="email"]').type("signup-test@openearth.org"); - cy.get('input[name="password"]').type("Password123!"); - cy.get('input[name="confirmPassword"]').type("Password123!"); - cy.get('input[name="inviteCode"]').type("123456"); - cy.get('input[name="acceptTerms"]') - .siblings(".chakra-checkbox__control") - .click(); - cy.get('button[type="submit"]').click(); - cy.wait("@register"); // make sure API request is sent - - cy.url().should("contain", "/auth/check-email"); - cy.contains("Check Your Email"); - }); - - it("shows errors when entering invalid data", () => { - cy.visit("/auth/signup"); - cy.contains("Sign Up"); - cy.get('input[name="name"]').type("asd"); - cy.get('input[name="email"]').type("testopenearthorg"); - cy.get('input[name="password"]').type("Pas"); - cy.get('input[name="confirmPassword"]').type("Pa1"); - cy.get('input[name="inviteCode"]').type("12345"); - cy.get('button[type="submit"]').click(); - - cy.url().should("contain", "/auth/signup"); - cy.contains("valid email address"); - cy.contains("Minimum length"); - cy.contains("Invalid invite code"); - cy.contains("Please accept the terms"); - }); - - it("should require matching passwords", () => { - cy.visit("/auth/signup"); - cy.contains("Sign Up"); - cy.get('input[name="name"]').type("Test Account"); - cy.get('input[name="email"]').type("test@openearth.org"); - cy.get('input[name="password"]').type("Password1"); - cy.get('input[name="confirmPassword"]').type("Password2"); - cy.get('input[name="inviteCode"]').type("123456"); - cy.get('input[name="acceptTerms"]') - .siblings(".chakra-checkbox__control") - .click(); - cy.get('button[type="submit"]').click(); - - cy.url().should("contain", "/auth/signup"); - cy.contains("Passwords don't match"); - }); -}); diff --git a/app/e2e/signup.spec.ts b/app/e2e/signup.spec.ts new file mode 100644 index 000000000..72409c4ae --- /dev/null +++ b/app/e2e/signup.spec.ts @@ -0,0 +1,90 @@ +import { test, expect, type Page } from "@playwright/test"; +import { randomUUID } from "node:crypto"; + +async function expectText(page: Page, text: string) { + await expect(page.getByText(text).first()).toBeVisible(); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/en/auth/signup"); +}); + +test.describe("Signup", () => { + test("should navigate to signup from login", async ({ page }) => { + await page.goto("/"); + const link = page.getByText("Sign up"); + await expect(link).toBeVisible(); + await link.click(); + await expect( + page.getByRole("heading", { name: "Sign Up to City Catalyst" }), + ).toBeVisible(); + }); + + test("should redirect to dashboard after entering correct data", async ({ + page, + }) => { + await expect( + page.getByRole("heading", { name: "Sign Up to City Catalyst" }), + ).toBeVisible(); + + const email = `e2e-test+${randomUUID()}@example.com`; + + await page.getByPlaceholder("Your full name").fill("Test User"); + await page.getByPlaceholder("e.g. youremail@domain.com").fill(email); + await page.getByLabel("Password", { exact: true }).fill("Test123"); + await page.getByLabel("Confirm Password").fill("Test123"); + await page.getByPlaceholder("Enter the code you received").fill("123456"); + await page + .locator('input[name="acceptTerms"] + .chakra-checkbox__control') + .click(); + await page.getByRole("button", { name: "Create Account" }).click(); + + await expect(page).toHaveURL( + `/en/auth/check-email?email=${email.replace("@", "%40")}`, + ); + }); + + test("should show errors when entering invalid data", async ({ page }) => { + await expect( + page.getByRole("heading", { name: "Sign Up to City Catalyst" }), + ).toBeVisible(); + + await page.getByPlaceholder("Your full name").fill("asd"); + await page + .getByPlaceholder("e.g. youremail@domain.com") + .fill("testopenearthorg"); + await page.getByLabel("Password", { exact: true }).fill("Pas"); + await page.getByLabel("Confirm Password").fill("Pa1"); + await page.getByPlaceholder("Enter the code you received").fill("12345"); + await page.getByRole("button", { name: "Create Account" }).click(); + + await expect(page).toHaveURL(`/en/auth/signup`); + await expectText(page, "valid email address"); + await expectText(page, "Minimum length"); + await expectText(page, "Invalid invite code"); + await expectText(page, "Please accept the terms"); + }); + + test("should require matching passwords", async ({ page }) => { + await expect( + page.getByRole("heading", { name: "Sign Up to City Catalyst" }), + ).toBeVisible(); + + await page.getByPlaceholder("Your full name").fill("Test Account"); + await page + .getByPlaceholder("e.g. youremail@domain.com") + .fill("e2e-test-fail@example.com"); + await page.getByLabel("Password", { exact: true }).fill("Password1"); + await page.getByLabel("Confirm Password").fill("Password2"); + await page.getByPlaceholder("Enter the code you received").fill("123456"); + await page + .locator('input[name="acceptTerms"] + .chakra-checkbox__control') // sibling + .click(); + await page.getByRole("button", { name: "Create Account" }).click(); + + await expect(page).toHaveURL(`/en/auth/signup`); + await expectText(page, "Passwords don't match"); + }); + + test.skip("should correctly handle and pass callbackUrl", () => {}); +}); diff --git a/app/package-lock.json b/app/package-lock.json index 7d315f62f..9775a569e 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -77,6 +77,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@playwright/test": "^1.43.1", "@storybook/addon-essentials": "^7.6.16", "@storybook/addon-interactions": "^7.4.5", "@storybook/addon-links": "^7.6.17", @@ -4699,6 +4700,21 @@ "node": ">=14" } }, + "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/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz", @@ -21465,6 +21481,50 @@ "node": ">=10" } }, + "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/pnp-webpack-plugin": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.7.0.tgz", diff --git a/app/package.json b/app/package.json index a3468bd0a..e73488c7c 100644 --- a/app/package.json +++ b/app/package.json @@ -8,8 +8,10 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test": "npm run api:test & npm run cy:test", + "test": "npm run api:test & npm run e2e:test", "api:test": "glob -c \"tsx --no-warnings --test\" \"./tests/**/*.test.ts\"", + "e2e:test": "npx playwright test", + "e2e:test:head": "npx playwright test -- --headed", "test-single": "tsx --no-warnings --test", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", @@ -22,9 +24,6 @@ "db:gen-seed": "sequelize-cli seed:generate --name", "sync-catalogue": "tsx scripts/catalogue-sync.ts", "ci:test": "tsx --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info tests/**/*.test.ts", - "cy:open": "cypress open", - "cy:run": "cypress run", - "cy:test": "start-server-and-test start http://localhost:3000/en/auth/login cy:run", "prettier": "npx prettier . --write", "email": "email dev --dir src/lib/emails", "create-admin": "tsx scripts/create-admin.ts" @@ -99,6 +98,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@playwright/test": "^1.43.1", "@storybook/addon-essentials": "^7.6.16", "@storybook/addon-interactions": "^7.4.5", "@storybook/addon-links": "^7.6.17", diff --git a/app/playwright.config.ts b/app/playwright.config.ts new file mode 100644 index 000000000..9ea1a49ef --- /dev/null +++ b/app/playwright.config.ts @@ -0,0 +1,77 @@ +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: "./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/app/scripts/start-db.sh b/app/scripts/start-db.sh new file mode 100755 index 000000000..b06bdeda2 --- /dev/null +++ b/app/scripts/start-db.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +DB_USER="${POSTGRES_USER:=postgres}" +DB_PASSWORD="${POSTGRES_PASSWORD:=citycatalyst}" +DB_NAME="${POSTGRES_DB:=development}" +DB_PORT="${POSTGRES_PORT:=5432}" +DB_HOST="${POSTGRES_HOST:=localhost}" + +if [[ -z "${SKIP_DOCKER}" ]]; then + docker run \ + -e POSTGRES_USER=${DB_USER} \ + -e POSTGRES_PASSWORD=${DB_PASSWORD} \ + -e POSTGRES_DB=${DB_NAME} \ + -p "${DB_PORT}":5432 \ + -d postgres \ + postgres -N 1000 +fi diff --git a/app/tests-examples/demo-todo-app.spec.ts b/app/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 000000000..2fd6016fe --- /dev/null +++ b/app/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); +}