From f486ececcbf2e89c0eb443ef72090a6ce719e87a Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Mon, 22 Jan 2024 16:01:09 +0100 Subject: [PATCH] add playwright setup and tests for auth, profile and organization settings --- README.md | 17 ++++++- docker-compose.yml | 25 ++++++++++ e2e/auth.spec.ts | 54 +++++++++++++++++++++ e2e/organization.spec.ts | 48 +++++++++++++++++++ e2e/profile.spec.ts | 21 ++++++++ package-lock.json | 67 ++++++++++++++++++++++++-- package.json | 5 +- playwright.config.ts | 77 ++++++++++++++++++++++++++++++ playwright/config.ts | 1 + playwright/fixtures.ts | 70 +++++++++++++++++++++++++++ resources/js/Layouts/AppLayout.vue | 2 + 11 files changed, 380 insertions(+), 7 deletions(-) create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/organization.spec.ts create mode 100644 e2e/profile.spec.ts create mode 100644 playwright.config.ts create mode 100644 playwright/config.ts create mode 100644 playwright/fixtures.ts diff --git a/README.md b/README.md index a80f7a84..f3744bc5 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,24 @@ Additional System Requirements: Add the following entry to your `/etc/hosts` ``` -127.0.0.1 time-tracking.local +127.0.0.1 timetracker.test +127.0.0.1 playwright.timetracker.test ``` +## Running E2E Tests + +`./vendor/bin/sail up -d ` will automatically start a Playwright UI server that you can access at `https://playwright.timetracker.test`. +Make sure that you use HTTPS otherwise the resources will not be loaded correctly. + +## Recording E2E Tests + +To record E2E tests, you need to install and execute playwright locally using: + +```bash +npx playwright install +npx playwright codegen timetracker.test +``` + ## Contributing This project is in a very early stage. The structure and APIs are still subject to change and not stable. diff --git a/docker-compose.yml b/docker-compose.yml index a0a43805..3805c45a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,31 @@ services: - '${DB_USERNAME}' retries: 3 timeout: 5s + mailpit: + image: 'axllent/mailpit:latest' + ports: + - '${FORWARD_MAILPIT_PORT:-1025}:1025' + - '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025' + networks: + - sail + playwright: + image: mcr.microsoft.com/playwright:v1.41.1-jammy + command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] + working_dir: /src + labels: + - "traefik.enable=true" + - "traefik.docker.network=${NETWORK_NAME}" + - "traefik.http.routers.playwright.rule=Host(`playwright.${NGINX_HOST_NAME}`)" + - "traefik.http.routers.playwright.entrypoints=web" + - "traefik.http.services.playwright.loadbalancer.server.port=8080" + - "traefik.http.routers.playwright-https.rule=Host(`playwright.${NGINX_HOST_NAME}`)" + - "traefik.http.routers.playwright-https.entrypoints=websecure" + - "traefik.http.routers.playwright-https.tls=true" + networks: + - sail + - reverse-proxy + volumes: + - '.:/src' networks: reverse-proxy: name: "${NETWORK_NAME}" diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 00000000..2c663be2 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; + +async function registerNewUser(page, email, password) { + await page.getByRole('link', { name: 'Register' }).click(); + await page.getByLabel('Name').fill('John Doe'); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByLabel('Confirm Password').fill(password); + await page.getByRole('button', { name: 'Register' }).click(); + await expect( + page.getByRole('heading', { name: 'Dashboard' }) + ).toBeVisible(); +} + +test('can register, logout and log back in', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL); + const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; + const password = 'suchagreatpassword123'; + await registerNewUser(page, email, password); + await expect( + page.getByRole('button', { name: "John's Organization" }) + ).toBeVisible(); + await page.locator('#currentUserButton').click(); + await page.getByRole('button', { name: 'Log Out' }).click(); + await page.waitForLoadState('networkidle'); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/'); + await page.goto(PLAYWRIGHT_BASE_URL + '/login'); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect( + page.getByRole('heading', { name: 'Dashboard' }) + ).toBeVisible(); +}); + +test('can register and delete account', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL); + const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; + const password = 'suchagreatpassword123'; + await registerNewUser(page, email, password); + await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); + await page.getByRole('button', { name: 'Delete Account' }).click(); + await page.getByPlaceholder('Password').fill(password); + await page.getByRole('button', { name: 'Delete Account' }).nth(1).click(); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/'); + await page.goto(PLAYWRIGHT_BASE_URL + '/login'); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByRole('paragraph')).toContainText( + 'These credentials do not match our records.' + ); +}); diff --git a/e2e/organization.spec.ts b/e2e/organization.spec.ts new file mode 100644 index 00000000..b5b1bbd6 --- /dev/null +++ b/e2e/organization.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; + +async function goToOrganizationSettings(page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await page.locator('#currentTeamButton').click(); + await page.getByRole('link', { name: 'Team Settings' }).click(); +} + +test('test that organization name can be updated', async ({ page }) => { + await goToOrganizationSettings(page); + await page.getByLabel('Team Name').fill('NEW ORG NAME'); + await page.getByLabel('Team Name').press('Enter'); + await page.getByLabel('Team Name').press('Meta+r'); + await expect(page.getByRole('navigation')).toContainText('NEW ORG NAME'); +}); + +test('test that new editor can be invited', async ({ page }) => { + await goToOrganizationSettings(page); + const editorId = Math.round(Math.random() * 10000); + await page.getByLabel('Email').fill(`new+${editorId}@editor.test`); + await page.getByRole('button', { name: 'Editor' }).click(); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByRole('main')).toContainText( + `new+${editorId}@editor.test` + ); +}); + +test('test that new admin can be invited', async ({ page }) => { + await goToOrganizationSettings(page); + const adminId = Math.round(Math.random() * 10000); + await page.getByLabel('Email').fill(`new+${adminId}@admin.test`); + await page.getByRole('button', { name: 'Administrator' }).click(); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByRole('main')).toContainText( + `new+${adminId}@admin.test` + ); +}); +test('test that error shows if no role is selected', async ({ page }) => { + await goToOrganizationSettings(page); + const noRoleId = Math.round(Math.random() * 10000); + + await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByRole('main')).toContainText( + 'The role field is required.' + ); +}); diff --git a/e2e/profile.spec.ts b/e2e/profile.spec.ts new file mode 100644 index 00000000..2f66648f --- /dev/null +++ b/e2e/profile.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; + +test('test that user name can be updated', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); + await page.getByLabel('Name').fill('NEW NAME'); + await page.getByRole('button', { name: 'Save' }).first().click(); + await page.reload(); + await expect(page.getByLabel('Name')).toHaveValue('NEW NAME'); +}); + +test('test that user email can be updated', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); + const emailId = Math.round(Math.random() * 10000); + await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`); + await page.getByRole('button', { name: 'Save' }).first().click(); + await page.reload(); + await expect(page.getByLabel('Email')).toHaveValue( + `newemail+${emailId}@test.com` + ); +}); diff --git a/package-lock.json b/package-lock.json index 0155f9d8..456074b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,10 @@ }, "devDependencies": { "@inertiajs/vue3": "^1.0.0", + "@playwright/test": "^1.41.1", "@tailwindcss/forms": "^0.5.2", "@tailwindcss/typography": "^0.5.2", + "@types/node": "^20.11.5", "@types/ziggy-js": "^1.8.0", "@vitejs/plugin-vue": "^4.5.0", "@vue/tsconfig": "^0.5.1", @@ -883,6 +885,21 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.1.tgz", + "integrity": "sha512-9g8EWTjiQ9yFBXc6HjCWe41msLpxEX0KhmfmPl9RPLJdfzL4F0lg2BdJ91O9azFdl11y1pmpwdjBiSxvqc+btw==", + "dev": true, + "dependencies": { + "playwright": "1.41.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.5.tgz", @@ -1100,8 +1117,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.5.tgz", "integrity": "sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -3499,6 +3514,50 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.1.tgz", + "integrity": "sha512-gdZAWG97oUnbBdRL3GuBvX3nDDmUOuqzV/D24dytqlKt+eI5KbwusluZRGljx1YoJKZ2NRPaeWiFTeGZO7SosQ==", + "dev": true, + "dependencies": { + "playwright-core": "1.41.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.1.tgz", + "integrity": "sha512-/KPO5DzXSMlxSX77wy+HihKGOunh3hqndhqeo/nMxfigiKzogn8kfL0ZBDu0L1RKgan5XHCPmn6zXd2NUJgjhg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.4.33", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", @@ -4336,9 +4395,7 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/universalify": { "version": "2.0.1", diff --git a/package.json b/package.json index 7a488a05..5d9345f0 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,15 @@ "build": "vite build", "lint": "eslint --ext .js,.vue,.ts --ignore-path .gitignore .", "lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore .", - "type-check": "vue-tsc --noEmit" + "type-check": "vue-tsc --noEmit", + "test:e2e": "npx playwright test" }, "devDependencies": { "@inertiajs/vue3": "^1.0.0", + "@playwright/test": "^1.41.1", "@tailwindcss/forms": "^0.5.2", "@tailwindcss/typography": "^0.5.2", + "@types/node": "^20.11.5", "@types/ziggy-js": "^1.8.0", "@vitejs/plugin-vue": "^4.5.0", "@vue/tsconfig": "^0.5.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..5a1b230e --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + 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: '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'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/playwright/config.ts b/playwright/config.ts new file mode 100644 index 00000000..961b435b --- /dev/null +++ b/playwright/config.ts @@ -0,0 +1 @@ +export const PLAYWRIGHT_BASE_URL = 'http://laravel.test'; diff --git a/playwright/fixtures.ts b/playwright/fixtures.ts new file mode 100644 index 00000000..5e73cabf --- /dev/null +++ b/playwright/fixtures.ts @@ -0,0 +1,70 @@ +import { expect, test as baseTest } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { PLAYWRIGHT_BASE_URL } from './config'; + +export * from '@playwright/test'; +export const test = baseTest.extend({ + // Use the same storage state for all tests in this worker. + storageState: ({ workerStorageState }, use) => use(workerStorageState), + + // Authenticate once per worker with a worker-scoped fixture. + workerStorageState: [ + async ({ browser }, use) => { + // Use parallelIndex as a unique identifier for each worker. + const id = test.info().parallelIndex; + const fileName = path.resolve( + test.info().project.outputDir, + `.auth/${id}.json` + ); + + if (fs.existsSync(fileName)) { + // Reuse existing authentication state if any. + await use(fileName); + return; + } + + // Important: make sure we authenticate in a clean environment by unsetting storage state. + const page = await browser.newPage({ storageState: undefined }); + + // Acquire a unique account, for example create a new one. + // Alternatively, you can have a list of precreated accounts for testing. + // Make sure that accounts are unique, so that multiple team members + // can run tests at the same time without interference. + // const account = await acquireAccount(id); + + // TODO: Use Seeder Accounts instead of creating new ones + + // Perform authentication steps. Replace these actions with your own. + await page.goto(PLAYWRIGHT_BASE_URL + '/register'); + await page.getByLabel('Name').fill('John Doe'); + await page + .getByLabel('Email') + .fill(`john+${Math.round(Math.random() * 10000)}@doe.com`); + await page + .getByLabel('Password', { exact: true }) + .fill('amazingpassword123'); + await page + .getByLabel('Confirm Password') + .fill('amazingpassword123'); + await page.getByRole('button', { name: 'Register' }).click(); + + // Wait until the page receives the cookies. + // + // Sometimes login flow sets cookies in the process of several redirects. + // Wait for the final URL to ensure that the cookies are actually set. + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/dashboard'); + // Alternatively, you can wait until the page reaches a state where all cookies are set. + await expect( + page.getByRole('heading', { name: 'Dashboard' }) + ).toBeVisible(); + + // End of authentication steps. + + await page.context().storageState({ path: fileName }); + await page.close(); + await use(fileName); + }, + { scope: 'worker' }, + ], +}); diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index 487d040a..f7c0dbc4 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -87,6 +87,7 @@ const logout = () => {