diff --git a/examples/.eslintrc b/examples/.eslintrc index 4988b7b..b4259d8 100644 --- a/examples/.eslintrc +++ b/examples/.eslintrc @@ -1,4 +1,24 @@ { "plugins": ["playwright"], - "rules": {} + "extends": ["plugin:playwright/recommended"], + "rules": { + "playwright/no-commented-out-tests": "error", + "playwright/no-duplicate-hooks": "error", + "playwright/no-get-by-title": "error", + "playwright/no-nth-methods": "error", + "playwright/no-raw-locators": "error", + "playwright/no-restricted-matchers": "error", + "playwright/prefer-comparison-matcher": "error", + "playwright/prefer-equality-matcher": "error", + "playwright/prefer-hooks-in-order": "error", + "playwright/prefer-hooks-on-top": "error", + "playwright/prefer-lowercase-title": "error", + "playwright/prefer-strict-equal": "error", + "playwright/prefer-to-be": "error", + "playwright/prefer-to-contain": "error", + "playwright/prefer-to-have-count": "error", + "playwright/prefer-to-have-length": "error", + "playwright/require-to-throw-message": "error", + "playwright/require-top-level-describe": "error" + } } diff --git a/examples/package.json b/examples/package.json index 2d5f911..aea488d 100644 --- a/examples/package.json +++ b/examples/package.json @@ -5,8 +5,11 @@ "lint": "eslint ." }, "dependencies": { - "@playwright/test": "^1.38.1", + "@playwright/test": "^1.42.0", "eslint": "^8.51.0", "eslint-plugin-playwright": "file:../" + }, + "devDependencies": { + "@types/node": "^20.11.17" } } diff --git a/examples/test.ts b/examples/test.ts new file mode 100644 index 0000000..ddf6726 --- /dev/null +++ b/examples/test.ts @@ -0,0 +1,492 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +test.describe.configure({ mode: 'parallel' }); + +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); + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(page.getByTestId('todo-count')).toHaveText('3 items left'); + await expect(page.getByTestId('todo-count')).toContainText('3'); + await expect(page.getByTestId('todo-count')).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should show #main and #footer when items added', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + await expect(page.locator('.main')).toBeVisible(); + await expect(page.locator('.footer')).toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); +}); + +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', + { tag: '@slow' }, + 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); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodo.getByRole('checkbox').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')).toBeHidden(); + await expect( + todoItem.locator('label', { + hasText: TODO_ITEMS[1], + }), + ).toBeHidden(); + 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?'); + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + await expect(page.getByTestId('todo-count')).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(page.getByTestId('todo-count')).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'); + await todoItems.nth(0).getByRole('checkbox').check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + 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(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 }) => { + await page.locator('.todo-list li .toggle').nth(1).check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(2); + await expect(page.getByTestId('todo-item')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should respect the back button', async ({ page }) => { + await page.locator('.todo-list li .toggle').nth(1).check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).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(page.getByTestId('todo-item')).toHaveCount(1); + await page.goBack(); + await expect(page.getByTestId('todo-item')).toHaveCount(2); + await page.goBack(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.locator('.todo-list li .toggle').nth(1).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.locator('.todo-list li .toggle').nth(1).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', + ); + await page.getByRole('link', { name: 'Active' }).click(); + // Page change - active items. + await expect(page.getByRole('link', { name: 'Active' })).toHaveClass( + 'selected', + ); + await page.getByRole('link', { name: 'Completed' }).click(); + // Page change - completed items. + await expect(page.getByRole('link', { name: 'Completed' })).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); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c4e184..687a7c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,14 +67,18 @@ importers: examples: dependencies: '@playwright/test': - specifier: ^1.38.1 - version: 1.41.2 + specifier: ^1.42.0 + version: 1.42.0 eslint: specifier: ^8.51.0 version: 8.56.0 eslint-plugin-playwright: specifier: file:../ version: file:(eslint-plugin-jest@25.0.0)(eslint@8.56.0) + devDependencies: + '@types/node': + specifier: ^20.11.17 + version: 20.11.17 packages: @@ -604,12 +608,12 @@ packages: '@octokit/openapi-types': 19.0.0 dev: true - /@playwright/test@1.41.2: - resolution: {integrity: sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==} + /@playwright/test@1.42.0: + resolution: {integrity: sha512-2k1HzC28Fs+HiwbJOQDUwrWMttqSLUVdjCqitBOjdCD0svWOMQUVqrXX6iFD7POps6xXAojsX/dGBpKnjZctLA==} engines: {node: '>=16'} hasBin: true dependencies: - playwright: 1.41.2 + playwright: 1.42.0 dev: false /@pnpm/config.env-replace@1.1.0: @@ -3356,18 +3360,18 @@ packages: pathe: 1.1.2 dev: true - /playwright-core@1.41.2: - resolution: {integrity: sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==} + /playwright-core@1.42.0: + resolution: {integrity: sha512-0HD9y8qEVlcbsAjdpBaFjmaTHf+1FeIddy8VJLeiqwhcNqGCBe4Wp2e8knpqiYbzxtxarxiXyNDw2cG8sCaNMQ==} engines: {node: '>=16'} hasBin: true dev: false - /playwright@1.41.2: - resolution: {integrity: sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==} + /playwright@1.42.0: + resolution: {integrity: sha512-Ko7YRUgj5xBHbntrgt4EIw/nE//XBHOKVKnBjO1KuZkmkhlbgyggTe5s9hjqQ1LpN+Xg+kHsQyt5Pa0Bw5XpvQ==} engines: {node: '>=16'} hasBin: true dependencies: - playwright-core: 1.41.2 + playwright-core: 1.42.0 optionalDependencies: fsevents: 2.3.2 dev: false