From 25f7f2985227758be058b3a9b31564f85576c8e8 Mon Sep 17 00:00:00 2001 From: Walmyr Date: Fri, 20 Dec 2024 23:42:47 +0100 Subject: [PATCH] WIP: Add Playwright tests --- .github/workflows/playwright.yml | 25 +++ .gitignore | 9 +- package-lock.json | 89 +++++++++-- package.json | 8 +- playwright.config.js | 42 +++++ tests/example.txt | 1 + tests/playground.spec.js | 253 +++++++++++++++++++++++++++++++ 7 files changed, 413 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 playwright.config.js create mode 100644 tests/example.txt create mode 100644 tests/playground.spec.js diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..848ed87 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,25 @@ +name: Playwright Tests +on: [pull_request, workflow_dispatch] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + env: + PASSWORD: ${{ secrets.PASSWORD }} + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index ff90a27..f20a7b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ .DS_Store -cypress.env.json + node_modules/ + +cypress.env.json cypress/downloads/ cypress/screenshots/ cypress/videos/ + +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ diff --git a/package-lock.json b/package-lock.json index 843bfa0..774c89b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "1.0.0", "license": "MIT", "devDependencies": { + "@playwright/test": "^1.49.1", + "@types/node": "^22.10.2", "axe-core": "^4.10.2", "cypress": "^13.17.0", - "cypress-axe": "^1.5.0" + "cypress-axe": "^1.5.0", + "lodash": "^4.17.21" } }, "node_modules/@colors/colors": { @@ -73,14 +76,30 @@ "ms": "^2.1.1" } }, + "node_modules/@playwright/test": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", + "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@types/node": { - "version": "18.19.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.6.tgz", - "integrity": "sha512-X36s5CXMrrJOs2lQCdDF68apW4Rfx9ixYMawlepwmE4Anezv/AV2LSpKD1Ub8DAc+urp5bk0BGZ6NtmBitfnsg==", + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", "dev": true, - "optional": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.20.0" } }, "node_modules/@types/sinonjs__fake-timers": { @@ -907,6 +926,21 @@ "node": ">=10" } }, + "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/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1295,7 +1329,8 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.once": { "version": "4.1.1", @@ -1534,6 +1569,38 @@ "node": ">=0.10.0" } }, + "node_modules/playwright": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -1928,11 +1995,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "dev": true, - "optional": true + "license": "MIT" }, "node_modules/universalify": { "version": "2.0.1", diff --git a/package.json b/package.json index 4bc70c5..0835cca 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "cy:open:prod": "cypress open --env environment=prod", "test": "cypress run --record false --browser chrome", "test:prod": "cypress run --record false --browser chrome --env environment=prod", - "test:prod:cloud": "cypress run --browser chrome --record --tag 'prod' --env environment=prod" + "test:prod:cloud": "cypress run --browser chrome --record --tag 'prod' --env environment=prod", + "pw:open": "PASSWORD='53cR37P@55w0rD' playwright test --ui" }, "keywords": [ "Cypress Playground", @@ -20,8 +21,11 @@ "author": "Walmyr (https://walmyr.dev/)", "license": "MIT", "devDependencies": { + "@playwright/test": "^1.49.1", + "@types/node": "^22.10.2", "axe-core": "^4.10.2", "cypress": "^13.17.0", - "cypress-axe": "^1.5.0" + "cypress-axe": "^1.5.0", + "lodash": "^4.17.21" } } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..80352f7 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,42 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test') + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config({ path: path.resolve(__dirname, '.env') }) + +/** + * @see https://playwright.dev/docs/test-configuration + */ +module.exports = defineConfig({ + testDir: './tests', + /* 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://127.0.0.1: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'] }, + }, + ], +}) + diff --git a/tests/example.txt b/tests/example.txt new file mode 100644 index 0000000..b45ef6f --- /dev/null +++ b/tests/example.txt @@ -0,0 +1 @@ +Hello, World! \ No newline at end of file diff --git a/tests/playground.spec.js b/tests/playground.spec.js new file mode 100644 index 0000000..cc8803b --- /dev/null +++ b/tests/playground.spec.js @@ -0,0 +1,253 @@ +const { test, expect } = require('@playwright/test') +const { times } = require('lodash') + +const fs = require('fs') +const path = require('path') + +test.beforeEach(async ({ page }) => { + const date = new Date(Date.UTC(1982, 3, 15)) + await page.clock.setFixedTime(date) + + // const environment = process.env.ENVIRONMENT + // const url = environment === 'prod' + // ? 'https://cypress-playground.s3.eu-central-1.amazonaws.com/index.html' + // : './src/index.html' + // await page.goto(url) + await page.goto('https://cypress-playground.s3.eu-central-1.amazonaws.com/index.html') +}) + +test.describe('Playwright Playground', () => { + test('shows a promotional banner', async ({ page }) => { + const banner = await page.locator('#promotional-banner') + expect(await banner).toBeVisible() + expect(await banner.innerText()).toContain('📣 Get to know the Cypress, from Zero to the Cloud course!') + + const link = await banner.locator('a') + expect(await link.getAttribute('target')).toEqual('_blank') + expect(await link.getAttribute('href')).toEqual('https://www.udemy.com/course/cypress-from-zero-to-the-cloud/?referralCode=CABCDDFA5ADBB7BE2E1A') + }) + + test('after visiting a page, asserts some text is visible', async ({ page }) => { + const header = await page.locator('h1') + expect(await header).toBeVisible() + expect(await header.innerText()).toContain('Cypress Playground') + + const subHeader = await page.locator('#visit h2') + expect(await subHeader).toBeVisible() + expect(await subHeader.innerText()).toEqual('cy.visit()') + }) + + test('clicks a button and ensures an action is triggered', async ({ page }) => { + const successMessage = await page.locator('#click span#success') + await page.click('#click button') + expect(await successMessage).toBeVisible() + expect(await successMessage.innerText()).toEqual("You've been successfully subscribed to our newsletter.") + + await page.clock.runFor(3000) + expect(await successMessage).not.toBeVisible() + }) + + test("types in an input which 'signs' a form, then asserts it's signed", async ({ page }) => { + await page.fill('#type textarea', 'Mad Max') + const signedText = await page.locator('#type em') + expect(await signedText).toBeVisible() + expect(await signedText.innerText()).toEqual('Mad Max') + }) + + test('types in the signature, checks the checkbox to see the preview, then unchecks it', async ({ page }) => { + await page.fill('#check textarea', 'Scarecrow') + await page.check('#check input[type="checkbox"]') + const preview = await page.locator('#check em') + expect(await preview).toBeVisible() + expect(await preview.innerText()).toEqual('Scarecrow') + + await page.uncheck('#check input[type="checkbox"]') + expect(await preview).not.toBeVisible() + }) + + test('checks both radios and asserts if it is "on" or "off"', async ({ page }) => { + const radioOn = await page.locator('#check-radio input[value="on"]') + const radioOff = await page.locator('#check-radio input[value="off"]') + + const onOffText = await page.locator('#check-radio strong p') + expect(await onOffText).toBeVisible() + expect(await onOffText.innerText()).toEqual('ON') + + await radioOff.check() + expect(await onOffText).toBeVisible() + expect(await onOffText.innerText()).toEqual('OFF') + + await radioOn.check() + expect(await onOffText.innerText()).toEqual('ON') + }) + + test('selects a type via the dropdown field and asserts on the selection', async ({ page }) => { + await page.selectOption('#select select[name="selection-type"]', 'VIP') + const selectedOption = await page.getByText("You've selected: VIP") + expect(await selectedOption).toBeVisible() + }) + + test('selects multiple fruits via the dropdown field and asserts on the selection', async ({ page }) => { + await page.selectOption('#select select[multiple]', ['apple', 'banana', 'cherry']) + const selectedFruits = await page.getByText("You've selected the following fruits: apple, banana, cherry") + expect(await selectedFruits).toBeVisible() + }) + + test('uploads a file and asserts the correct file name appears as a paragraph', async ({ page }) => { + const fileInput = await page.locator('#select-file input[type="file"]') + await fileInput.setInputFiles('cypress/fixtures/example.json') + + const selectedFile = await page.getByText('The following file has been selected for upload: example.json') + expect(await selectedFile).toBeVisible() + }) + + test('clicks a button and triggers a request', async ({ page }) => { + const button = await page.locator('#intercept button') + await button.click() + + const response = await page.waitForResponse('https://jsonplaceholder.typicode.com/todos/1') + expect(response.status()).toEqual(200) + + const jsonResponse = await response.json() + expect(jsonResponse.id).toEqual(1) + + const todoIdParagraph = await page.getByText('TODO ID: ') + const titleParagraph = await page.getByText('Title: ') + const completedParagraph = await page.getByText('Completed: ') + const userIdParagraph = await page.getByText('User ID: ') + + expect(await todoIdParagraph).toBeVisible() + expect(await titleParagraph).toBeVisible() + expect(await completedParagraph).toBeVisible() + expect(await userIdParagraph).toBeVisible() + }) + + test('clicks a button and triggers a stubbed request', async ({ page }) => { + await page.route('**/todos/1', route => { + route.fulfill({ + path: './cypress/fixtures/todo.json' + }) + }) + + const button = await page.locator('#intercept button') + await button.click() + + const response = await page.waitForResponse('https://jsonplaceholder.typicode.com/todos/1') + expect(await response.status()).toEqual(200) + + // const jsonResponse = await response.json() + // expect(await jsonResponse.id).toEqual(420) + + const todoIdParagraph = await page.getByText('TODO ID: 420') + const titleParagraph = await page.getByText('Title: Cypress test') + const completedParagraph = await page.getByText('Completed: true') + const userIdParagraph = await page.getByText('User ID: 66') + + expect(await todoIdParagraph).toBeVisible() + expect(await titleParagraph).toBeVisible() + expect(await completedParagraph).toBeVisible() + expect(await userIdParagraph).toBeVisible() + }) + + test('clicks a button and simulates an API failure', async ({ page }) => { + await page.route('**/todos/1', async route => { + await route.fulfill({ + status: 500, + }) + }) + + const button = await page.locator('#intercept button') + await button.click() + + const errorMessage = await page.locator('#intercept .error span') + expect(await errorMessage).toBeVisible() + expect(await errorMessage.innerText()).toEqual('Oops, something went wrong. Refresh the page and try again.') + }) + + test('clicks a button and simulates a network failure', async ({ page }) => { + await page.route('**/todos/1', route => route.abort()) + const button = await page.locator('#intercept button') + await button.click() + + const errorMessage = await page.locator('#intercept .error span') + expect(await errorMessage).toBeVisible() + expect(await errorMessage.innerText()).toEqual('Oops, something went wrong. Check your internet connection, refresh the page, and try again.') + }) + + test('makes an HTTP request and asserts on the returned status code', async ({ page }) => { + const response = await page.request.get('https://jsonplaceholder.typicode.com/todos/1') + expect(response.status()).toEqual(200) + }) + + times(10, index => { + test(`selects ${index + 1} out of 10`, async ({ page }) => { + await page.fill('#input-range input[type="range"]', `${index + 1}`) + const selectedLevel = await page.getByText(`You're on level: ${index + 1}`) + expect(await selectedLevel).toBeVisible() + }) + }) + + test('selects a date and asserts the correct date has been displayed', async ({ page }) => { + await page.fill('#input-date input[type="date"]', '2024-01-16') + + const dateParagraph = await page.locator('#input-date p#date-paragraph') + expect(await dateParagraph).toBeVisible() + expect(await dateParagraph.innerText()).toEqual("The date you've selected is: 2024-01-16") + }) + + test('types a password without leaking it, shows it, and hides it again', async ({ page }) => { + await page.fill('#password-input input[type="password"]', process.env.PASSWORD) + + const showPasswordCheckbox = await page.locator('#password-input input[type="checkbox"]') + await showPasswordCheckbox.check() + + const textInput = await page.locator('#password-input input[type="text"]') + expect(await textInput).toBeVisible() + expect(await textInput.inputValue()).toEqual(process.env.PASSWORD) + + await showPasswordCheckbox.uncheck() + expect(await textInput).not.toBeVisible() + }) + + test('counts the number of animals in a list', async ({ page }) => { + const animals = await page.$$('#should-have-length ul li') + expect(animals.length).toEqual(5) + }) + + test('freezes the browser clock and asserts the frozen date is displayed', async ({ page }) => { + const dateSection = await page.locator('#date-section #date-section-paragraph') + expect(await dateSection).toBeVisible() + expect(await dateSection.innerText()).toEqual('Current date: 1982-04-15') + }) + + test('copies the code, types it, submits it, then asserts on the success message', async ({ page }) => { + const codeElement = await page.locator('#copy-paste span#timestamp') + const code = await codeElement.innerText() + + const codeField = await page.locator('#copy-paste input[type="number"]') + await codeField.fill(code) + + const submitCodeButton = await page.locator('#copy-paste button') + await submitCodeButton.click() + + const successCodeSubmissionMessage = await page.getByText("Congrats! You've entered the correct code.") + expect(await successCodeSubmissionMessage).toBeVisible() + + await page.clock.runFor(3000) + expect(await successCodeSubmissionMessage).not.toBeVisible() + }) + + test('downloads a file, reads it, and asserts on its content', async ({ page }) => { + const downloadPromise = page.waitForEvent('download') + const filePath = path.join(__dirname, 'example.txt') + + const downloadLink = await page.getByText('Download a text file').last() + await downloadLink.click() + const download = await downloadPromise + await download.saveAs(`tests/${download.suggestedFilename()}`) + + const fileContent = fs.readFileSync(filePath, 'utf8') + + expect(fileContent).toEqual('Hello, World!') + }) +})