From 5678902f2e368fb8417e15b0d8bbb6047843c5b3 Mon Sep 17 00:00:00 2001 From: threadi Date: Fri, 9 Oct 2026 18:55:42 +0200 Subject: [PATCH] add Playwright --- .github/workflows/build-zip.yml | 54 +++++++- .gitignore | 5 + app/Plugin/Settings.php | 2 +- changelog.md | 2 + package.json | 4 +- playwright.config.ts | 63 +++++++++ readme.md | 39 +++++- readme.txt | 4 +- tests/Playwright/fixtures.ts | 176 ++++++++++++++++++++++++ tests/Playwright/frontend.spec.ts | 92 +++++++++++++ tests/Playwright/helpers.ts | 213 +++++++++++++++++++++++++++++ tests/Playwright/plugin.spec.ts | 51 +++++++ tests/Playwright/positions.spec.ts | 68 +++++++++ tests/Playwright/settings.spec.ts | 53 +++++++ tests/Playwright/setup.spec.ts | 70 ++++++++++ 15 files changed, 890 insertions(+), 6 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/Playwright/fixtures.ts create mode 100644 tests/Playwright/frontend.spec.ts create mode 100644 tests/Playwright/helpers.ts create mode 100644 tests/Playwright/plugin.spec.ts create mode 100644 tests/Playwright/positions.spec.ts create mode 100644 tests/Playwright/settings.spec.ts create mode 100644 tests/Playwright/setup.spec.ts diff --git a/.github/workflows/build-zip.yml b/.github/workflows/build-zip.yml index 7fa50836..2ba4c529 100644 --- a/.github/workflows/build-zip.yml +++ b/.github/workflows/build-zip.yml @@ -211,10 +211,62 @@ jobs: release-notes.md if-no-files-found: error + # the end-to-end tests run beside "build". A release is only published if both succeeded. + playwright: + name: Playwright tests + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + # the Playground packages in package-lock.json need Node 24. + node-version: 24 + + # --- install and compile, as in "build" ----------------------------------------------------------- + + - name: Install PHP packages + # also runs Mozart, which copies easy-transients-for-wordpress to app/Dependencies. + run: composer install --no-interaction --no-progress + + - name: Build blocks, commands and copy driver.js + run: | + npm ci + npm run build + npm run global-styles + + - name: Generate the URL constants + # the placeholders are enough for the tests, they never contact these URLs. + run: cp inc/constants_urls.php.dist inc/constants_urls.php + + # --- run the tests -------------------------------------------------------------------------------- + + - name: Install the browser for Playwright + run: npx --no -- playwright install --with-deps chromium + + - name: Run the Playwright tests + run: npx --no -- playwright test + + - name: Hand over the report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 14 + if-no-files-found: ignore + # the only job which may write to the repository - and it runs no code from the repository or from npm. release: name: Publish the release - needs: [build] + needs: [build, playwright] # only for a pushed tag, never for a manual run. if: github.event_name == 'push' runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index b5e82b06..8fb63658 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,8 @@ languages/*.json /vendor/ *.min.css +/node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/app/Plugin/Settings.php b/app/Plugin/Settings.php index b99a5155..f56e57b9 100644 --- a/app/Plugin/Settings.php +++ b/app/Plugin/Settings.php @@ -720,7 +720,7 @@ public function add_the_settings(): void { $setting = $settings_obj->add_setting( 'personio_integration_light_setting_view' ); $setting->set_section( $advanced ); $setting->set_type( 'string' ); - $setting->set_default( 'classic' ); + $setting->set_default( 'dataview' ); $field = new Select( $settings_obj ); $field->set_title( __( 'Settings view', 'personio-integration-light' ) ); $field->set_description( __( 'Choose the view for the settings of this plugin. DataView is only available for WordPress 7 or newer.', 'personio-integration-light' ) ); diff --git a/changelog.md b/changelog.md index 3b53401e..fa6ae774 100644 --- a/changelog.md +++ b/changelog.md @@ -11,6 +11,7 @@ - Added abilities to run and cancel the import of positions, to delete all positions and to read the log with AI, and extended the import state ability with the progress - Added a link to edit a single position in your Personio account in the list of all positions in backend - Added setting to chose, which API to use for import of positions +- Added Playwright for automated visual tests before each release - Added new hooks ### Changed @@ -34,6 +35,7 @@ - The license key is not part of URLs anymore and is not loaded on every request - Log cleanup runs at most once per hour, the log table is paginated in the database - Release ZIP from GitHub does not contain development files anymore +- Settings default view is now DataView for all new installations (old stay unchanged) - Updated the settings library - Updated the setup library - Updated dependencies diff --git a/package.json b/package.json index 6ab63ef5..802a2a8f 100644 --- a/package.json +++ b/package.json @@ -28,13 +28,15 @@ "start:commands": "wp-scripts start blocks/commands.js --output-path=blocks/commands", "build:commands": "wp-scripts build blocks/commands.js --output-path=blocks/commands", "global-styles": "cat blocks/*/build/style-index.css > css/blocks.css", + "test:e2e": "playwright test", "copy:driver": "mkdir -p admin/driver && cp node_modules/driver.js/dist/driver.js.iife.js node_modules/driver.js/dist/driver.css admin/driver/ && cp node_modules/driver.js/license admin/driver/" }, "devDependencies": { "@babel/core": "^7.28.5", "@wordpress/scripts": "~36.0", "@wp-playground/cli": "^3.1.57", - "concurrently": "^9.2.3" + "concurrently": "^9.2.3", + "@playwright/test": "^1.62.1" }, "dependencies": { "@babel/runtime": "^7.28.4", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..1035e315 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,63 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for the end-to-end tests of Personio Integration Light. + * + * Every test worker starts its own WordPress Playground with this plugin mounted + * (see tests/Playwright/fixtures.ts). Starting Playground takes a while and uses a + * lot of memory, therefore the tests run in one worker, one after another. + * + * Run them with: npx playwright test + */ +// @ts-ignore +const isCI = !! process.env.CI; + +export default defineConfig( { + testDir: './tests/Playwright', + testMatch: '**/*.spec.ts', + + // one Playground for all tests: they share the website, see helpers.ts. + fullyParallel: false, + workers: 1, + + // test.only must never end up in the repository. + forbidOnly: isCI, + + // a failed test gets one more try in CI. Playwright starts a fresh website for it. + retries: isCI ? 1 : 0, + + // a single test. Starting Playground is not part of it, see "cli" in fixtures.ts. + // Playground is a lot slower than a real WordPress, especially on a shared server, + // and some tests load several pages of the backend. + timeout: 120_000, + expect: { timeout: 20_000 }, + + reporter: isCI + ? [ [ 'github' ], [ 'list' ], [ 'html', { open: 'never', outputFolder: 'playwright-report' } ] ] + : [ [ 'list' ] ], + + outputDir: 'test-results', + + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + locale: 'en-US', + timezoneId: 'Europe/Berlin', + navigationTimeout: 60_000, + actionTimeout: 30_000, + }, + + projects: [ + { + name: 'chromium', + use: { + ...devices[ 'Desktop Chrome' ], + launchOptions: { + // optional: use an already installed Chromium instead of the one of "npx playwright install". + // @ts-ignore + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined, + }, + }, + }, + ], +} ); diff --git a/readme.md b/readme.md index a7ee0067..630fbefa 100644 --- a/readme.md +++ b/readme.md @@ -74,7 +74,29 @@ After checkout go through the following steps: ### On GitHub 1. Create a new tag with the new version number. -2. The release zip will be created by a GitHub action. +2. The release zip will be created by a GitHub action. It is only published if the Playwright tests (see below) succeed. + +## End-to-end tests with Playwright + +The tests in `tests/Playwright` start a WordPress Playground with this plugin and check setup, settings, import and frontend. They need no database and no Personio account: requests to the test Personio URL are answered with `tests/Data/positions.xml`. + +### Preparations + +1. `composer install` +2. `npm ci && npm run build && npm run global-styles` +3. `npx playwright install chromium` (once) + +### Run + +`npx playwright test` (or `npm run test:e2e`) + +Optional environment variables: + +* `PLAYGROUND_WP`: WordPress version, e.g. `6.8`, or the URL of a WordPress zip (default: `latest`) +* `PLAYGROUND_PHP`: PHP version (default: `8.3`) +* `PLAYGROUND_HOME`: where Playground keeps the downloaded WordPress (default: `node_modules/.cache/playground-home`) + +Each test fails if the plugin causes a PHP error, warning, notice or deprecation. ## Translations @@ -167,3 +189,18 @@ Then: `composer test-install` ### Run `composer test` + +## Playwright + +See: https://wordpress.github.io/wordpress-playground/guides/e2e-testing-with-playwright/ + +### Prepare + +``` +npm install +npx playwright install chromium +``` + +### Run + +`npx playwright test` diff --git a/readme.txt b/readme.txt index 90603367..c6a20e3b 100644 --- a/readme.txt +++ b/readme.txt @@ -44,8 +44,8 @@ The output of the positions as a list is limited to a maximum of 10. Only in [Pe #### the Pro license includes: -➕ Native support for following PageBuilders: Avada, Beaver Builder, Breakdance, Bricks, Brizy, Divi 4 & 5, Elementor, Elements in GeneratePress Premium, SiteOrigin (SiteOrigin Widgets Bundle necessary), WPBakery -➕ Also compatible with Avia (from Enfold) and Kubio AI +➕ Native support for following PageBuilders: Avada, Beaver Builder, Bold Page Builder, Breakdance, Bricks, Brizy, Divi 4 & 5, Elementor, Elements in GeneratePress Premium, Fusion, LiveComposer, Oxygen, PageLayer, SiteOrigin (SiteOrigin Widgets Bundle necessary), Themify, WPBakery und ZionBuilder +➕ Also compatible with Avia (from Enfold), Colibri, Kubio, Nimble Builder, SeedProd Pro, Visual Composer ➕ Multiple and customizable application forms incl. export of them via Personio API ➕ Support for multiple form handler like Avada Forms, Contact Form 7, Elementor Forms, Everest Forms, Fluent Forms, Forminator, Ninja Forms and WPForms ➕ Customization of slugs (URLs) for list and detailed views of positions diff --git a/tests/Playwright/fixtures.ts b/tests/Playwright/fixtures.ts new file mode 100644 index 00000000..ae493418 --- /dev/null +++ b/tests/Playwright/fixtures.ts @@ -0,0 +1,176 @@ +// @ts-ignore +import { test as base, type Page } from '@playwright/test'; +// @ts-ignore +import { execSync } from 'node:child_process'; +// @ts-ignore +import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs'; +// @ts-ignore +import path from 'node:path'; +import { clearPhpErrors, getPhpErrors, type Cli } from './helpers'; + +/** + * Fixtures for the Playwright tests. + * + * Each test worker starts one WordPress Playground with this plugin mounted from the + * working copy and activated. "page" is the logged-in administrator, "visitor" is + * somebody who is not logged in. + * + * The WordPress and PHP version can be set with environment variables: + * - PLAYGROUND_WP: "latest" (default), a version like "6.8" or the URL of a WordPress zip. + * - PLAYGROUND_PHP: "8.3" (default) or any other PHP version Playground supports. + * - PLAYGROUND_HOME: where Playground keeps the WordPress it downloads, see loadPlayground(). + */ + +// @ts-ignore +const pluginRoot = path.resolve(__dirname, '../..'); +const pluginSlug = 'personio-integration-light'; +const autoloadPath = path.join(pluginRoot, 'vendor', 'autoload.php'); +const lockPath = path.join(pluginRoot, 'composer.lock'); +const urlConstantsPath = path.join(pluginRoot, 'inc', 'constants_urls.php'); + +function needsComposerInstall(): boolean { + if (!existsSync(autoloadPath)) return true; + if (!existsSync(lockPath)) return false; + return statSync(lockPath).mtimeMs > statSync(autoloadPath).mtimeMs; +} + +/** + * Make sure the working copy contains everything the plugin needs at runtime. + */ +function preparePlugin(): void { + if (needsComposerInstall()) { + console.log('Running composer install …'); + execSync('composer install --optimize-autoloader', { + cwd: pluginRoot, + stdio: 'inherit', + }); + } + + // the plugin loads this file. The placeholders of the .dist file are enough for the tests. + if (!existsSync(urlConstantsPath)) { + copyFileSync(`${urlConstantsPath}.dist`, urlConstantsPath); + } + + // the blocks are compiled with npm. Without them the plugin can not be tested. + for (const file of ['blocks/list/build/index.asset.php', 'blocks/commands/commands.asset.php', 'css/blocks.css']) { + if (!existsSync(path.join(pluginRoot, file))) { + throw new Error(`${file} is missing. Run "npm run build && npm run global-styles" first.`); + } + } +} + +/** + * Load Playground's runCLI(). + * + * Playground keeps the WordPress it downloads in "/.wordpress-playground" and + * determines that directory once, while its module is loaded. The home directory is not always + * writable, e.g. for the user of a web server. Therefore the module is loaded here with HOME + * pointing to node_modules/.cache/playground-home (or PLAYGROUND_HOME). HOME is restored right + * after, as Playwright itself looks for its browsers in the real home directory. + */ +async function loadPlayground() { + // @ts-ignore + const playgroundHome = process.env.PLAYGROUND_HOME || path.join(pluginRoot, 'node_modules', '.cache', 'playground-home'); + mkdirSync(playgroundHome, { recursive: true }); + + // @ts-ignore + const env = process.env; + const realHome = env.HOME; + const realUserProfile = env.USERPROFILE; + env.HOME = playgroundHome; + env.USERPROFILE = playgroundHome; + try { + return (await import('@wp-playground/cli')).runCLI; + } finally { + if (realHome === undefined) delete env.HOME; else env.HOME = realHome; + if (realUserProfile === undefined) delete env.USERPROFILE; else env.USERPROFILE = realUserProfile; + } +} + +export const test = base.extend<{ visitor: Page; phpErrors: void }, { cli: Cli }>({ + // a page of somebody who is not logged in. "page" is always the administrator: + // Playground logs every browser in, unless this cookie says it already did. + // @ts-ignore + visitor: async ({ browser, cli }, use) => { + const context = await browser.newContext(); + await context.addCookies([ + { name: 'playground_auto_login_already_happened', value: '1', url: cli.serverUrl }, + ]); + + await use(await context.newPage()); + + await context.close(); + }, + + // every test fails if the plugin caused a PHP error, warning, notice or deprecation meanwhile. + // Tests which provoke one on purpose can call clearPhpErrors() before they end. + phpErrors: [ + // @ts-ignore + async ({ cli }, use) => { + await clearPhpErrors(cli); + await use(); + const errors = await getPhpErrors(cli); + // @ts-ignore + base.expect(errors, 'PHP messages caused by the plugin (wp-content/debug.log)').toEqual([]); + }, + { auto: true }, + ], + + // worker-scoped: runs once per test worker, not once per test. + cli: [ + // @ts-ignore + async ({}, use) => { + preparePlugin(); + const runCLI = await loadPlayground(); + + const server = await runCLI({ + command: 'server', + // the database of each run is new and empty, nothing is kept between runs. + mount: [ + { + hostPath: pluginRoot, + vfsPath: `/wordpress/wp-content/plugins/${pluginSlug}`, + }, + ], + blueprint: { + preferredVersions: { + // @ts-ignore + php: process.env.PLAYGROUND_PHP || '8.3', + // @ts-ignore + wp: process.env.PLAYGROUND_WP || 'latest', + }, + login: true, + steps: [ + { + // collect PHP messages in wp-content/debug.log, see "phpErrors" above. + step: 'defineWpConfigConsts', + consts: { WP_DEBUG: true, WP_DEBUG_LOG: true, WP_DEBUG_DISPLAY: false }, + }, + { + // pretty permalinks, so the archive and the single positions have their slugs. + step: 'setSiteOptions', + options: { permalink_structure: '/%postname%/' }, + }, + { + step: 'activatePlugin', + pluginPath: `${pluginSlug}/${pluginSlug}.php`, + }, + ], + }, + }); + + // the very first request to a new Playground server is answered with a redirect + // which also removes the cookie the "visitor" above relies on. Get it out of the way. + await fetch(`${server.serverUrl}/`, { redirect: 'manual' }); + + await use(server as unknown as Cli); + + await server[Symbol.asyncDispose](); + }, + // starting Playground (and downloading WordPress the first time) takes longer than a test. + { scope: 'worker', timeout: 300_000 }, + ], +}); + +// @ts-ignore +export { expect } from '@playwright/test'; diff --git a/tests/Playwright/frontend.spec.ts b/tests/Playwright/frontend.spec.ts new file mode 100644 index 00000000..8d3f4142 --- /dev/null +++ b/tests/Playwright/frontend.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from './fixtures'; +import { createPage, getPositions, importPositions, personioUrl } from './helpers'; + +/** + * What visitors of the website see. + */ +test.describe('Positions in the frontend', () => { + test.beforeEach(async ({ cli }) => { + expect(await importPositions(cli)).toBe(11); + }); + + test('the archive lists the positions, at most 10 in Light', async ({ visitor, cli }) => { + const response = await visitor.goto(`${cli.serverUrl}/positions/`); + expect(response?.status()).toBe(200); + + // 11 positions are imported, Light shows 10 of them. + await expect(visitor.locator('article[id^="post-"] .entry-title')).toHaveCount(10); + await expect(visitor.getByRole('link', { name: 'Go to Market Manager', exact: true })).toBeVisible(); + }); + + test('the archive links to the single positions', async ({ visitor, cli }) => { + await visitor.goto(`${cli.serverUrl}/positions/`); + await visitor.getByRole('link', { name: 'Programme Advisor', exact: true }).click(); + + await expect(visitor).toHaveURL(/\/position\/programme-advisor\/$/); + await expect(visitor.getByRole('heading', { name: 'Programme Advisor' }).first()).toBeVisible(); + }); + + test('a single position shows its description and the application button', async ({ visitor, cli }) => { + const position = (await getPositions(cli)).find((entry) => entry.title === 'Copy of Junior Compliance and Immigrations Manager'); + expect(position).toBeDefined(); + + const response = await visitor.goto(position!.url); + expect(response?.status()).toBe(200); + + await expect(visitor.getByRole('heading', { name: position!.title }).first()).toBeVisible(); + await expect(visitor.locator('body')).toContainText('Your mission'); + await expect(visitor.locator('body')).toContainText('The International Affairs Office Manager'); + + // the button leads to the application form at Personio. + const apply = visitor.getByRole('link', { name: 'Apply for this position' }).first(); + await expect(apply).toBeVisible(); + await expect(apply).toHaveAttribute('href', new RegExp(`^${personioUrl.replace(/\./g, '\\.')}/job/2140231`)); + }); + + test('a position which does not exist answers with 404', async ({ visitor, cli }) => { + const response = await visitor.goto(`${cli.serverUrl}/position/this-position-does-not-exist/`); + expect(response?.status()).toBe(404); + }); + + test('the shortcode [personioPositions] lists positions on a page', async ({ visitor, cli }) => { + const url = await createPage(cli, 'Jobs', '[personioPositions limit="3"]'); + + await visitor.goto(url); + await expect(visitor.locator('article[id^="post-"] .entry-title')).toHaveCount(3); + }); + + test('the shortcode [personioPosition] shows one position', async ({ visitor, cli }) => { + const url = await createPage(cli, 'One job', '[personioPosition personioid="2140229"]'); + + await visitor.goto(url); + await expect(visitor.locator('body')).toContainText('Programme Advisor'); + await expect(visitor.locator('body')).not.toContainText('Go to Market Manager'); + }); + + test('the list can be filtered', async ({ visitor, cli }) => { + const url = await createPage(cli, 'Jobs with filter', '[personioPositions showfilter="1" filter="office" filtertype="select"]'); + + await visitor.goto(url); + const filter = visitor.locator('form.personio-position-filter'); + await expect(filter).toBeVisible(); + + const office = filter.locator('select').first(); + await expect(office.locator('option', { hasText: 'Amsterdam' })).toHaveCount(1); + await office.selectOption({ label: 'Amsterdam' }); + + // the filter is a plain form: "Search" loads the page again with the filter in the URL. + await Promise.all([ + visitor.waitForURL(/personiofilter/), + filter.getByRole('button', { name: 'Search' }).click(), + ]); + + // only "Social Media (Working Student)" is in Amsterdam. + await expect(visitor.locator('article[id^="post-"] .entry-title')).toHaveCount(1); + await expect(visitor.locator('article[id^="post-"] .entry-title')).toContainText('Social Media (Working Student)'); + }); + + test('visitors can not open the backend of the plugin', async ({ visitor, cli }) => { + await visitor.goto(`${cli.serverUrl}/wp-admin/edit.php?post_type=personioposition`); + await expect(visitor).toHaveURL(/wp-login\.php/); + }); +}); diff --git a/tests/Playwright/helpers.ts b/tests/Playwright/helpers.ts new file mode 100644 index 00000000..e7c35187 --- /dev/null +++ b/tests/Playwright/helpers.ts @@ -0,0 +1,213 @@ +/** + * Helpers for the Playwright tests: prepare the website through PHP instead of clicking. + * + * All tests of one worker share one website (see fixtures.ts). Each test therefore says + * what it needs, and does not rely on what a test before it left behind - Playwright + * starts a fresh website whenever a test fails. + */ + +// what we use of the Playground the fixture starts. +export type Cli = { + serverUrl: string; + playground: { run: (options: { code: string }) => Promise<{ text: string; errors?: string }> }; +}; + +/** + * The Personio URL used in the tests. It never leaves the website: the must-use plugin + * of mockPersonio() answers every request to it with the XML of tests/Data. + */ +export const personioUrl = 'https://personio-integration-test.jobs.personio.com'; + +/** + * Where the plugin, and with it tests/Data, is mounted in Playground. + */ +const pluginDir = '/wordpress/wp-content/plugins/personio-integration-light'; + +/** + * Run PHP in the website, as administrator. Returns what the code prints. + * + * Values are handed over as "$args", so no test has to build PHP strings from its data. + */ +export async function php(cli: Cli, code: string, args: Record = {}): Promise { + // @ts-ignore + const encoded = Buffer.from(JSON.stringify(args)).toString('base64'); + const result = await cli.playground.run({ + code: ` 'administrator', 'number' => 1 ) )[0]->ID ); + $args = json_decode( base64_decode( '${encoded}' ), true ); + ${code}`, + }); + + return result.text; +} + +/** + * Answer all requests to the test Personio URL inside the website with the XML files of + * tests/Data, so neither the tests nor the import need the real Personio. + * + * - the default URL delivers tests/Data/positions.xml + * - "?language=…" is kept, every language gets the same positions + */ +export async function mockPersonio(cli: Cli): Promise { + await php( + cli, + `wp_mkdir_p( WPMU_PLUGIN_DIR ); + file_put_contents( WPMU_PLUGIN_DIR . '/personio-mock.php', $args['code'] );`, + { + code: ` array( 'last-modified' => gmdate( 'D, d M Y H:i:s' ) . ' GMT' ), + 'body' => $body, + 'response' => array( 'code' => 200, 'message' => 'OK' ), + 'cookies' => array(), + 'filename' => null, + ); + }, + 10, + 3 +); +`, + }, + ); +} + +/** + * Set the Personio URL. + */ +export async function setPersonioUrl(cli: Cli, url: string = personioUrl): Promise { + await php(cli, `update_option( 'personioIntegrationUrl', $args['url'] ); flush_rewrite_rules();`, { url }); +} + +/** + * Make the website look like the setup has been run: Personio URL set, setup marked as + * completed. Only then the plugin shows its settings, the list of positions and the import. + */ +export async function completeSetup(cli: Cli): Promise { + await mockPersonio(cli); + await setPersonioUrl(cli); + await php( + cli, + `$completed = get_option( 'esfw_completed', array() ); + if ( ! is_array( $completed ) ) { + $completed = array(); + } + if ( ! in_array( 'personio-integration-light', $completed, true ) ) { + $completed[] = 'personio-integration-light'; + } + update_option( 'esfw_completed', $completed ); + // the intro tour would lie above every page of the plugin. It has its own test. + update_option( 'personio_integration_intro', 1 );`, + ); +} + +/** + * Put the website back to before the setup: no Personio URL, no positions, setup not run. + */ +export async function resetSetup(cli: Cli): Promise { + await deletePositions(cli); + await php( + cli, + `delete_option( 'personioIntegrationUrl' ); + delete_option( 'esfw_completed' ); + delete_option( 'esfw_step' ); + delete_option( 'esfw_max_steps' ); + delete_option( 'personio_integration_intro' );`, + ); +} + +/** + * Import the positions of tests/Data/positions.xml. Returns how many positions exist afterwards. + */ +export async function importPositions(cli: Cli): Promise { + await completeSetup(cli); + const count = await php( + cli, + `update_option( WP_PERSONIO_INTEGRATION_IMPORT_RUNNING, 0 ); + ( new \\PersonioIntegrationLight\\PersonioIntegration\\Imports\\Xml() )->run(); + echo count( \\PersonioIntegrationLight\\PersonioIntegration\\Positions::get_instance()->get_positions() );`, + ); + + return parseInt(count.trim(), 10); +} + +/** + * Delete all positions. + */ +export async function deletePositions(cli: Cli): Promise { + await php( + cli, + `update_option( WP_PERSONIO_INTEGRATION_DELETE_RUNNING, 0 ); + \\PersonioIntegrationLight\\PersonioIntegration\\PostTypes\\PersonioPosition::get_instance()->delete_positions();`, + ); +} + +/** + * Return title and URL of the imported positions, as the website lists them. + */ +export async function getPositions(cli: Cli): Promise> { + const json = await php( + cli, + `$list = array(); + foreach ( \\PersonioIntegrationLight\\PersonioIntegration\\Positions::get_instance()->get_positions() as $position ) { + $list[] = array( + 'id' => $position->get_id(), + 'title' => $position->get_title(), + 'url' => $position->get_link(), + ); + } + echo wp_json_encode( $list );`, + ); + + return JSON.parse(json); +} + +/** + * Create a published page with the given content. Returns its URL. + */ +export async function createPage(cli: Cli, title: string, content: string): Promise { + return php( + cli, + `$id = wp_insert_post( array( 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => $args['title'], 'post_content' => $args['content'] ) ); + echo get_permalink( $id );`, + { title, content }, + ); +} + +/** + * Where WordPress writes PHP errors to (see "defineWpConfigConsts" in fixtures.ts). + */ +const debugLog = '/wordpress/wp-content/debug.log'; + +/** + * Empty the PHP error log. + */ +export async function clearPhpErrors(cli: Cli): Promise { + await php(cli, `@file_put_contents( $args['file'], '' );`, { file: debugLog }); +} + +/** + * Return the PHP errors, warnings, notices and deprecations caused by this plugin since + * the last clearPhpErrors(). Messages of WordPress itself or of other plugins are ignored. + */ +export async function getPhpErrors(cli: Cli): Promise { + const log = await php(cli, `echo (string) @file_get_contents( $args['file'] );`, { file: debugLog }); + + return log + .split(/\n(?=\[)/) + .filter((entry) => /PHP (Fatal error|Parse error|Warning|Notice|Deprecated|Recoverable fatal error)/.test(entry)) + .filter((entry) => entry.includes('/plugins/personio-integration-light/')); +} diff --git a/tests/Playwright/plugin.spec.ts b/tests/Playwright/plugin.spec.ts new file mode 100644 index 00000000..13a2e941 --- /dev/null +++ b/tests/Playwright/plugin.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from './fixtures'; +import { completeSetup, php } from './helpers'; + +test.describe('Plugin', () => { + test.beforeEach(async ({ cli }) => { + await completeSetup(cli); + }); + + test('Plugin active & positions loading', async ({ page, cli }) => { + await page.goto(`${cli.serverUrl}/wp-admin/edit.php?post_type=personioposition`); + await expect(page.locator('#wpbody-content')).toBeVisible(); + await expect(page).toHaveTitle(/Positions/); + }); + + test('Plugin settings can be saved', async ({ page, cli }) => { + await page.goto(`${cli.serverUrl}/wp-admin/edit.php?post_type=personioposition&page=personioPositions`); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + + // the confirmation is on the page twice: as the visible notice and as a hidden + // announcement for screen readers. Only the notice is what a user sees. + await expect(page.getByTestId('snackbar')).toContainText('Settings saved'); + }); + + test('the plugin can be deactivated and activated again', async ({ page, cli }) => { + await page.goto(`${cli.serverUrl}/wp-admin/plugins.php`); + const row = page.locator('tr[data-slug="personio-integration-light"]'); + + await row.getByRole('link', { name: /^Deactivate/ }).click(); + await expect(page.locator('#message')).toContainText('Plugin deactivated.'); + await expect(row.getByRole('link', { name: /^Activate/ })).toBeVisible(); + + await row.getByRole('link', { name: /^Activate/ }).click(); + await expect(page.locator('#message')).toContainText('Plugin activated.'); + await expect(row.getByRole('link', { name: /^Deactivate/ })).toBeVisible(); + + // the positions and the settings survive a deactivation. + expect(await php(cli, `echo get_option( 'personioIntegrationUrl' );`)).not.toBe(''); + }); + + test('the post type and the taxonomies are registered', async ({ cli }) => { + const result = await php( + cli, + `echo wp_json_encode( array( + 'post_type' => post_type_exists( 'personioposition' ), + 'office' => taxonomy_exists( 'personioOffice' ), + 'version' => defined( 'WP_PERSONIO_INTEGRATION_VERSION' ), + ) );`, + ); + expect(JSON.parse(result)).toEqual({ post_type: true, office: true, version: true }); + }); +}); diff --git a/tests/Playwright/positions.spec.ts b/tests/Playwright/positions.spec.ts new file mode 100644 index 00000000..67926d9b --- /dev/null +++ b/tests/Playwright/positions.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from './fixtures'; +import { completeSetup, deletePositions, getPositions, importPositions } from './helpers'; + +const listPath = '/wp-admin/edit.php?post_type=personioposition'; + +/** + * The list of positions in the backend. + */ +test.describe('Positions in the backend', () => { + test('the list shows all imported positions', async ({ page, cli }) => { + expect(await importPositions(cli)).toBe(11); + + await page.goto(`${cli.serverUrl}${listPath}`); + await expect(page.locator('.displaying-num').first()).toHaveText('11 items'); + await expect(page.getByRole('link', { name: 'Go to Market Manager', exact: true })).toBeVisible(); + await expect(page.locator('#the-list')).toContainText('2140235'); + }); + + test('positions can be searched', async ({ page, cli }) => { + await importPositions(cli); + + await page.goto(`${cli.serverUrl}${listPath}`); + await page.locator('#post-search-input').fill('Marketing'); + await page.getByRole('button', { name: 'Search Position' }).click(); + + await expect(page.locator('.displaying-num').first()).toHaveText('2 items'); + await expect(page.locator('#the-list')).toContainText('SEA Marketing Managers'); + await expect(page.locator('#the-list')).toContainText('SEO Marketing Managers'); + }); + + test('without positions the list offers the import', async ({ page, cli }) => { + await completeSetup(cli); + await deletePositions(cli); + + await page.goto(`${cli.serverUrl}${listPath}`); + await expect(page.locator('#the-list')).toContainText('Start import'); + await expect(page.getByRole('link', { name: 'now', exact: true })).toHaveAttribute('href', /action=personioPositionsImport/); + }); + + test('the import can be started in the backend', async ({ page, cli }) => { + test.slow(); + await completeSetup(cli); + await deletePositions(cli); + + await page.goto(`${cli.serverUrl}${listPath}`); + await page.getByRole('link', { name: 'Run import' }).first().click(); + + // a dialog asks first, then shows the progress and the end of the import. + const dialog = page.getByRole('dialog'); + await expect(dialog).toContainText('Do you really want to import open positions from'); + await dialog.getByRole('button', { name: 'Yes' }).click(); + await expect(dialog).toContainText('Positions has been imported', { timeout: 60_000 }); + expect(await getPositions(cli)).toHaveLength(11); + + // "OK" closes the dialog. The list then shows the positions. + await dialog.getByRole('button', { name: 'OK' }).click(); + await expect(page.locator('.displaying-num').first()).toHaveText('11 items'); + }); + + test('a single position can be viewed in the backend', async ({ page, cli }) => { + await importPositions(cli); + const position = (await getPositions(cli)).find((entry) => entry.title === 'Programme Advisor'); + + await page.goto(`${cli.serverUrl}/wp-admin/post.php?post=${position?.id}&action=edit`); + await expect(page.locator('body')).toContainText('Programme Advisor'); + await expect(page.locator('body')).not.toContainText('Sorry, you are not allowed'); + }); +}); diff --git a/tests/Playwright/settings.spec.ts b/tests/Playwright/settings.spec.ts new file mode 100644 index 00000000..6ce77387 --- /dev/null +++ b/tests/Playwright/settings.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from './fixtures'; +import { completeSetup, personioUrl, php } from './helpers'; + +const settingsPath = '/wp-admin/edit.php?post_type=personioposition&page=personioPositions'; + +test.describe('Settings', () => { + test.beforeEach(async ({ cli }) => { + await completeSetup(cli); + await php(cli, `update_option( 'personioIntegrationMainLanguage', 'en' );`); + }); + + test('Loading settings page without error', async ({ page, cli }) => { + await page.goto(`${cli.serverUrl}${settingsPath}`); + await expect(page.getByRole('heading', { level: 1 })).toContainText('Personio Integration Light Settings'); + await expect(page.getByRole('textbox', { name: 'Personio URL' })).toHaveValue(personioUrl); + }); + + for (const tab of ['Templates', 'Import', 'Additional settings']) { + test(`the tab "${tab}" can be opened`, async ({ page, cli }) => { + await page.goto(`${cli.serverUrl}${settingsPath}`); + await page.getByRole('tab', { name: tab, exact: true }).click(); + await expect(page.getByRole('tab', { name: tab, exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('tabpanel', { name: tab })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeVisible(); + }); + } + + test('a changed setting is saved', async ({ page, cli }) => { + await page.goto(`${cli.serverUrl}${settingsPath}`); + await page.getByRole('radio', { name: 'German' }).check(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByTestId('snackbar')).toContainText('Settings saved'); + + expect(await php(cli, `echo get_option( 'personioIntegrationMainLanguage' );`)).toBe('de'); + + // and it is still selected after loading the page again. + await page.reload(); + await expect(page.getByRole('radio', { name: 'German' })).toBeChecked(); + }); + + test('a URL which is not from Personio is not saved', async ({ page, cli }) => { + await page.goto(`${cli.serverUrl}${settingsPath}`); + const url = page.getByRole('textbox', { name: 'Personio URL' }); + await url.fill('https://example.com'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByTestId('snackbar')).toBeVisible(); + + // the valid URL from before is kept. + expect(await php(cli, `echo get_option( 'personioIntegrationUrl' );`)).toBe(personioUrl); + await page.reload(); + await expect(url).toHaveValue(personioUrl); + }); +}); diff --git a/tests/Playwright/setup.spec.ts b/tests/Playwright/setup.spec.ts new file mode 100644 index 00000000..b5dc2a67 --- /dev/null +++ b/tests/Playwright/setup.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from './fixtures'; +import { mockPersonio, personioUrl, php, resetSetup } from './helpers'; + +/** + * The setup a new installation starts with: enter the Personio URL, import, done. + */ +test.describe('Setup', () => { + test.beforeEach(async ({ cli }) => { + await resetSetup(cli); + await mockPersonio(cli); + }); + + test('a new installation shows the setup instead of the positions', async ({ page, cli }) => { + await page.goto(`${cli.serverUrl}/wp-admin/admin.php?page=personioPositions`); + await expect(page.getByRole('heading', { level: 1 })).toContainText('Personio Integration Light Setup'); + await expect(page.getByRole('textbox', { name: 'Personio URL' })).toBeVisible({ timeout: 30_000 }); + + // the list of positions is only available after the setup. + await page.goto(`${cli.serverUrl}/wp-admin/edit.php?post_type=personioposition`); + await expect(page.locator('body')).toContainText('Sorry, you are not allowed'); + }); + + test('the dashboard asks to run the setup', async ({ page, cli }) => { + await page.goto(`${cli.serverUrl}/wp-admin/`); + const start = page.getByRole('link', { name: 'Start setup' }); + await expect(start).toBeVisible(); + await expect(start).toHaveAttribute('href', /page=personioPositions/); + }); + + test('a URL which is not from Personio is rejected', async ({ page, cli }) => { + await page.goto(`${cli.serverUrl}/wp-admin/admin.php?page=personioPositions`); + const url = page.getByRole('textbox', { name: 'Personio URL' }); + await expect(url).toBeVisible({ timeout: 30_000 }); + + await url.fill('https://example.com'); + await expect(page.getByText('is not a Personio-URL')).toBeVisible(); + + // still the first step, nothing has been saved. + await expect(url).toBeVisible(); + expect(await php(cli, `echo get_option( 'personioIntegrationUrl' );`)).toBe(''); + }); + + test('the setup imports the positions and starts the intro', async ({ page, cli }) => { + test.slow(); + + await page.goto(`${cli.serverUrl}/wp-admin/admin.php?page=personioPositions`); + const url = page.getByRole('textbox', { name: 'Personio URL' }); + await expect(url).toBeVisible({ timeout: 30_000 }); + await url.fill(personioUrl); + + // wait for the check of the URL, which runs while typing. + const validation = page.waitForResponse((response) => response.url().includes('/validate-field')); + await url.blur(); + await validation; + + await page.getByRole('button', { name: 'Continue' }).click(); + + // the second step imports the positions and then offers to finish the setup. + const completed = page.getByRole('button', { name: 'Completed' }); + await expect(completed).toBeEnabled({ timeout: 60_000 }); + await completed.click(); + + // the setup forwards to the list of positions, where the intro starts. + await page.waitForURL(/edit\.php\?post_type=personioposition/, { timeout: 30_000 }); + await expect(page.getByRole('dialog')).toContainText('Intro'); + await expect(page.locator('.displaying-num').first()).toHaveText('11 items'); + + expect(await php(cli, `echo get_option( 'personioIntegrationUrl' );`)).toBe(personioUrl); + }); +});