diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..9662b54 --- /dev/null +++ b/.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 install -g pnpm && pnpm install + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + - name: Run Playwright tests + run: pnpm exec playwright test + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 4ed771c..edd6bba 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ stats-*.json ## Panda styled-system styled-system-studio +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/atomic.test.ts b/e2e/atomic.test.ts new file mode 100644 index 0000000..5bbad3c --- /dev/null +++ b/e2e/atomic.test.ts @@ -0,0 +1,179 @@ +import { test, expect } from "@playwright/test"; + +test("test", async ({ page }) => { + // Inspect element + await page.goto("http://localhost:4173/"); + await page + .getByRole("button", { name: "Select an element to inspect" }) + .click(); + + // Toggle CSS declarations + await page.getByText("Atomic CSS Devtools [data-").click(); + expect(page.getByText("Atomic CSS Devtools [data-")).toHaveCSS( + "font-size", + "2.25rem" + ); + + await page.getByText("font-size", { exact: true }).click(); + expect(page.getByText("Atomic CSS Devtools [data-")).not.toHaveCSS( + "font-size", + "2.25rem" + ); + + await page.getByText("font-size", { exact: true }).click(); + expect(page.getByText("Atomic CSS Devtools [data-")).toHaveCSS( + "font-size", + "2.25rem" + ); + + expect(page.getByText("Atomic CSS Devtools [data-")).toHaveCSS( + "color", + "#eab308" + ); + await page.getByText("color", { exact: true }).click(); + + expect(page.getByText("Atomic CSS Devtools [data-")).not.toHaveCSS( + "color", + "#eab308" + ); + await page.getByLabel("color", { exact: true }).click(); + expect(page.getByText("Atomic CSS Devtools [data-")).toHaveCSS( + "color", + "#eab308" + ); + + // Group declarations by layer + await page.getByLabel("Group elements by @layer").click(); + + expect(page.getByText("color", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "▼ @layer utilities (3)" }).click(); + expect(page.getByText("color", { exact: true })).not.toBeVisible(); + + await page.getByRole("button", { name: "▶︎ @layer utilities (3)" }).click(); + expect(page.getByText("color", { exact: true })).toBeVisible(); + + expect(page.getByText("box-sizing", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "▼ @layer base (4)" }).click(); + + expect(page.getByText("box-sizing", { exact: true })).not.toBeVisible(); + await page.getByRole("button", { name: "▶︎ @layer base (4)" }).click(); + expect(page.getByText("box-sizing", { exact: true })).toBeVisible(); + + expect( + page.getByRole("button", { name: "▼ @layer utilities (3)" }) + ).toBeVisible(); + await page.getByLabel("Group elements by @layer").click(); + + expect( + page.getByRole("button", { name: "▼ @layer utilities (3)" }) + ).not.toBeVisible(); + await page.getByLabel("Toggle layer visibility").click(); + expect( + page.getByRole("button", { name: "▼ @layer utilities (3)" }) + ).toBeVisible(); + + await page.getByLabel("utilities(3)").uncheck(); + expect( + page.getByRole("button", { name: "▼ @layer utilities (3)" }) + ).not.toBeVisible(); + + await page.getByLabel("utilities(3)").check(); + expect( + page.getByRole("button", { name: "▼ @layer utilities (3)" }) + ).toBeVisible(); + + expect( + page.getByRole("button", { name: "▼ @layer reset (6)" }) + ).toBeVisible(); + await page.getByText("reset(6)").click(); + + expect( + page.getByRole("button", { name: "▼ @layer reset (6)" }) + ).not.toBeVisible(); + await page.getByText("reset(6)").click(); + expect( + page.getByRole("button", { name: "▼ @layer reset (6)" }) + ).toBeVisible(); + + // Inspect another element + await page + .getByRole("button", { name: "Select an element to inspect" }) + .click(); + await page + .getByRole("button", { name: "Select an element to inspect" }) + .click(); + + expect(page.getByText("min-width", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "▼ @layer recipes (8)" }).click(); + + expect(page.getByText("min-width", { exact: true })).not.toBeVisible(); + await page.getByRole("button", { name: "▶︎ @layer recipes (8)" }).click(); + + expect(page.getByText("border-radius", { exact: true })).toBeVisible(); + await page + .getByRole("button", { name: "▼ @layer recipes._base (24)" }) + .click(); + + expect(page.getByText("border-radius", { exact: true })).not.toBeVisible(); + await page + .getByRole("button", { name: "▶︎ @layer recipes._base (24)" }) + .click(); + + expect( + page.getByRole("button", { name: "▼ @layer recipes._base (24)" }) + ).toBeVisible(); + await page.getByText("recipes._base(24)").click(); + expect( + page.getByRole("button", { name: "▼ @layer recipes._base (24)" }) + ).not.toBeVisible(); + await page.getByLabel("recipes._base(24)").click(); + expect( + page.getByRole("button", { name: "▼ @layer recipes._base (24)" }) + ).toBeVisible(); + + expect( + page.getByRole("button", { name: "▼ (8)" }) + ).not.toBeVisible(); + await page.getByLabel("Group elements by @media").click(); + + expect(page.getByRole("button", { name: "▼ (8)" })).toBeVisible(); + await page.getByRole("button", { name: "▶︎ (8)" }).click(); + + // + // await page.getByPlaceholder("Filter").click(); + // await page.getByPlaceholder("Filter").fill("button"); + // await page.locator('[id="tooltip\\:\\:r1b9\\:\\:trigger"] > span').click(); + // await page.getByPlaceholder("Filter").click(); + // await page.getByPlaceholder("Filter").press("Meta+a"); + // await page.getByPlaceholder("Filter").fill("gap"); + // await page.locator(".w_16px").first().click(); + // await page.getByPlaceholder("Filter").click(); + // await page.getByPlaceholder("Filter").fill("gap"); + // await page.getByText("gap").click(); + // await page.getByText("gap").click(); + // await page.locator(".w_16px").first().click(); + // await page.getByPlaceholder("Filter").click(); + // await page.locator("#inline-styles").getByText("}").click(); + // await page + // .getByRole("button", { name: "Select an element to inspect" }) + // .click(); + // await page.getByText("Atomic CSS Devtools [data-").click(); + // await page.getByText("Atomic CSS Devtools [data-").click(); + // await page.getByText("element.style{").click(); + // await page.locator("#editable-key").fill("color"); + // await page.locator("#editable-key").press("Tab"); + // await page.locator("#editable-value").fill("red"); + // await page.locator("#editable-value").press("Tab"); + // await page.getByText("color:red;").click(); + // await page.getByText("red").click(); + // await page.getByText("red").press("ArrowRight"); + // await page.getByText("red").press("ArrowRight"); + // await page.getByText("red").press("ArrowRight"); + // await page.getByText("color:red;").click(); + // await page + // .locator("div") + // .filter({ hasText: "Atomic CSS Devtools [data-" }) + // .nth(3) + // .click(); + // await page.getByText("Atomic CSS Devtools [data-").click(); +}); diff --git a/e2e/example.spec.ts b/e2e/example.spec.ts new file mode 100644 index 0000000..e5c2368 --- /dev/null +++ b/e2e/example.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from "@playwright/test"; + +test.skip("has title", async ({ page }) => { + await page.goto("https://playwright.dev/"); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test.skip("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/package.json b/package.json index d3e51aa..f70cc85 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "atomic-css-devtools", "description": "manifest.json description", "private": true, - "version": "0.0.6-dev", + "version": "0.0.7-dev", "type": "module", "scripts": { "dev": "wxt", @@ -15,7 +15,11 @@ "postinstall": "wxt prepare", "test": "vitest", "typecheck": "tsc --noEmit", - "play": "vite" + "play": "vite", + "e2e": "pnpm exec playwright test", + "e2e:ui": "pnpm exec playwright test --ui", + "e2e:codegen": "pnpm exec playwright codegen localhost:4173", + "e2e:preview": "vite --port 4173" }, "imports": { "#components/*": "./components/*", @@ -42,6 +46,8 @@ "devDependencies": { "@pandacss/dev": "^0.37.1", "@park-ui/panda-preset": "^0.36.1", + "@playwright/test": "^1.43.1", + "@types/node": "^20.12.7", "@types/react": "^18.2.46", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..bfe3e83 --- /dev/null +++ b/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/pnpm-lock.yaml b/pnpm-lock.yaml index 9224696..e7d5e82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,12 @@ devDependencies: '@park-ui/panda-preset': specifier: ^0.36.1 version: 0.36.1(@internationalized/date@3.5.2)(@pandacss/dev@0.37.1)(@pandacss/types@0.37.1) + '@playwright/test': + specifier: ^1.43.1 + version: 1.43.1 + '@types/node': + specifier: ^20.12.7 + version: 20.12.7 '@types/react': specifier: ^18.2.46 version: 18.2.74 @@ -75,16 +81,16 @@ devDependencies: version: 5.4.4 vite: specifier: ^5.2.8 - version: 5.2.9 + version: 5.2.9(@types/node@20.12.7) vite-plugin-inspect: specifier: ^0.8.3 version: 0.8.3(vite@5.2.9) vitest: specifier: ^1.4.0 - version: 1.4.0 + version: 1.4.0(@types/node@20.12.7) wxt: specifier: ^0.17.0 - version: 0.17.12 + version: 0.17.12(@types/node@20.12.7) packages: @@ -1207,6 +1213,14 @@ packages: - '@internationalized/date' dev: true + /@playwright/test@1.43.1: + resolution: {integrity: sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==} + engines: {node: '>=16'} + hasBin: true + dependencies: + playwright: 1.43.1 + dev: true + /@pnpm/config.env-replace@1.1.0: resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -1445,8 +1459,8 @@ packages: resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} dev: true - /@types/node@20.12.4: - resolution: {integrity: sha512-E+Fa9z3wSQpzgYQdYmme5X3OTuejnnTx88A6p6vkkJosR3KBz+HpE3kqNm98VE6cfLFcISx7zW7MsJkH6KwbTw==} + /@types/node@20.12.7: + resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==} dependencies: undici-types: 5.26.5 dev: true @@ -1480,7 +1494,7 @@ packages: resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} requiresBuild: true dependencies: - '@types/node': 20.12.4 + '@types/node': 20.12.7 dev: true optional: true @@ -1495,7 +1509,7 @@ packages: '@babel/plugin-transform-react-jsx-source': 7.24.1(@babel/core@7.24.4) '@types/babel__core': 7.20.5 react-refresh: 0.14.0 - vite: 5.2.9 + vite: 5.2.9(@types/node@20.12.7) transitivePeerDependencies: - supports-color dev: true @@ -2578,7 +2592,7 @@ packages: engines: {node: '>=12.13.0'} hasBin: true dependencies: - '@types/node': 20.12.4 + '@types/node': 20.12.7 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 2.0.1 @@ -3326,6 +3340,14 @@ packages: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4690,6 +4712,22 @@ packages: pathe: 1.1.2 dev: true + /playwright-core@1.43.1: + resolution: {integrity: sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==} + engines: {node: '>=16'} + hasBin: true + dev: true + + /playwright@1.43.1: + resolution: {integrity: sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==} + engines: {node: '>=16'} + hasBin: true + dependencies: + playwright-core: 1.43.1 + optionalDependencies: + fsevents: 2.3.2 + dev: true + /pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -5670,7 +5708,7 @@ packages: hasBin: true dev: true - /vite-node@1.4.0: + /vite-node@1.4.0(@types/node@20.12.7): resolution: {integrity: sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -5679,7 +5717,7 @@ packages: debug: 4.3.4 pathe: 1.1.2 picocolors: 1.0.0 - vite: 5.2.9 + vite: 5.2.9(@types/node@20.12.7) transitivePeerDependencies: - '@types/node' - less @@ -5710,13 +5748,13 @@ packages: perfect-debounce: 1.0.0 picocolors: 1.0.0 sirv: 2.0.4 - vite: 5.2.9 + vite: 5.2.9(@types/node@20.12.7) transitivePeerDependencies: - rollup - supports-color dev: true - /vite@5.2.9: + /vite@5.2.9(@types/node@20.12.7): resolution: {integrity: sha512-uOQWfuZBlc6Y3W/DTuQ1Sr+oIXWvqljLvS881SVmAj00d5RdgShLcuXWxseWPd4HXwiYBFW/vXHfKFeqj9uQnw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -5744,6 +5782,7 @@ packages: terser: optional: true dependencies: + '@types/node': 20.12.7 esbuild: 0.20.2 postcss: 8.4.38 rollup: 4.14.0 @@ -5751,7 +5790,7 @@ packages: fsevents: 2.3.3 dev: true - /vitest@1.4.0: + /vitest@1.4.0(@types/node@20.12.7): resolution: {integrity: sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -5776,6 +5815,7 @@ packages: jsdom: optional: true dependencies: + '@types/node': 20.12.7 '@vitest/expect': 1.4.0 '@vitest/runner': 1.4.0 '@vitest/snapshot': 1.4.0 @@ -5793,8 +5833,8 @@ packages: strip-literal: 2.1.0 tinybench: 2.6.0 tinypool: 0.8.3 - vite: 5.2.9 - vite-node: 1.4.0 + vite: 5.2.9(@types/node@20.12.7) + vite-node: 1.4.0(@types/node@20.12.7) why-is-node-running: 2.2.2 transitivePeerDependencies: - less @@ -5981,7 +6021,7 @@ packages: optional: true dev: true - /wxt@0.17.12: + /wxt@0.17.12(@types/node@20.12.7): resolution: {integrity: sha512-VedEvueLVP8Pi9DH0ikSqhQyGJ6uRWVycSicdLNxeGUmPTbExj7hhH5ZZM+SIgsIh9yQgVm7MPnVnobzmeJHJw==} engines: {node: '>=18', pnpm: '>=8'} hasBin: true @@ -6021,7 +6061,7 @@ packages: prompts: 2.4.2 publish-browser-extension: 2.1.3 unimport: 3.7.1 - vite: 5.2.9 + vite: 5.2.9(@types/node@20.12.7) web-ext-run: 0.2.0 webextension-polyfill: 0.10.0 transitivePeerDependencies: diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 0000000..2fd6016 --- /dev/null +++ b/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,437 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +]; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0] + ]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ page }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe('Item', () => { + + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); + }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); + await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect(todoItem.locator('label', { + hasText: TODO_ITEMS[1], + })).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e; + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction(t => { + return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t); + }, title); +}