From b03d862b53309d5cdd26abb6c20bdfad16bec592 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Sat, 13 Aug 2022 13:47:10 +0530 Subject: [PATCH] Playwright init --- .github/workflows/playwright.yml | 27 ++ .gitignore | 3 + package.json | 3 +- playwright.config.ts | 107 +++++ tests-examples/demo-todo-app.spec.ts | 398 ++++++++++++++++++ tests/example.spec.ts | 20 + ...ng-to-the-intro-page-1-chromium-darwin.txt | 1 + yarn.lock | 13 + 8 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 playwright.config.ts create mode 100644 tests-examples/demo-todo-app.spec.ts create mode 100644 tests/example.spec.ts create mode 100644 tests/example.spec.ts-snapshots/homepage-has-Playwright-in-title-and-get-started-link-linking-to-the-intro-page-1-chromium-darwin.txt diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..e637a0a9 --- /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@v2 + - uses: actions/setup-node@v2 + with: + node-version: '14.x' + - name: Install dependencies + run: yarn + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: yarn playwright test + - uses: actions/upload-artifact@v2 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 381928d7..3b77731b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ yarn-error.log /cypress/downloads /cypress/videos /cypress/screenshots +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/package.json b/package.json index 837d04af..241e3a2e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@cypress/snapshot": "2.1.7", + "@playwright/test": "^1.25.0", "@sveltejs/adapter-static": "1.0.0-next.39", "@sveltejs/kit": "1.0.0-next.405", "@testing-library/jest-dom": "5.16.5", @@ -89,4 +90,4 @@ "engines": { "node": ">=16.7" } -} +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..979c83a6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,107 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000 + }, + /* 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: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* 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: { + // channel: 'msedge', + // }, + // }, + // { + // name: 'Google Chrome', + // use: { + // channel: 'chrome', + // }, + // }, + ] + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // outputDir: 'test-results/', + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // port: 3000, + // }, +}; + +export default config; diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 00000000..5bba0ada --- /dev/null +++ b/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,398 @@ +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 1st todo. + await page.locator('.new-todo').fill(TODO_ITEMS[0]); + await page.locator('.new-todo').press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.locator('.view label')).toHaveText([ + TODO_ITEMS[0] + ]); + + // Create 2nd todo. + await page.locator('.new-todo').fill(TODO_ITEMS[1]); + await page.locator('.new-todo').press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.locator('.view label')).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 one todo item. + await page.locator('.new-todo').fill(TODO_ITEMS[0]); + await page.locator('.new-todo').press('Enter'); + + // Check that input is empty. + await expect(page.locator('.new-todo')).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.locator('.todo-count')).toHaveText('3 items left'); + await expect(page.locator('.todo-count')).toContainText('3'); + await expect(page.locator('.todo-count')).toHaveText(/3/); + + // Check all items in one call. + await expect(page.locator('.view label')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should show #main and #footer when items added', async ({ page }) => { + await page.locator('.new-todo').fill(TODO_ITEMS[0]); + await page.locator('.new-todo').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.locator('.toggle-all').check(); + + // Ensure all todos have 'completed' class. + await expect(page.locator('.todo-list li')).toHaveClass(['completed', 'completed', 'completed']); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ page }) => { + // Check and then immediately uncheck. + await page.locator('.toggle-all').check(); + await page.locator('.toggle-all').uncheck(); + + // Should be no completed classes. + await expect(page.locator('.todo-list li')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { + const toggleAll = page.locator('.toggle-all'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.locator('.todo-list li').nth(0); + await firstTodo.locator('.toggle').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.locator('.toggle').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 two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await page.locator('.new-todo').fill(item); + await page.locator('.new-todo').press('Enter'); + } + + // Check first item. + const firstTodo = page.locator('.todo-list li').nth(0); + await firstTodo.locator('.toggle').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.locator('.todo-list li').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.locator('.toggle').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 two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await page.locator('.new-todo').fill(item); + await page.locator('.new-todo').press('Enter'); + } + + const firstTodo = page.locator('.todo-list li').nth(0); + const secondTodo = page.locator('.todo-list li').nth(1); + await firstTodo.locator('.toggle').check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodo.locator('.toggle').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.locator('.todo-list li'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.locator('.edit')).toHaveValue(TODO_ITEMS[1]); + await secondTodo.locator('.edit').fill('buy some sausages'); + await secondTodo.locator('.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.locator('.todo-list li').nth(1); + await todoItem.dblclick(); + await expect(todoItem.locator('.toggle')).not.toBeVisible(); + await expect(todoItem.locator('label')).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.locator('.todo-list li'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).locator('.edit').fill('buy some sausages'); + await todoItems.nth(1).locator('.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.locator('.todo-list li'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).locator('.edit').fill(' buy some sausages '); + await todoItems.nth(1).locator('.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.locator('.todo-list li'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).locator('.edit').fill(''); + await todoItems.nth(1).locator('.edit').press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.locator('.todo-list li'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).locator('.edit').press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + await page.locator('.new-todo').fill(TODO_ITEMS[0]); + await page.locator('.new-todo').press('Enter'); + await expect(page.locator('.todo-count')).toContainText('1'); + + await page.locator('.new-todo').fill(TODO_ITEMS[1]); + await page.locator('.new-todo').press('Enter'); + await expect(page.locator('.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.locator('.clear-completed')).toHaveText('Clear completed'); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.locator('.todo-list li'); + await todoItems.nth(1).locator('.toggle').check(); + await page.locator('.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.locator('.clear-completed').click(); + await expect(page.locator('.clear-completed')).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + for (const item of TODO_ITEMS.slice(0, 2)) { + await page.locator('.new-todo').fill(item); + await page.locator('.new-todo').press('Enter'); + } + + const todoItems = page.locator('.todo-list li'); + await todoItems.nth(0).locator('.toggle').check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + 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.locator('.filters >> text=Active').click(); + await expect(page.locator('.todo-list li')).toHaveCount(2); + await expect(page.locator('.todo-list li')).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.locator('.filters >> text=All').click(); + await expect(page.locator('.todo-list li')).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.locator('.filters >> text=Active').click(); + }); + + await test.step('Showing completed items', async () => { + await page.locator('.filters >> text=Completed').click(); + }); + + await expect(page.locator('.todo-list li')).toHaveCount(1); + await page.goBack(); + await expect(page.locator('.todo-list li')).toHaveCount(2); + await page.goBack(); + await expect(page.locator('.todo-list li')).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.locator('.filters >> text=Completed').click(); + await expect(page.locator('.todo-list li')).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.locator('.filters >> text=Active').click(); + await page.locator('.filters >> text=Completed').click(); + await page.locator('.filters >> text=All').click(); + await expect(page.locator('.todo-list li')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.locator('.filters >> text=All')).toHaveClass('selected'); + await page.locator('.filters >> text=Active').click(); + // Page change - active items. + await expect(page.locator('.filters >> text=Active')).toHaveClass('selected'); + await page.locator('.filters >> text=Completed').click(); + // Page change - completed items. + await expect(page.locator('.filters >> text=Completed')).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page: Page) { + for (const item of TODO_ITEMS) { + await page.locator('.new-todo').fill(item); + await page.locator('.new-todo').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/tests/example.spec.ts b/tests/example.spec.ts new file mode 100644 index 00000000..cfb9a569 --- /dev/null +++ b/tests/example.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '@playwright/test'; + +test('homepage has Playwright in title and get started link linking to the intro page', async ({ + page, + context +}) => { + await page.goto( + 'http://localhost:3000/edit?gist=https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/ec9b4ab0e41e4ff6287326cd3cb47affd7851e19' + ); + + // cy.getLocalStorage('codeStore').snapshot(); + // Expect a title "to contain" a substring. + await page.locator('"History"').click(); + await page.locator('"Party"').isVisible(); + await page.locator('"Revisions"').isVisible(); + await page.locator('"sidharthv96 v7851e19"').isVisible(); + await page.waitForTimeout(400); + const storage = await context.storageState(); + expect(JSON.stringify(storage)).toMatchSnapshot(); +}); diff --git a/tests/example.spec.ts-snapshots/homepage-has-Playwright-in-title-and-get-started-link-linking-to-the-intro-page-1-chromium-darwin.txt b/tests/example.spec.ts-snapshots/homepage-has-Playwright-in-title-and-get-started-link-linking-to-the-intro-page-1-chromium-darwin.txt new file mode 100644 index 00000000..39d40ace --- /dev/null +++ b/tests/example.spec.ts-snapshots/homepage-has-Playwright-in-title-and-get-started-link-linking-to-the-intro-page-1-chromium-darwin.txt @@ -0,0 +1 @@ +{"cookies":[],"origins":[{"origin":"http://localhost:3000","localStorage":[{"name":"manualHistoryStore","value":"[]"},{"name":"autoHistoryMode","value":"\"loader\""},{"name":"codeStore","value":"{\"code\":\"graph TD\\n A[Party] -->|Get money| B(Go shopping)\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"forest\\\",\\n \\\"test\\\": \\\"hello\\\"\\n}\",\"updateEditor\":false,\"autoSync\":true,\"updateDiagram\":false,\"loader\":{\"type\":\"gist\",\"config\":{\"url\":\"https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/ec9b4ab0e41e4ff6287326cd3cb47affd7851e19\"}}}"},{"name":"themeStore","value":"{\"theme\":\"light\",\"isDark\":false}"},{"name":"autoHistoryStore","value":"[]"}]}]} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 2df48f38..b95adbe6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -243,6 +243,14 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@playwright/test@^1.25.0": + version "1.25.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.25.0.tgz#e0de134651e78e45e986c5f16578188dd5937331" + integrity sha512-j4EZhTTQI3dBeWblE21EV//swwmBtOpIrLdOIJIRv4uqsLdHgBg1z+JtTg+AeC5o2bAXIE26kDNW5A0TimG8Bg== + dependencies: + "@types/node" "*" + playwright-core "1.25.0" + "@polka/url@^1.0.0-next.20": version "1.0.0-next.21" resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" @@ -3933,6 +3941,11 @@ pify@^2.2.0, pify@^2.3.0: resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz" integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= +playwright-core@1.25.0: + version "1.25.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.25.0.tgz#54dc867c6c2cc5e4233905e249206a02914d14f1" + integrity sha512-kZ3Jwaf3wlu0GgU0nB8UMQ+mXFTqBIFz9h1svTlNduNKjnbPXFxw7mJanLVjqxHJRn62uBfmgBj93YHidk2N5Q== + postcss-calc@^8.2.3: version "8.2.4" resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-8.2.4.tgz#77b9c29bfcbe8a07ff6693dc87050828889739a5"