playwright (#3)

* add playwright setup and tests for auth, profile and organization settings

* add playwright github action

* add sqlite database

* fix playwright base url

* add mailpit and parallelization

* remove additional waitForUrl in fixture

* fix tests

* remove waitforurl in tests

* change playwright github action to only one worker

* try promiso all to avoid loading errors

* change environment to include http protocol

* convert back to simpler structure

* add caching of playwright browser binaries

* test if playwright in ci works faster with multiple workers

* change back to one worker

* remove browser binary caching

* try using playwright container to speedup browser setup

* rollback image changes

* add playwright gitignore changes

---------

Co-authored-by: Gregor Vostrak <gregorvostrak@Gregors-MacBook-Pro.local>
This commit is contained in:
Gregor Vostrak
2024-01-23 17:13:25 +01:00
committed by GitHub
parent 27140d4ffc
commit fc524625c2
14 changed files with 505 additions and 7 deletions
+54
View File
@@ -0,0 +1,54 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=database
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
+63
View File
@@ -0,0 +1,63 @@
name: Playwright Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
services:
mailpit:
image: 'axllent/mailpit:latest'
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: none
- name: Run composer install
run: composer install -n --prefer-dist
- name: Create SQLite database
run: touch database/database.sqlite
- name: Prepare Laravel Application
run: |
cp .env.ci .env
php artisan key:generate
php artisan migrate --seed
- name: Install dependencies
run: npm ci
- name: Build Frontend
run: npm run build
- name: Run Laravel Server
run: php artisan serve > /dev/null 2>&1 &
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
env:
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
+4
View File
@@ -20,3 +20,7 @@ yarn-error.log
/.fleet
/.idea
/.vscode
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+16 -1
View File
@@ -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.
+25
View File
@@ -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}"
+54
View File
@@ -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.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.'
);
});
+50
View File
@@ -0,0 +1,50 @@
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 page.reload();
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 page.reload();
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.'
);
});
+21
View File
@@ -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`
);
});
+62 -5
View File
@@ -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",
+4 -1
View File
@@ -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",
+77
View File
@@ -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,
// },
});
+2
View File
@@ -0,0 +1,2 @@
export const PLAYWRIGHT_BASE_URL =
process.env.PLAYWRIGHT_BASE_URL ?? 'http://laravel.test';
+71
View File
@@ -0,0 +1,71 @@
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<object, { workerStorageState: string }>({
// 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' },
],
});
+2
View File
@@ -87,6 +87,7 @@ const logout = () => {
<span class="inline-flex rounded-md">
<button
type="button"
id="currentTeamButton"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700 active:bg-gray-50 dark:active:bg-gray-700 transition ease-in-out duration-150">
{{
page.props.auth.user
@@ -209,6 +210,7 @@ const logout = () => {
page.props.jetstream
.managesProfilePhotos
"
id="currentUserButton"
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
<img
class="h-8 w-8 rounded-full object-cover"