diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..d488d010 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,36 @@ +--- +name: Playwright Tests +on: + deployment_status: + paths: + - playwright/** + - .github/workflows/playwright.yml +defaults: + run: + working-directory: ./playwright +env: + PW_XFD_2FA_ISSUER: ${{ secrets._PW_XFD_2FA_ISSUER }} + PW_XFD_2FA_SECRET: ${{ secrets.PW_XFD_2FA_SECRET }} + PW_XFD_PASSWORD: ${{ secrets.PW_XFD_PASSWORD }} + PW_XFD_URL: ${{ vars.PW_XFD_URL }} + PW_XFD_USER_ROLE: ${{ vars.PW_XFD_USER_ROLE }} + PW_XFD_USERNAME: ${{ secrets.PW_XFD_USERNAME }} + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.41.2-jammy + if: github.event.deployment_status.state == 'success' + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Install dependencies + run: npm ci + - name: Run your tests + run: npx playwright test + env: + HOME: /root diff --git a/playwright/.gitignore b/playwright/.gitignore new file mode 100644 index 00000000..dbcee752 --- /dev/null +++ b/playwright/.gitignore @@ -0,0 +1,6 @@ +/blob-report/ +/playwright-report/ +/playwright/.cache/ +/test-results/ +node_modules/ +storageState.json diff --git a/playwright/e2e/global-admin/home.spec.ts b/playwright/e2e/global-admin/home.spec.ts new file mode 100644 index 00000000..1e1795e2 --- /dev/null +++ b/playwright/e2e/global-admin/home.spec.ts @@ -0,0 +1,66 @@ +import { test, expect, Page } from '@playwright/test'; +import exp from 'constants'; + +test.describe.configure({ mode: 'serial' }); +let page: Page; + +test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + await page.goto('/'); +}); + +test.afterAll(async () => { + await page.close(); +}); +test('home', async () => { + // Expect home page to show Latest Vulnerabilities. + await expect( + page.getByRole('heading', { name: 'Latest Vulnerabilities' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Open Vulnerabilities by Domain' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Most Common Ports' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Most Common Vulnerabilities' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Severity Levels' }) + ).toBeVisible(); + await expect( + page.getByPlaceholder('Search a domain, vuln, port, service, IP') + ).toBeVisible(); + await expect(page.getByRole('link', { name: 'Inventory' })).toBeVisible(); + await page.screenshot({ path: 'test-results/img/global-admin/home.png' }); +}); + +test('Open Vulnerabilities by Domain', async () => { + await page.getByRole('button', { name: 'All' }).click(); + await page.screenshot({ + path: 'test-results/img/global-admin/open_vuln_all.png' + }); + if ( + (await page.getByRole('button', { name: 'Medium' }).isDisabled()) == false + ) { + await page.getByRole('button', { name: 'Medium' }).click(); + await page.screenshot({ + path: 'test-results/img/global-admin/open_vuln_medium.png' + }); + } + if ( + (await page.getByRole('button', { name: 'High' }).isDisabled()) == false + ) { + await page.getByRole('button', { name: 'High' }).click(); + await page.screenshot({ + path: 'test-results/img/global-admin/open_vuln_high.png' + }); + } + if ((await page.getByLabel('Go to next page').isDisabled()) == false) { + await page.getByLabel('Go to next page').click(); + } + if ((await page.getByLabel('Go to previous page').isDisabled()) == false) { + await page.getByLabel('Go to previous page').click(); + } +}); diff --git a/playwright/e2e/global-admin/inventory.spec.ts b/playwright/e2e/global-admin/inventory.spec.ts new file mode 100644 index 00000000..559b7679 --- /dev/null +++ b/playwright/e2e/global-admin/inventory.spec.ts @@ -0,0 +1,72 @@ +import { test, expect, chromium, Page } from '@playwright/test'; + +test.describe.configure({ mode: 'serial' }); +let page: Page; + +test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + await page.goto('/'); +}); + +test.afterAll(async () => { + await page.close(); +}); +test('Inventory', async () => { + await page.getByRole('link', { name: 'Inventory' }).click(); + await expect(page).toHaveURL('/inventory'); + await page.getByRole('button', { name: 'IP(s)' }).click(); + await page.getByRole('button', { name: 'Severity' }).click(); + await page.getByLabel('Sort by:').first().click(); + await page.getByRole('option', { name: 'Domain Name' }).click(); + await page.getByLabel('Sort by:').first().click(); + await page.getByRole('option', { name: 'IP' }).click(); + await page.getByLabel('Sort by:').first().click(); + await page.getByRole('option', { name: 'Last Seen' }).click(); + await page.getByLabel('Sort by:').first().click(); + await page.getByRole('option', { name: 'First Seen' }).click(); + await page.screenshot({ + path: 'test-results/img/global-admin/inventory.png' + }); +}); + +test('Domains', async () => { + await page.goto('/inventory'); + await page.getByRole('link', { name: 'All Domains' }).click(); + await expect(page).toHaveURL('/inventory/domains'); + if ((await page.getByLabel('Go to next page').isDisabled()) == false) { + await page.getByLabel('Go to next page').click(); + } + if ((await page.getByLabel('Go to previous page').isDisabled()) == false) { + await page.getByLabel('Go to previous page').click(); + } + await page.screenshot({ path: 'test-results/img/global-admin/domains.png' }); +}); + +test('Domain details', async () => { + await page.goto('/inventory/domains'); + await page.getByRole('row').nth(2).getByRole('link').click(); + await expect(page).toHaveURL(new RegExp('/inventory/domain/')); + await expect(page.getByText('IP:')).toBeVisible(); + await expect(page.getByText('First Seen:')).toBeVisible(); + await expect(page.getByText('Last Seen:')).toBeVisible(); + await expect(page.getByText('Organization:')).toBeVisible(); + await page.screenshot({ + path: 'test-results/img/global-admin/domain_details.png' + }); +}); + +test('Domains filter', async () => { + await page.goto('/inventory/domains'); + await page.locator('#organizationName').click(); + await page.locator('#organizationName').fill('Homeland'); + await page.locator('#organizationName').press('Enter'); + let rowCount = await page.getByRole('row').count(); + for (let it = 2; it < rowCount; it++) { + await expect( + page.getByRole('row').nth(it).getByRole('cell').nth(1) + ).toContainText('Homeland'); + } + await page.screenshot({ + path: 'test-results/img/global-admin/domain_filter.png' + }); +}); diff --git a/playwright/e2e/global-admin/vulnerabilities.spec.ts b/playwright/e2e/global-admin/vulnerabilities.spec.ts new file mode 100644 index 00000000..ca92064f --- /dev/null +++ b/playwright/e2e/global-admin/vulnerabilities.spec.ts @@ -0,0 +1,59 @@ +import { test, expect, chromium, Page } from '@playwright/test'; + +test.describe.configure({ mode: 'serial' }); +let page: Page; + +test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + await page.goto('/'); +}); + +test.afterAll(async () => { + await page.close(); +}); + +test('Vulnerabilities', async () => { + await page.getByRole('link', { name: 'Inventory' }).click(); + await page.getByRole('link', { name: 'All Vulnerabilities' }).click(); + await expect(page).toHaveURL('/inventory/vulnerabilities'); + if ((await page.getByLabel('Go to next page').isDisabled()) == false) { + await page.getByLabel('Go to next page').click(); + } + if ((await page.getByLabel('Go to previous page').isDisabled()) == false) { + await page.getByLabel('Go to previous page').click(); + } + await page.screenshot({ + path: 'test-results/img/global-admin/vulnerabilities.png' + }); +}); + +test('Vulnerability details NIST', async () => { + await page.goto('/inventory/vulnerabilities'); + const newTabPromise = page.waitForEvent('popup'); + await page.getByRole('row').nth(2).getByRole('link').nth(0).click(); + const newTab = await newTabPromise; + await newTab.waitForLoadState(); + await expect(newTab).toHaveURL( + new RegExp('^https://nvd\\.nist\\.gov/vuln/detail/') + ); +}); + +test('Domain details link', async () => { + await page.goto('/inventory/vulnerabilities'); + await page.getByRole('row').nth(2).getByRole('link').nth(1).click(); + await expect(page).toHaveURL(new RegExp('/inventory/domain/')); +}); + +test('Vulnerability details', async () => { + await page.goto('/inventory/vulnerabilities'); + await page.getByRole('row').nth(2).getByRole('link').nth(2).click(); + await expect(page).toHaveURL(new RegExp('/inventory/vulnerability/')); + await expect(page.getByRole('heading', { name: 'Overview' })).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Installed (Known) Products' }) + ).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Provenance' })).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Vulnerability Detection History' }) + ).toBeVisible(); +}); diff --git a/playwright/global-setup.ts b/playwright/global-setup.ts new file mode 100644 index 00000000..410d250a --- /dev/null +++ b/playwright/global-setup.ts @@ -0,0 +1,42 @@ +import { chromium, FullConfig, test as setup } from '@playwright/test'; +import * as OTPAuth from 'otpauth'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +const authFile = './storageState.json'; + +let totp = new OTPAuth.TOTP({ + issuer: process.env.PW_XFD_2FA_ISSUER, + label: 'Crossfeed', + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: process.env.PW_XFD_2FA_SECRET +}); + +async function globalSetup(config: FullConfig) { + const { baseURL, storageState } = config.projects[0].use; + const browser = await chromium.launch(); + const page = await browser.newPage(); + + //Log in with credentials. + await page.goto(String(process.env.PW_XFD_URL)); + await page + .getByPlaceholder('Enter your email address') + .fill(String(process.env.PW_XFD_USERNAME)); + await page + .getByPlaceholder('Enter your password') + .fill(String(process.env.PW_XFD_PASSWORD)); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page + .getByPlaceholder('Enter code from your authenticator app') + .fill(totp.generate()); + await page.getByRole('button', { name: 'Confirm' }).click(); + //Wait for storageState to write to json file for other tests to use. + await page.waitForTimeout(1000); + await page.context().storageState({ path: authFile }); + await page.close(); +} + +export default globalSetup; diff --git a/playwright/package-lock.json b/playwright/package-lock.json new file mode 100644 index 00000000..39b36fb4 --- /dev/null +++ b/playwright/package-lock.json @@ -0,0 +1,125 @@ +{ + "lockfileVersion": 3, + "name": "xfd_playwright", + "packages": { + "": { + "dependencies": { + "dotenv": "^16.4.5", + "otpauth": "^9.2.2" + }, + "devDependencies": { + "@playwright/test": "^1.41.2", + "@types/node": "^20.11.20" + }, + "license": "ISC", + "name": "xfd_playwright", + "version": "1.0.0" + }, + "node_modules/@playwright/test": { + "bin": { + "playwright": "cli.js" + }, + "dependencies": { + "playwright": "1.41.2" + }, + "dev": true, + "engines": { + "node": ">=16" + }, + "integrity": "sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz", + "version": "1.41.2" + }, + "node_modules/@types/node": { + "dependencies": { + "undici-types": "~5.26.4" + }, + "dev": true, + "integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz", + "version": "20.11.20" + }, + "node_modules/dotenv": { + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + }, + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "version": "16.4.5" + }, + "node_modules/fsevents": { + "dev": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + }, + "hasInstallScript": true, + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "optional": true, + "os": [ + "darwin" + ], + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "version": "2.3.2" + }, + "node_modules/jssha": { + "engines": { + "node": "*" + }, + "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz", + "version": "3.3.1" + }, + "node_modules/otpauth": { + "dependencies": { + "jssha": "~3.3.1" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + }, + "integrity": "sha512-2VcnYRUmq1dNckIfySNYP32ITWp1bvTeAEW0BSCR6G3GBf3a5zb9E+ubY62t3Dma9RjoHlvd7QpmzHfJZRkiNg==", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.2.2.tgz", + "version": "9.2.2" + }, + "node_modules/playwright": { + "bin": { + "playwright": "cli.js" + }, + "dependencies": { + "playwright-core": "1.41.2" + }, + "dev": true, + "engines": { + "node": ">=16" + }, + "integrity": "sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==", + "optionalDependencies": { + "fsevents": "2.3.2" + }, + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.2.tgz", + "version": "1.41.2" + }, + "node_modules/playwright-core": { + "bin": { + "playwright-core": "cli.js" + }, + "dev": true, + "engines": { + "node": ">=16" + }, + "integrity": "sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.2.tgz", + "version": "1.41.2" + }, + "node_modules/undici-types": { + "dev": true, + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "version": "5.26.5" + } + }, + "requires": true, + "version": "1.0.0" +} diff --git a/playwright/package.json b/playwright/package.json new file mode 100644 index 00000000..38e48e97 --- /dev/null +++ b/playwright/package.json @@ -0,0 +1,18 @@ +{ + "author": "", + "dependencies": { + "dotenv": "^16.4.5", + "otpauth": "^9.2.2" + }, + "description": "", + "devDependencies": { + "@playwright/test": "^1.41.2", + "@types/node": "^20.11.20" + }, + "keywords": [], + "license": "ISC", + "main": "index.js", + "name": "xfd_playwright", + "scripts": {}, + "version": "1.0.0" +} diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts new file mode 100644 index 00000000..2781f82f --- /dev/null +++ b/playwright/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ + +dotenv.config(); + +export default defineConfig({ + globalSetup: './global-setup', + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['list', { printSteps: true }], + ['json', { outputFile: 'test-results/test-results.json' }], + ['html', { open: 'always' }] + ], + /* 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: process.env.PW_XFD_URL, + storageState: 'storageState.json', + + /* 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'] } + } + ] +}); diff --git a/playwright/tests-examples/demo-todo-app.spec.ts b/playwright/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 00000000..775ea003 --- /dev/null +++ b/playwright/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,489 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +]; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([TODO_ITEMS[0]]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ + page + }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ + page + }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count'); + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass([ + 'completed', + 'completed', + 'completed' + ]); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ + page + }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ + page + }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe('Item', () => { + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); + }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue( + TODO_ITEMS[1] + ); + await secondTodo + .getByRole('textbox', { name: 'Edit' }) + .fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect( + todoItem.locator('label', { + hasText: TODO_ITEMS[1] + }) + ).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .fill('buy some sausages'); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .fill(' buy some sausages '); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ + page + }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .press('Enter'); + + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .fill('buy some sausages'); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count'); + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect( + page.getByRole('button', { name: 'Clear completed' }) + ).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ + page + }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect( + page.getByRole('button', { name: 'Clear completed' }) + ).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass( + 'selected' + ); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction((e) => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage( + page: Page, + expected: number +) { + return await page.waitForFunction((e) => { + return ( + JSON.parse(localStorage['react-todos']).filter( + (todo: any) => todo.completed + ).length === e + ); + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction((t) => { + return JSON.parse(localStorage['react-todos']) + .map((todo: any) => todo.title) + .includes(t); + }, title); +} diff --git a/playwright/tests/example.spec.ts b/playwright/tests/example.spec.ts new file mode 100644 index 00000000..2a6048e1 --- /dev/null +++ b/playwright/tests/example.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://localhost/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Crossfeed/); +}); + +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(); +});