diff --git a/.env b/.env deleted file mode 100644 index bc3c03d..0000000 --- a/.env +++ /dev/null @@ -1,12 +0,0 @@ -# Server -NODE_ENV=development - -# Application Database -DB_HOST=localhost -DB_PORT=5432 -DB_NAME=fullstock -DB_USER=diego -DB_PASSWORD= - -# Admin Database (for database creation/deletion) -ADMIN_DB_NAME=diego diff --git a/.gitignore b/.gitignore index 667e9b7..a1533a2 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,11 @@ dist-ssr # React Router .react-router/ -.build/ \ No newline at end of file +.build/ +.env + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/package-lock.json b/package-lock.json index 730be53..84f8416 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ }, "devDependencies": { "@eslint/js": "^9.15.0", + "@playwright/test": "^1.53.1", "@react-router/dev": "^7.5.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -1882,6 +1883,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.1.tgz", + "integrity": "sha512-Z4c23LHV0muZ8hfv4jw6HngPJkbbtZxTkxPNIg7cJcTc9C28N/p2q7g3JZS2SiKBBHJ3uM1dgDye66bB7LEk5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.53.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", @@ -9403,6 +9420,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.1.tgz", + "integrity": "sha512-LJ13YLr/ocweuwxyGf1XNFWIU4M2zUSo149Qbp+A4cpwDjsxRPj7k6H25LBrEHiEwxvRbD8HdwvQmRMSvquhYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.53.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.1.tgz", + "integrity": "sha512-Z46Oq7tLAyT0lGoFx4DOuB1IA9D1TPj0QkYxpPVUnGDqHHvDpCftu1J2hM2PiWsNMoZh8+LQaarAWcDfPBc6zg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index e34667e..ff9004a 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "devDependencies": { "@eslint/js": "^9.15.0", + "@playwright/test": "^1.53.1", "@react-router/dev": "^7.5.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..53f697b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,79 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './src/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://localhost: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://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/src/db/migrations/initial.sql b/src/db/migrations/initial.sql index 11ad170..0fe8b74 100644 --- a/src/db/migrations/initial.sql +++ b/src/db/migrations/initial.sql @@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS products ( CREATE TABLE IF NOT EXISTS carts ( id SERIAL PRIMARY KEY, session_cart_id UUID UNIQUE DEFAULT gen_random_uuid(), - user_id INTEGER REFERENCES users(id), + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS cart_items ( CREATE TABLE IF NOT EXISTS orders ( id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id), + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, total_amount NUMERIC(10,2) NOT NULL, -- Customer and shipping details diff --git a/src/e2e/demo.signin.spec.ts b/src/e2e/demo.signin.spec.ts new file mode 100644 index 0000000..fd643d7 --- /dev/null +++ b/src/e2e/demo.signin.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from "@playwright/test"; + +import { hashPassword } from "@/lib/security"; +import type { CreateUserDTO } from "@/models/user.model"; +import { + createUser, + deleteUser, + getUserByEmail, +} from "@/repositories/user.repository"; + +test.describe("Visitante inicio sesion", () => { + let testUserId: number; + + test.beforeAll(async () => { + const testUser: CreateUserDTO = { + email: "diego@codeable.com", + name: null, + password: await hashPassword("letmein"), + isGuest: false, + }; + + const existingUser = await getUserByEmail(testUser.email); + + if (existingUser) { + await deleteUser(existingUser.id); + } + + const user = await createUser(testUser); + testUserId = user.id; + }); + + test.afterAll(async () => { + await deleteUser(testUserId); + }); + + test("test", async ({ page }) => { + await page.goto("http://localhost:5173/"); + await page.getByTestId("login").click(); + await page.getByRole("textbox", { name: "Correo electrónico" }).click(); + await page + .getByRole("textbox", { name: "Correo electrónico" }) + .fill("diego@codeable.com"); + await page + .getByRole("textbox", { name: "Correo electrónico" }) + .press("Tab"); + await page.getByRole("textbox", { name: "Contraseña" }).fill("letmein"); + await page.getByRole("button", { name: "Iniciar sesión" }).click(); + + await expect(page.getByText("Bienvenido diego@codeable.com")).toBeVisible(); + }); +}); diff --git a/src/e2e/demo.spec.ts b/src/e2e/demo.spec.ts new file mode 100644 index 0000000..9ec8857 --- /dev/null +++ b/src/e2e/demo.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Visitor", () => { + test("can add a product to the cart", async ({ page }) => { + await page.goto("http://localhost:5173/"); + + await expect(page).toHaveTitle(/inicio/i); + + await page.getByRole("menuitem", { name: "Polos", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Polos" })).toBeVisible(); + + await page.getByTestId("product-item").first().click(); + const button = page.getByRole("button", { + name: "Agregar al Carrito", + }); + await expect(button).toBeVisible(); + await button.click(); + const cartCount = page.getByTestId("cart-count"); + await expect(cartCount).toHaveText("1"); + }); +}); diff --git a/src/e2e/example.spec.ts b/src/e2e/example.spec.ts new file mode 100644 index 0000000..54a906a --- /dev/null +++ b/src/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/src/e2e/user-create-order.spec.ts b/src/e2e/user-create-order.spec.ts new file mode 100644 index 0000000..20c4c55 --- /dev/null +++ b/src/e2e/user-create-order.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from "@playwright/test"; + +import { hashPassword } from "@/lib/security"; +import type { CreateUserDTO } from "@/models/user.model"; +import { + createUser, + deleteUser, + getUserByEmail, +} from "@/repositories/user.repository"; + +test.describe("User", () => { + let testUserId: number; + + test.beforeAll(async () => { + const testUser: CreateUserDTO = { + email: "diego@codeable.com", + name: null, + password: await hashPassword("letmein"), + isGuest: false, + }; + + const existingUser = await getUserByEmail(testUser.email); + + if (existingUser) { + await deleteUser(existingUser.id); + } + + const user = await createUser(testUser); + testUserId = user.id; + }); + + test.afterAll(async () => { + await deleteUser(testUserId); + }); + + test("User can create an order", async ({ page }) => { + await page.goto("http://localhost:5173/"); + + await page.getByRole("link", { name: "Iniciar sesión" }).click(); + + const loginForm = { + "Correo electrónico": "diego@codeable.com", + Contraseña: "letmein", + }; + + for (const [key, value] of Object.entries(loginForm)) { + const input = await page.getByRole("textbox", { name: key }); + await input.click(); + await input.fill(value); + } + + await page.getByRole("button", { name: "Iniciar sesión" }).click(); + + // Wait for the user to be logged in + await expect( + page.getByRole("button", { name: "Cerrar sesión" }) + ).toBeVisible(); + + await page.getByRole("menuitem", { name: "Polos" }).click(); + await page.getByTestId("product-item").first().click(); + + await page.getByRole("button", { name: "Agregar al Carrito" }).click(); + await page.getByRole("link", { name: "Carrito de compras" }).click(); + + await page.getByRole("link", { name: "Continuar Compra" }).click(); + + const orderForm = { + Nombre: "Testino", + Apellido: "Diprueba", + Compañia: "", + Dirección: "Calle De Prueba 123", + Ciudad: "Lima", + "Provincia/Estado": "Lima", + "Código Postal": "51111", + Teléfono: "987456321", + }; + + for (const [key, value] of Object.entries(orderForm)) { + const input = await page.getByRole("textbox", { name: key }); + await input.click(); + await input.fill(value); + } + + await page.getByRole("combobox", { name: "País" }).selectOption("PE"); + + await page.getByRole("button", { name: "Confirmar Orden" }).click(); + + await expect( + page.getByText("¡Muchas gracias por tu compra!") + ).toBeVisible(); + await expect(page.getByTestId("orderId")).toBeVisible(); + }); +}); diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index 90827dd..8dbd0b0 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -8,7 +8,11 @@ interface ProductCardProps { export function ProductCard({ product }: ProductCardProps) { return ( - +

