diff --git a/.github/workflows/admin_playwright_tests.yml b/.github/workflows/admin_playwright_tests.yml new file mode 100644 index 000000000..d15acf3d6 --- /dev/null +++ b/.github/workflows/admin_playwright_tests.yml @@ -0,0 +1,101 @@ +name: Admin | Playwright Tests + +on: [push, pull_request] + +permissions: + contents: read + +env: + FORCE_COLOR: 1 + +jobs: + admin_playwright_test: + runs-on: ${{ matrix.operating-systems }} + + strategy: + fail-fast: false + matrix: + operating-systems: [ubuntu-latest] + php-versions: ['8.3'] + node-version: ['22.13.1'] + shard-index: [1,2,3,4,5,6] + shard-total: [6] + + name: Admin | Playwright Tests | Shard ${{ matrix.shard-index }} Of ${{ matrix.shard-total }} + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: krayin + ports: + - 3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: curl, fileinfo, gd, intl, mbstring, openssl, pdo, pdo_mysql, tokenizer, zip + ini-values: error_reporting=E_ALL + tools: composer:v2 + + - name: Set Up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install Node.js Dependencies + run: npm install + working-directory: packages/Webkul/Admin + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + working-directory: packages/Webkul/Admin + + - name: Setting Environment + run: | + cp .env.example .env + sed -i "s|^\(DB_HOST=\s*\).*$|\1127.0.0.1|" .env + sed -i "s|^\(DB_PORT=\s*\).*$|\1${{ job.services.mysql.ports['3306'] }}|" .env + sed -i "s|^\(DB_DATABASE=\s*\).*$|\1krayin|" .env + sed -i "s|^\(DB_USERNAME=\s*\).*$|\1root|" .env + sed -i "s|^\(DB_PASSWORD=\s*\).*$|\1root|" .env + sed -i "s|^\(APP_DEBUG=\s*\).*$|\1false|" .env + sed -i "s|^\(APP_URL=\s*\).*$|\1http://127.0.0.1:8000|" .env + cat .env + + - name: Install Composer Dependencies + run: composer install + + - name: Running Krayin Installer + run: php artisan krayin-crm:install --skip-env-check --skip-admin-creation + + # - name: Seed Product Table + # run: php artisan db:seed --class="Webkul\\Installer\\Database\\Seeders\\ProductTableSeeder" + + - name: Start Laravel server + run: | + php artisan serve --host=0.0.0.0 --port=8000 > server.log 2>&1 & + echo "Waiting for server to start..." + timeout 30 bash -c 'until curl -s http://127.0.0.1:8000 > /dev/null; do sleep 1; done' + + - name: Run All Playwright Tests + env: + BASE_URL: 'http://127.0.0.1:8000' + run: | + npx playwright test --reporter=list --config=tests/e2e-pw/playwright.config.ts --shard=${{ matrix.shard-index }}/${{ matrix.shard-total }} + working-directory: packages/Webkul/Admin + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: packages/Webkul/Admin/tests/e2e-pw/test-results + retention-days: 1 diff --git a/.github/workflows/linting_tests.yml b/.github/workflows/auto_commits.yml similarity index 100% rename from .github/workflows/linting_tests.yml rename to .github/workflows/auto_commits.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0acb8d1af..0b3deb5d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,12 @@ on: [push, pull_request] jobs: tests: runs-on: ${{ matrix.operating-system }} + strategy: matrix: operating-system: [ubuntu-latest] php-versions: ["8.3", "8.2"] + name: PHP ${{ matrix.php-versions }} test on ${{ matrix.operating-system }} services: @@ -30,8 +32,10 @@ jobs: with: php-version: ${{ matrix.php-versions }} extensions: curl, gd, intl, mbstring, openssl, pdo, pdo_mysql, tokenizer, zip + ini-values: error_reporting=E_ALL + tools: composer:v2 - - name: Composer Install + - name: Running Composer Install run: composer install - name: Set Testing Environment @@ -44,25 +48,8 @@ jobs: sed -i "s|^\(DB_USERNAME=\s*\).*$|\1root|" .env sed -i "s|^\(DB_PASSWORD=\s*\).*$|\1root|" .env - - name: Key Generate - run: php artisan key:generate - - - name: Complete ENV File - run: | - printf "The complete `.env` ... \n\n" - cat .env - - - name: Migrate Database - run: php artisan migrate - - - name: Seed Database - run: php artisan db:seed - - - name: Vendor Publish - run: php artisan vendor:publish --provider=Webkul\\Core\\Providers\\CoreServiceProvider --force - - - name: Optimize Stuffs - run: php artisan optimize:clear + - name: Running Krayin Installer + run: php artisan krayin-crm::install --skip-env-check --skip-admin-creation - - name: Run Tests - run: vendor/bin/pest + - name: Running Pest Test + run: vendor/bin/pest --parallel --colors=always diff --git a/.gitignore b/.gitignore index e5fd0bfac..6cc620860 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ Homestead.yaml /node_modules npm-debug.log package-lock.json +/playwright-report /public/css /public/js /public/hot @@ -26,3 +27,9 @@ package-lock.json /vendor yarn.lock yarn-error.log + +# Playwright +node_modules/ +/test-results/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/example.spec.ts b/e2e/example.spec.ts new file mode 100644 index 000000000..54a906a4e --- /dev/null +++ b/e2e/example.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); diff --git a/package.json b/package.json index 56f5ddcc5..1f80c75ec 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build" - }, - "devDependencies": { - "axios": "^1.6.4", - "laravel-vite-plugin": "^1.0.0", - "vite": "^5.0.0" - } + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@types/node": "^22.13.5", + "axios": "^1.6.4", + "laravel-vite-plugin": "^1.0.0", + "vite": "^5.0.0" + } } diff --git a/packages/Webkul/Admin/.gitignore b/packages/Webkul/Admin/.gitignore index 6376db975..4d32a594c 100755 --- a/packages/Webkul/Admin/.gitignore +++ b/packages/Webkul/Admin/.gitignore @@ -1,3 +1,5 @@ /node_modules /package-lock.json -npm-debug.log \ No newline at end of file +npm-debug.log +/playwright-report +/test-results \ No newline at end of file diff --git a/packages/Webkul/Admin/package.json b/packages/Webkul/Admin/package.json index d028b9678..ef4b3a3cb 100644 --- a/packages/Webkul/Admin/package.json +++ b/packages/Webkul/Admin/package.json @@ -5,22 +5,27 @@ "build": "vite build" }, "devDependencies": { + "@playwright/test": "^1.48.1", + "@types/node": "^22.7.8", "autoprefixer": "^10.4.16", - "axios": "^1.6.4", - "laravel-vite-plugin": "^0.7.2", + "axios": "^1.7.4", + "laravel-vite-plugin": "^1.0", "postcss": "^8.4.23", "tailwindcss": "^3.3.2", - "vite": "^4.0.0", - "vue": "^3.4.19" + "vite": "^5.0", + "vue": "^3.4.21" }, "dependencies": { "@vee-validate/i18n": "^4.9.1", "@vee-validate/rules": "^4.9.1", "@vitejs/plugin-vue": "^4.2.3", "chartjs-chart-funnel": "^4.2.1", + "dotenv": "^16.4.7", "dompurify": "^3.1.7", "flatpickr": "^4.6.13", "mitt": "^3.0.1", + "playwright": "^1.48.1", + "readline-sync": "^1.4.10", "vee-validate": "^4.9.1", "vue-cal": "^4.9.0", "vue-flatpickr": "^2.3.0", diff --git a/packages/Webkul/Admin/tests/e2e-pw/.gitignore b/packages/Webkul/Admin/tests/e2e-pw/.gitignore new file mode 100644 index 000000000..52e84d7ae --- /dev/null +++ b/packages/Webkul/Admin/tests/e2e-pw/.gitignore @@ -0,0 +1,2 @@ +/playwright-report +/test-results diff --git a/packages/Webkul/Admin/tests/e2e-pw/playwright.config.ts b/packages/Webkul/Admin/tests/e2e-pw/playwright.config.ts new file mode 100644 index 000000000..7732483cf --- /dev/null +++ b/packages/Webkul/Admin/tests/e2e-pw/playwright.config.ts @@ -0,0 +1,52 @@ +import { defineConfig, devices } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +dotenv.config({ path: path.resolve(__dirname, "../../../../../.env") }); + +export default defineConfig({ + testDir: "./tests", + + timeout: 30 * 1000, + + expect: { timeout: 20 * 1000 }, + + outputDir: "./test-results", + + fullyParallel: false, + + workers: 1, + + forbidOnly: !!process.env.CI, + + retries: 0, + + reportSlowTests: null, + + reporter: [ + [ + "html", + { + outputFolder: "./playwright-report", + }, + ], + ], + + use: { + baseURL: `${process.env.APP_URL}/`.replace(/\/+$/, "/"), + screenshot: { mode: "only-on-failure", fullPage: true }, + video: "retain-on-failure", + trace: "retain-on-failure", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/packages/Webkul/Admin/tests/e2e-pw/setup.ts b/packages/Webkul/Admin/tests/e2e-pw/setup.ts new file mode 100644 index 000000000..80a053e05 --- /dev/null +++ b/packages/Webkul/Admin/tests/e2e-pw/setup.ts @@ -0,0 +1,25 @@ +import { test as base, expect, type Page } from "@playwright/test"; + +type AdminFixtures = { + adminPage: Page; +}; + +export const test = base.extend({ + adminPage: async ({ page }, use) => { + const adminCredentials = { + email: "admin@example.com", + password: "admin123", + }; + + await page.goto("admin/login"); + await page.fill('input[name="email"]', adminCredentials.email); + await page.fill('input[name="password"]', adminCredentials.password); + await page.press('input[name="password"]', "Enter"); + + await page.waitForURL("**/admin/dashboard"); + + await use(page); + }, +}); + +export { expect }; diff --git a/packages/Webkul/Admin/tests/e2e-pw/tests/auth.spec.ts b/packages/Webkul/Admin/tests/e2e-pw/tests/auth.spec.ts new file mode 100644 index 000000000..7c59f5944 --- /dev/null +++ b/packages/Webkul/Admin/tests/e2e-pw/tests/auth.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from "../setup"; + +const adminCredentials = { + email: "admin@example.com", + password: "admin123", +}; + +test("should be able to login", async ({ page }) => { + await page.goto("admin/login"); + await page.getByPlaceholder("Email Address").click(); + await page.getByPlaceholder("Email Address").fill(adminCredentials.email); + await page.getByPlaceholder("Password").click(); + await page.getByPlaceholder("Password").fill(adminCredentials.password); + await page.getByRole("button", { name: "Sign In" }).click(); + + await expect(page.getByPlaceholder("Mega Search").first()).toBeVisible(); +}); + +test("should be able to logout", async ({ page }) => { + await page.goto("admin/login"); + await page.getByPlaceholder("Email Address").click(); + await page.getByPlaceholder("Email Address").fill(adminCredentials.email); + await page.getByPlaceholder("Password").click(); + await page.getByPlaceholder("Password").fill(adminCredentials.password); + await page.getByLabel("Sign In").click(); + await page.click("button:text('E')"); + await page.getByRole("link", { name: "Logout" }).click(); + await page.waitForTimeout(5000); + + await expect(page.getByPlaceholder("Password").first()).toBeVisible(); +}); diff --git a/packages/Webkul/Marketing/src/Mail/CampaignMail.php b/packages/Webkul/Marketing/src/Mail/CampaignMail.php index f5058c035..882167d73 100644 --- a/packages/Webkul/Marketing/src/Mail/CampaignMail.php +++ b/packages/Webkul/Marketing/src/Mail/CampaignMail.php @@ -2,7 +2,7 @@ namespace Webkul\Marketing\Mail; -use Illuminate\Mail\Mailable as Mailable; +use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Address; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 000000000..8641cb5f5 --- /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' +] as const; + +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); +}