Código de seguimiento

-

{orderId}

+

+ {orderId} +

); diff --git a/src/routes/root/components/auth-nav/index.tsx b/src/routes/root/components/auth-nav/index.tsx index 1cf10ec..1042fbb 100644 --- a/src/routes/root/components/auth-nav/index.tsx +++ b/src/routes/root/components/auth-nav/index.tsx @@ -26,6 +26,7 @@ export default function AuthNav({ user }: { user?: Omit }) { Iniciar sesión diff --git a/src/routes/root/components/header-actions/index.tsx b/src/routes/root/components/header-actions/index.tsx index fdf90c6..001569b 100644 --- a/src/routes/root/components/header-actions/index.tsx +++ b/src/routes/root/components/header-actions/index.tsx @@ -37,7 +37,10 @@ export default function HeaderActions({ {totalItems > 0 && ( - + {totalItems} )} diff --git a/src/session.server.ts b/src/session.server.ts index 89a5cb2..31d3a05 100644 --- a/src/session.server.ts +++ b/src/session.server.ts @@ -27,7 +27,7 @@ const { getSession, commitSession, destroySession } = path: "/", sameSite: "lax", secrets: ["s3cret1"], - secure: true, + secure: process.env.NODE_ENV === "production", // true en producción, false en desarrollo }, }); diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 0000000..8641cb5 --- /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); +}