From 14df56c72e3898d3a630a91aa0aa74ce3b191def Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 26 Dec 2025 17:10:43 +0100 Subject: [PATCH 1/7] e2e tests refactor --- .github/workflows/playwright.yml | 14 +- package-lock.json | 1422 +++++++++++++++++--- package.json | 21 +- playwright.config.js | 33 - playwright.config.ts | 93 ++ tests/e2e/.env.example | 9 + tests/e2e/.gitignore | 10 + tests/e2e/api/tasks.api.ts | 129 ++ tests/e2e/auth.setup.js | 85 -- tests/e2e/constants/selectors.js | 13 - tests/e2e/fixtures/base.fixture.ts | 96 ++ tests/e2e/fixtures/playground.fixture.ts | 120 ++ tests/e2e/global-setup.ts | 68 + tests/e2e/global-teardown.ts | 14 + tests/e2e/helpers/cleanup.js | 100 -- tests/e2e/pages/base.page.ts | 96 ++ tests/e2e/pages/dashboard.page.ts | 403 ++++++ tests/e2e/pages/index.ts | 3 + tests/e2e/pages/yoast-settings.page.ts | 156 +++ tests/e2e/sequential.spec.js | 14 - tests/e2e/sequential/onboarding.spec.js | 59 - tests/e2e/sequential/task-tagline.spec.js | 113 -- tests/e2e/sequential/todo-complete.spec.js | 137 -- tests/e2e/sequential/todo-reorder.spec.js | 111 -- tests/e2e/sequential/todo.spec.js | 83 -- tests/e2e/specs/onboarding.spec.ts | 59 + tests/e2e/specs/task-dismissible.spec.ts | 39 + tests/e2e/specs/task-snooze.spec.ts | 56 + tests/e2e/specs/task-tagline.spec.ts | 51 + tests/e2e/specs/todo-complete.spec.ts | 67 + tests/e2e/specs/todo-crud.spec.ts | 66 + tests/e2e/specs/todo-reorder.spec.ts | 91 ++ tests/e2e/specs/tour.spec.ts | 49 + tests/e2e/specs/yoast-integration.spec.ts | 55 + tests/e2e/task-dismissible.spec.js | 62 - tests/e2e/task-snooze.spec.js | 75 -- tests/e2e/tour.spec.js | 46 - tests/e2e/tsconfig.json | 21 + tests/e2e/utils.js | 51 - tests/e2e/yoast-focus-element.spec.js | 90 -- 40 files changed, 3032 insertions(+), 1248 deletions(-) delete mode 100644 playwright.config.js create mode 100644 playwright.config.ts create mode 100644 tests/e2e/.env.example create mode 100644 tests/e2e/.gitignore create mode 100644 tests/e2e/api/tasks.api.ts delete mode 100644 tests/e2e/auth.setup.js delete mode 100644 tests/e2e/constants/selectors.js create mode 100644 tests/e2e/fixtures/base.fixture.ts create mode 100644 tests/e2e/fixtures/playground.fixture.ts create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/global-teardown.ts delete mode 100644 tests/e2e/helpers/cleanup.js create mode 100644 tests/e2e/pages/base.page.ts create mode 100644 tests/e2e/pages/dashboard.page.ts create mode 100644 tests/e2e/pages/index.ts create mode 100644 tests/e2e/pages/yoast-settings.page.ts delete mode 100644 tests/e2e/sequential.spec.js delete mode 100644 tests/e2e/sequential/onboarding.spec.js delete mode 100644 tests/e2e/sequential/task-tagline.spec.js delete mode 100644 tests/e2e/sequential/todo-complete.spec.js delete mode 100644 tests/e2e/sequential/todo-reorder.spec.js delete mode 100644 tests/e2e/sequential/todo.spec.js create mode 100644 tests/e2e/specs/onboarding.spec.ts create mode 100644 tests/e2e/specs/task-dismissible.spec.ts create mode 100644 tests/e2e/specs/task-snooze.spec.ts create mode 100644 tests/e2e/specs/task-tagline.spec.ts create mode 100644 tests/e2e/specs/todo-complete.spec.ts create mode 100644 tests/e2e/specs/todo-crud.spec.ts create mode 100644 tests/e2e/specs/todo-reorder.spec.ts create mode 100644 tests/e2e/specs/tour.spec.ts create mode 100644 tests/e2e/specs/yoast-integration.spec.ts delete mode 100644 tests/e2e/task-dismissible.spec.js delete mode 100644 tests/e2e/task-snooze.spec.js delete mode 100644 tests/e2e/tour.spec.js create mode 100644 tests/e2e/tsconfig.json delete mode 100644 tests/e2e/utils.js delete mode 100644 tests/e2e/yoast-focus-element.spec.js diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 49cdf31381..50192b1541 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -63,12 +63,12 @@ jobs: - name: Checkout code uses: actions/checkout@v3 - - name: Install Node.js & Playwright + - name: Install Node.js & dependencies uses: actions/setup-node@v3 with: - node-version: 18 - - run: npm install -D @playwright/test - - run: npx playwright install --with-deps + node-version: 20 + - run: npm ci + - run: npx playwright install --with-deps chromium - name: Complete WordPress installation run: | @@ -109,7 +109,7 @@ jobs: docker exec $WP_CONTAINER wp plugin install wordpress-seo --activate --allow-root - name: Run Playwright Tests - run: npx playwright test tests/e2e/ + run: npm run test:e2e # Begin Yoast SEO Premium tests - name: Install PHP & Composer on host @@ -148,8 +148,8 @@ jobs: # Save the updated option docker exec $WP_CONTAINER wp option update wpseo_premium "$UPDATED_OPTION" --format=json --allow-root - - name: Run Yoast Focus Element Test Again - run: npx playwright test tests/e2e/yoast-focus-element.spec.js + - name: Run Yoast Integration Tests with Premium + run: npx playwright test --project=parallel --grep="Yoast" # End Yoast SEO Premium tests - name: Upload Playwright Report diff --git a/package-lock.json b/package-lock.json index da733a5a3c..2b8c51aaeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,15 @@ "driver.js": "^1.3.1" }, "devDependencies": { - "@playwright/test": "*", + "@playwright/test": "^1.49.0", + "@types/node": "^22.0.0", "@wordpress/scripts": "*", "@wordpress/stylelint-config": "*", - "dotenv": "*", + "@wp-playground/cli": "^0.9.0", + "dotenv": "^16.4.0", "eslint-plugin-eslint-comments": "*", - "husky": "*" + "husky": "*", + "typescript": "^5.6.0" }, "engines": { "node": ">=20.10.0", @@ -3515,6 +3518,381 @@ "third-party-web": "latest" } }, + "node_modules/@php-wasm/logger": { + "version": "0.9.46", + "resolved": "https://registry.npmjs.org/@php-wasm/logger/-/logger-0.9.46.tgz", + "integrity": "sha512-fmDGj7DMA4LVc7eCSxfeUVwwfwo17J9GZchQ76FJK/+I/XSqqiJhE/85TEEgPPpxqeGHpqkXV6S9bGiFmMPHkw==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/node-polyfills": "0.9.46" + }, + "engines": { + "node": ">=18.18.0", + "npm": ">=8.11.0" + } + }, + "node_modules/@php-wasm/node": { + "version": "0.9.46", + "resolved": "https://registry.npmjs.org/@php-wasm/node/-/node-0.9.46.tgz", + "integrity": "sha512-yvI4z148CadV0OVPXGPg4NK/OXj2lnzXWtV13G6I4v5dmzaKNJreIeEQ67KV1dotYD9h0ThaCmGZ62kDUfN1pw==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/logger": "0.9.46", + "@php-wasm/node-polyfills": "0.9.46", + "@php-wasm/universal": "0.9.46", + "@php-wasm/util": "0.9.46", + "@wp-playground/common": "0.9.46", + "@wp-playground/wordpress": "0.9.46", + "comlink": "^4.4.1", + "express": "4.19.2", + "ini": "4.1.2", + "ws": "8.18.0", + "yargs": "17.7.2" + }, + "engines": { + "node": ">=18.18.0", + "npm": ">=8.11.0" + } + }, + "node_modules/@php-wasm/node-polyfills": { + "version": "0.9.46", + "resolved": "https://registry.npmjs.org/@php-wasm/node-polyfills/-/node-polyfills-0.9.46.tgz", + "integrity": "sha512-pX0cpMM49dc+e0bPb7X+D7ZrQBd+l5GMesQ5fj/JG/XIUOR6O/CSLtJYK3Se5hUOq5D7UIDhsenEkN1xR5eoWQ==", + "dev": true, + "license": "GPL-2.0-or-later" + }, + "node_modules/@php-wasm/node/node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/@php-wasm/node/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@php-wasm/node/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@php-wasm/node/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@php-wasm/node/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@php-wasm/node/node_modules/express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/@php-wasm/node/node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@php-wasm/node/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@php-wasm/node/node_modules/ini": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", + "integrity": "sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@php-wasm/node/node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@php-wasm/node/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@php-wasm/node/node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@php-wasm/node/node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@php-wasm/node/node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@php-wasm/node/node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@php-wasm/node/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@php-wasm/progress": { + "version": "0.9.46", + "resolved": "https://registry.npmjs.org/@php-wasm/progress/-/progress-0.9.46.tgz", + "integrity": "sha512-F5zp8orjZZH7KVCcWyCeZk/i7bh0J4j+TfWyK3XQfBkmIt4Z8K4LWz0HwS5g/7ua6F4A96wNyW+W6DqSxqnvSg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/logger": "0.9.46", + "@php-wasm/node-polyfills": "0.9.46" + }, + "engines": { + "node": ">=18.18.0", + "npm": ">=8.11.0" + } + }, + "node_modules/@php-wasm/scopes": { + "version": "0.9.46", + "resolved": "https://registry.npmjs.org/@php-wasm/scopes/-/scopes-0.9.46.tgz", + "integrity": "sha512-Ab5OMSqLXGrTKGiMjmROcZuf8qzFhzRLvoVK+dhXI4cCemrKGxftFU5jb8E3Qavta7ii1hDle+1UMoVc22yrvA==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=16.15.1", + "npm": ">=8.11.0" + } + }, + "node_modules/@php-wasm/stream-compression": { + "version": "0.9.46", + "resolved": "https://registry.npmjs.org/@php-wasm/stream-compression/-/stream-compression-0.9.46.tgz", + "integrity": "sha512-CiBAyRE3vKDB+Nx4f+ZSXjLhreLOs43CRUHv+WCSKFCIFeDfWcEJFhIIrAJr7peHvXN7GBVmAlOxm+TTbmqtRg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/node-polyfills": "0.9.46", + "@php-wasm/util": "0.9.46" + } + }, + "node_modules/@php-wasm/universal": { + "version": "0.9.46", + "resolved": "https://registry.npmjs.org/@php-wasm/universal/-/universal-0.9.46.tgz", + "integrity": "sha512-Cf/bTExIPvtcCbvHsDB7NdxXsflhiWJj86dXpoCN2SQl2D3YiXCBIBegK646Vv4rCoGh+GhhuwLjzG9v4RbRyA==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/logger": "0.9.46", + "@php-wasm/node-polyfills": "0.9.46", + "@php-wasm/progress": "0.9.46", + "@php-wasm/stream-compression": "0.9.46", + "@php-wasm/util": "0.9.46", + "comlink": "^4.4.1", + "ini": "4.1.2" + }, + "engines": { + "node": ">=18.18.0", + "npm": ">=8.11.0" + } + }, + "node_modules/@php-wasm/universal/node_modules/ini": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", + "integrity": "sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@php-wasm/util": { + "version": "0.9.46", + "resolved": "https://registry.npmjs.org/@php-wasm/util/-/util-0.9.46.tgz", + "integrity": "sha512-Chgtkon+su2IIhsJJrv8nMgk5uYST0Efh19xQ/Hp84jQjMLA0crhiGbpIEHgjeK8MHbd8SytWRrzsnA0DTu4bw==", + "dev": true, + "engines": { + "node": ">=18.18.0", + "npm": ">=8.11.0" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -4347,12 +4725,13 @@ } }, "node_modules/@types/node": { - "version": "24.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", - "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~7.10.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/node-forge": { @@ -5341,191 +5720,854 @@ "integrity": "sha512-PaeJcNKoxGE0W5M1QYAIVlIrVV4rqrVOwxSsGQVHMCOMoLZcEECIiPELAUH+fW2AJWXb0v1McavjSFcgZ2jdkg==", "dev": true, "dependencies": { - "@babel/runtime": "7.25.7", - "jest-matcher-utils": "^29.6.2" + "@babel/runtime": "7.25.7", + "jest-matcher-utils": "^29.6.2" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "jest": ">=29" + } + }, + "node_modules/@wordpress/jest-preset-default": { + "version": "12.28.0", + "resolved": "https://registry.npmjs.org/@wordpress/jest-preset-default/-/jest-preset-default-12.28.0.tgz", + "integrity": "sha512-JjZ5vhVuEDwpeBrogbVZBHVYqXO54WS7UC7hwPZEqgLqf5dTzAxT2wo3nnGJmYwE/8WlABGQkE/4FgzuyFP/1Q==", + "dev": true, + "dependencies": { + "@wordpress/jest-console": "^8.28.0", + "babel-jest": "29.7.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "@babel/core": ">=7", + "jest": ">=29" + } + }, + "node_modules/@wordpress/npm-package-json-lint-config": { + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-5.28.0.tgz", + "integrity": "sha512-H9T004zwuO3MSJPO1RbgR4ceZuLam5JIfVwD3UEqJ1VQRpIPLzdJ9MybKI0URqNL9/+A4UJGX0RwpilMGoTNKg==", + "dev": true, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "npm-package-json-lint": ">=6.0.0" + } + }, + "node_modules/@wordpress/postcss-plugins-preset": { + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-5.28.0.tgz", + "integrity": "sha512-9934WftbPRTsM10PiSVsQWKwjGXm1Mvj5wjEnAhvuvBfjw0Yz01S7mNfy2I+Y2/oR1zgPRHAp97dVwIn/YRluA==", + "dev": true, + "dependencies": { + "@wordpress/base-styles": "^6.4.0", + "autoprefixer": "^10.4.20" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/@wordpress/prettier-config": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.28.0.tgz", + "integrity": "sha512-Lp6pvFZ+XgdEgO/mhL88asL74GzbZ6xdik6Nb9LTsW8psXsIX3O2t4BbGJP81EjvBujJt94kljTHEfZrgAuB8g==", + "dev": true, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "prettier": ">=3" + } + }, + "node_modules/@wordpress/scripts": { + "version": "30.21.0", + "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-30.21.0.tgz", + "integrity": "sha512-yztjf6DjQFpndvdIG8zV6jVG8cU6EoXVx5+e/RikWZNHWh5nwnauhVa4xE0ZkGFONKKq0+8T5DqNYW3UvoXPMg==", + "dev": true, + "dependencies": { + "@babel/core": "7.25.7", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", + "@svgr/webpack": "^8.0.1", + "@wordpress/babel-preset-default": "^8.28.0", + "@wordpress/browserslist-config": "^6.28.0", + "@wordpress/dependency-extraction-webpack-plugin": "^6.28.0", + "@wordpress/e2e-test-utils-playwright": "^1.28.0", + "@wordpress/eslint-plugin": "^22.14.0", + "@wordpress/jest-preset-default": "^12.28.0", + "@wordpress/npm-package-json-lint-config": "^5.28.0", + "@wordpress/postcss-plugins-preset": "^5.28.0", + "@wordpress/prettier-config": "^4.28.0", + "@wordpress/stylelint-config": "^23.20.0", + "adm-zip": "^0.5.9", + "babel-jest": "29.7.0", + "babel-loader": "9.2.1", + "browserslist": "^4.21.10", + "chalk": "^4.0.0", + "check-node-version": "^4.1.0", + "clean-webpack-plugin": "^3.0.0", + "copy-webpack-plugin": "^10.2.0", + "cross-spawn": "^7.0.6", + "css-loader": "^6.2.0", + "cssnano": "^6.0.1", + "cwd": "^0.10.0", + "dir-glob": "^3.0.1", + "eslint": "^8.3.0", + "expect-puppeteer": "^4.4.0", + "fast-glob": "^3.2.7", + "filenamify": "^4.2.0", + "jest": "^29.6.2", + "jest-dev-server": "^10.1.4", + "jest-environment-jsdom": "^29.6.2", + "jest-environment-node": "^29.6.2", + "json2php": "^0.0.9", + "markdownlint-cli": "^0.31.1", + "merge-deep": "^3.0.3", + "mini-css-extract-plugin": "^2.9.2", + "minimist": "^1.2.0", + "npm-package-json-lint": "^6.4.0", + "npm-packlist": "^3.0.0", + "postcss": "^8.4.5", + "postcss-loader": "^6.2.1", + "prettier": "npm:wp-prettier@3.0.3", + "puppeteer-core": "^23.10.1", + "react-refresh": "^0.14.0", + "read-pkg-up": "^7.0.1", + "resolve-bin": "^0.4.0", + "rtlcss": "^4.3.0", + "sass": "^1.54.0", + "sass-loader": "^16.0.3", + "schema-utils": "^4.2.0", + "source-map-loader": "^3.0.0", + "stylelint": "^16.8.2", + "terser-webpack-plugin": "^5.3.10", + "url-loader": "^4.1.1", + "webpack": "^5.97.0", + "webpack-bundle-analyzer": "^4.9.1", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^4.15.1" + }, + "bin": { + "wp-scripts": "bin/wp-scripts.js" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "@playwright/test": "^1.51.1", + "@wordpress/env": "^10.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@wordpress/env": { + "optional": true + } + } + }, + "node_modules/@wordpress/stylelint-config": { + "version": "23.20.0", + "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-23.20.0.tgz", + "integrity": "sha512-WRnd35HIdrrtvGU7gxxvXbmmGI/KoLVeHDOTFjYFQHkIn7Hkv9EkudnSfW9P4cK2K5lDhdMM+sre8g7BfMrDlg==", + "dev": true, + "dependencies": { + "@stylistic/stylelint-plugin": "^3.0.1", + "stylelint-config-recommended": "^14.0.1", + "stylelint-config-recommended-scss": "^14.1.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "stylelint": "^16.8.2", + "stylelint-scss": "^6.4.0" + } + }, + "node_modules/@wordpress/warning": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.28.0.tgz", + "integrity": "sha512-Hn2wrdgBDRcmBjpEXd5q+bz4qvLMSYoZa0b3uo1Ja9ONNh8eHGnILIAxBk/OmFrCjmXqY6bydTVBRcvXaBq0MQ==", + "dev": true, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wp-playground/blueprints": { + "version": "0.9.46", + "resolved": "https://registry.npmjs.org/@wp-playground/blueprints/-/blueprints-0.9.46.tgz", + "integrity": "sha512-Fe6+xqHe0NpH0RATpVHjEw4wtFB3UB1OaZx89+FSUlFNjYVb5WlJjCOqDCzoN50BEz1Abs5x1ZQKVKBo82sTbQ==", + "dev": true, + "dependencies": { + "@php-wasm/logger": "0.9.46", + "@php-wasm/node": "0.9.46", + "@php-wasm/node-polyfills": "0.9.46", + "@php-wasm/progress": "0.9.46", + "@php-wasm/scopes": "0.9.46", + "@php-wasm/universal": "0.9.46", + "@php-wasm/util": "0.9.46", + "@wp-playground/common": "0.9.46", + "@wp-playground/wordpress": "0.9.46", + "ajv": "8.12.0", + "comlink": "^4.4.1", + "ini": "4.1.2" + }, + "engines": { + "node": ">=18.18.0", + "npm": ">=8.11.0" + } + }, + "node_modules/@wp-playground/blueprints/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@wp-playground/blueprints/node_modules/ini": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", + "integrity": "sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@wp-playground/blueprints/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wp-playground/cli": { + "version": "0.9.46", + "resolved": "https://registry.npmjs.org/@wp-playground/cli/-/cli-0.9.46.tgz", + "integrity": "sha512-QJWFhFkkoh80bcnwqkmaqwQzDUM2JJLaoAbU86Hm8OFlTutJZutn+3PbRUDthhzXhmq4buuaWlr4Om28hEHeTA==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/logger": "0.9.46", + "@php-wasm/node": "0.9.46", + "@php-wasm/progress": "0.9.46", + "@php-wasm/universal": "0.9.46", + "@wp-playground/blueprints": "0.9.46", + "@wp-playground/common": "0.9.46", + "@wp-playground/wordpress": "0.9.46", + "ajv": "8.12.0", + "comlink": "^4.4.1", + "express": "4.19.2", + "fs-extra": "11.1.1", + "ini": "4.1.2", + "ws": "8.18.0", + "yargs": "17.7.2" + }, + "bin": { + "cli": "wp-playground.js" + } + }, + "node_modules/@wp-playground/cli/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@wp-playground/cli/node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/@wp-playground/cli/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@wp-playground/cli/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@wp-playground/cli/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wp-playground/cli/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@wp-playground/cli/node_modules/express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/@wp-playground/cli/node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@wp-playground/cli/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@wp-playground/cli/node_modules/ini": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", + "integrity": "sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@wp-playground/cli/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wp-playground/cli/node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wp-playground/cli/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@wp-playground/cli/node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wp-playground/cli/node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@wp-playground/cli/node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@wp-playground/cli/node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@wp-playground/cli/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@wp-playground/common": { + "version": "0.9.46", + "resolved": "https://registry.npmjs.org/@wp-playground/common/-/common-0.9.46.tgz", + "integrity": "sha512-ORkT2oPYtIrq5YQusl22yXsoi7Ma6tvnUMOjxW68AYN0/6E5mCCxMK3xU56WcgLXV5CAXEtJ8yVipJZwl4l1CQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "0.9.46", + "@php-wasm/util": "0.9.46", + "comlink": "^4.4.1", + "ini": "4.1.2" + }, + "engines": { + "node": ">=18.18.0", + "npm": ">=8.11.0" + } + }, + "node_modules/@wp-playground/common/node_modules/ini": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", + "integrity": "sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@wp-playground/wordpress": { + "version": "0.9.46", + "resolved": "https://registry.npmjs.org/@wp-playground/wordpress/-/wordpress-0.9.46.tgz", + "integrity": "sha512-emBgcs2ZvxLcNh9CJicAzkS+9zzQjc0SftTb/7l7tO8c1p9p4t3qyefQdQMniji8qywBJ9v9eYe+3+ZLYWaQ1Q==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/node": "0.9.46", + "@php-wasm/universal": "0.9.46", + "@php-wasm/util": "0.9.46", + "@wp-playground/common": "0.9.46", + "comlink": "^4.4.1", + "express": "4.19.2", + "ini": "4.1.2", + "ws": "8.18.0", + "yargs": "17.7.2" + }, + "engines": { + "node": ">=18.18.0", + "npm": ">=8.11.0" + } + }, + "node_modules/@wp-playground/wordpress/node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/@wp-playground/wordpress/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@wp-playground/wordpress/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@wp-playground/wordpress/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wp-playground/wordpress/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@wp-playground/wordpress/node_modules/express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/@wp-playground/wordpress/node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "jest": ">=29" + "node": ">= 0.8" } }, - "node_modules/@wordpress/jest-preset-default": { - "version": "12.28.0", - "resolved": "https://registry.npmjs.org/@wordpress/jest-preset-default/-/jest-preset-default-12.28.0.tgz", - "integrity": "sha512-JjZ5vhVuEDwpeBrogbVZBHVYqXO54WS7UC7hwPZEqgLqf5dTzAxT2wo3nnGJmYwE/8WlABGQkE/4FgzuyFP/1Q==", + "node_modules/@wp-playground/wordpress/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, + "license": "MIT", "dependencies": { - "@wordpress/jest-console": "^8.28.0", - "babel-jest": "29.7.0" + "safer-buffer": ">= 2.1.2 < 3" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "@babel/core": ">=7", - "jest": ">=29" + "node": ">=0.10.0" } }, - "node_modules/@wordpress/npm-package-json-lint-config": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-5.28.0.tgz", - "integrity": "sha512-H9T004zwuO3MSJPO1RbgR4ceZuLam5JIfVwD3UEqJ1VQRpIPLzdJ9MybKI0URqNL9/+A4UJGX0RwpilMGoTNKg==", + "node_modules/@wp-playground/wordpress/node_modules/ini": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", + "integrity": "sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==", "dev": true, + "license": "ISC", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "npm-package-json-lint": ">=6.0.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/@wordpress/postcss-plugins-preset": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-5.28.0.tgz", - "integrity": "sha512-9934WftbPRTsM10PiSVsQWKwjGXm1Mvj5wjEnAhvuvBfjw0Yz01S7mNfy2I+Y2/oR1zgPRHAp97dVwIn/YRluA==", + "node_modules/@wp-playground/wordpress/node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", "dev": true, - "dependencies": { - "@wordpress/base-styles": "^6.4.0", - "autoprefixer": "^10.4.20" + "license": "MIT" + }, + "node_modules/@wp-playground/wordpress/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "postcss": "^8.0.0" + "node": ">=4" } }, - "node_modules/@wordpress/prettier-config": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.28.0.tgz", - "integrity": "sha512-Lp6pvFZ+XgdEgO/mhL88asL74GzbZ6xdik6Nb9LTsW8psXsIX3O2t4BbGJP81EjvBujJt94kljTHEfZrgAuB8g==", + "node_modules/@wp-playground/wordpress/node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wp-playground/wordpress/node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.4" + }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=0.6" }, - "peerDependencies": { - "prettier": ">=3" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@wordpress/scripts": { - "version": "30.21.0", - "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-30.21.0.tgz", - "integrity": "sha512-yztjf6DjQFpndvdIG8zV6jVG8cU6EoXVx5+e/RikWZNHWh5nwnauhVa4xE0ZkGFONKKq0+8T5DqNYW3UvoXPMg==", + "node_modules/@wp-playground/wordpress/node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/core": "7.25.7", - "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", - "@svgr/webpack": "^8.0.1", - "@wordpress/babel-preset-default": "^8.28.0", - "@wordpress/browserslist-config": "^6.28.0", - "@wordpress/dependency-extraction-webpack-plugin": "^6.28.0", - "@wordpress/e2e-test-utils-playwright": "^1.28.0", - "@wordpress/eslint-plugin": "^22.14.0", - "@wordpress/jest-preset-default": "^12.28.0", - "@wordpress/npm-package-json-lint-config": "^5.28.0", - "@wordpress/postcss-plugins-preset": "^5.28.0", - "@wordpress/prettier-config": "^4.28.0", - "@wordpress/stylelint-config": "^23.20.0", - "adm-zip": "^0.5.9", - "babel-jest": "29.7.0", - "babel-loader": "9.2.1", - "browserslist": "^4.21.10", - "chalk": "^4.0.0", - "check-node-version": "^4.1.0", - "clean-webpack-plugin": "^3.0.0", - "copy-webpack-plugin": "^10.2.0", - "cross-spawn": "^7.0.6", - "css-loader": "^6.2.0", - "cssnano": "^6.0.1", - "cwd": "^0.10.0", - "dir-glob": "^3.0.1", - "eslint": "^8.3.0", - "expect-puppeteer": "^4.4.0", - "fast-glob": "^3.2.7", - "filenamify": "^4.2.0", - "jest": "^29.6.2", - "jest-dev-server": "^10.1.4", - "jest-environment-jsdom": "^29.6.2", - "jest-environment-node": "^29.6.2", - "json2php": "^0.0.9", - "markdownlint-cli": "^0.31.1", - "merge-deep": "^3.0.3", - "mini-css-extract-plugin": "^2.9.2", - "minimist": "^1.2.0", - "npm-package-json-lint": "^6.4.0", - "npm-packlist": "^3.0.0", - "postcss": "^8.4.5", - "postcss-loader": "^6.2.1", - "prettier": "npm:wp-prettier@3.0.3", - "puppeteer-core": "^23.10.1", - "react-refresh": "^0.14.0", - "read-pkg-up": "^7.0.1", - "resolve-bin": "^0.4.0", - "rtlcss": "^4.3.0", - "sass": "^1.54.0", - "sass-loader": "^16.0.3", - "schema-utils": "^4.2.0", - "source-map-loader": "^3.0.0", - "stylelint": "^16.8.2", - "terser-webpack-plugin": "^5.3.10", - "url-loader": "^4.1.1", - "webpack": "^5.97.0", - "webpack-bundle-analyzer": "^4.9.1", - "webpack-cli": "^5.1.4", - "webpack-dev-server": "^4.15.1" - }, - "bin": { - "wp-scripts": "bin/wp-scripts.js" + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "@playwright/test": "^1.51.1", - "@wordpress/env": "^10.0.0", - "react": "^18.0.0", - "react-dom": "^18.0.0" - }, - "peerDependenciesMeta": { - "@wordpress/env": { - "optional": true - } + "node": ">= 0.8.0" } }, - "node_modules/@wordpress/stylelint-config": { - "version": "23.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-23.20.0.tgz", - "integrity": "sha512-WRnd35HIdrrtvGU7gxxvXbmmGI/KoLVeHDOTFjYFQHkIn7Hkv9EkudnSfW9P4cK2K5lDhdMM+sre8g7BfMrDlg==", + "node_modules/@wp-playground/wordpress/node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", "dev": true, + "license": "MIT", "dependencies": { - "@stylistic/stylelint-plugin": "^3.0.1", - "stylelint-config-recommended": "^14.0.1", - "stylelint-config-recommended-scss": "^14.1.0" + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "stylelint": "^16.8.2", - "stylelint-scss": "^6.4.0" + "node": ">= 0.8.0" } }, - "node_modules/@wordpress/warning": { - "version": "3.28.0", - "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.28.0.tgz", - "integrity": "sha512-Hn2wrdgBDRcmBjpEXd5q+bz4qvLMSYoZa0b3uo1Ja9ONNh8eHGnILIAxBk/OmFrCjmXqY6bydTVBRcvXaBq0MQ==", + "node_modules/@wp-playground/wordpress/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/@xtuc/ieee754": { @@ -7093,6 +8135,13 @@ "node": ">= 0.8" } }, + "node_modules/comlink": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz", + "integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -8227,10 +9276,11 @@ } }, "node_modules/dotenv": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", - "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -10080,6 +11130,31 @@ "node": ">=0.10.0" } }, + "node_modules/fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/fs-monkey": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", @@ -12557,6 +13632,29 @@ "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", "dev": true }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -18513,7 +19611,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18557,10 +19654,11 @@ } }, "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", - "dev": true + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", diff --git a/package.json b/package.json index add3622958..5fbc8be4f5 100644 --- a/package.json +++ b/package.json @@ -14,25 +14,32 @@ "npm": ">=10.2.3" }, "devDependencies": { - "@playwright/test": "*", + "@playwright/test": "^1.49.0", + "@types/node": "^22.0.0", "@wordpress/scripts": "*", "@wordpress/stylelint-config": "*", - "dotenv": "*", + "@wp-playground/cli": "^0.9.0", + "dotenv": "^16.4.0", "eslint-plugin-eslint-comments": "*", - "husky": "*" + "husky": "*", + "typescript": "^5.6.0" }, "scripts": { "format": "wp-scripts format ./assets", "lint:css": "wp-scripts lint-style \"**/*.css\"", "lint:css:fix": "npm run lint:css -- --fix", - "lint:js": "wp-scripts lint-js ./assets/js/*.js && wp-scripts lint-js ./assets/js/web-components/*.js && wp-scripts lint-js ./assets/js/widgets/*.js && wp-scripts lint-js ./assets/js/recommendations/*.js && wp-scripts lint-js ./tests/**/*.js", - "lint:js:fix": "wp-scripts lint-js ./assets/js/*.js --fix && wp-scripts lint-js ./assets/js/web-components/*.js --fix && wp-scripts lint-js ./assets/js/widgets/*.js --fix && wp-scripts lint-js ./assets/js/recommendations/*.js --fix && wp-scripts lint-js ./tests/**/*.js --fix", + "lint:js": "wp-scripts lint-js ./assets/js/*.js && wp-scripts lint-js ./assets/js/web-components/*.js && wp-scripts lint-js ./assets/js/widgets/*.js && wp-scripts lint-js ./assets/js/recommendations/*.js", + "lint:js:fix": "wp-scripts lint-js ./assets/js/*.js --fix && wp-scripts lint-js ./assets/js/web-components/*.js --fix && wp-scripts lint-js ./assets/js/widgets/*.js --fix && wp-scripts lint-js ./assets/js/recommendations/*.js --fix", "prepare": "husky", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", "test:e2e:debug": "playwright test --debug", - "test:sequential": "npx playwright test --project=sequential", - "test:parallel": "npx playwright test --project=parallel", + "test:e2e:report": "playwright show-report", + "test:e2e:codegen": "playwright codegen", + "test:sequential": "playwright test --project=sequential", + "test:parallel": "playwright test --project=parallel", + "test:playground": "PLAYGROUND=true playwright test", "test": "npm run test:sequential && npm run test:parallel" }, "dependencies": { diff --git a/playwright.config.js b/playwright.config.js deleted file mode 100644 index 00a5537010..0000000000 --- a/playwright.config.js +++ /dev/null @@ -1,33 +0,0 @@ -const { defineConfig, devices } = require( '@playwright/test' ); - -module.exports = defineConfig( { - testDir: './tests/e2e', - timeout: 30000, - forbidOnly: !! process.env.CI, - retries: process.env.CI ? 2 : 0, - reporter: 'html', - globalSetup: './tests/e2e/auth.setup.js', - globalTeardown: './tests/e2e/auth.setup.js', - use: { - baseURL: process.env.WORDPRESS_URL || 'http://localhost:8080', - trace: 'on-first-retry', - screenshot: 'only-on-failure', - storageState: 'auth.json', - }, - projects: [ - { - name: 'sequential', - use: { ...devices[ 'Desktop Chrome' ] }, - testMatch: 'sequential.spec.js', - fullyParallel: false, - workers: 1, - }, - { - name: 'parallel', - use: { ...devices[ 'Desktop Chrome' ] }, - testIgnore: [ 'sequential.spec.js', '**/sequential/**' ], - fullyParallel: true, - workers: 4, - }, - ], -} ); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..dfc2442c18 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,93 @@ +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; + +// Load environment variables +dotenv.config(); + +const baseURL = process.env.WORDPRESS_URL || 'http://localhost:8080'; + +export default defineConfig({ + testDir: './tests/e2e/specs', + + // Run tests in parallel by default + 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 for more predictable results + workers: process.env.CI ? 1 : undefined, + + // Reporter to use + reporter: [ + ['html', { open: 'never' }], + ['list'], + ], + + // Global timeout + timeout: 30000, + + // Shared settings for all the projects below + use: { + baseURL, + + // Ignore HTTPS errors for local development with self-signed certificates + ignoreHTTPSErrors: true, + + // Collect trace on first retry + trace: 'on-first-retry', + + // Take screenshot on failure + screenshot: 'only-on-failure', + + // Video recording on first retry + video: 'on-first-retry', + + // Increase timeout for slow WordPress operations + actionTimeout: 15000, + + // Use authenticated state by default + storageState: './auth.json', + }, + + // Global setup for authentication + globalSetup: './tests/e2e/global-setup.ts', + + // Global teardown for cleanup + globalTeardown: './tests/e2e/global-teardown.ts', + + // Configure projects for different browsers + projects: [ + // Sequential tests that must run in order (e.g., onboarding) + // Note: In CI, onboarding runs first on fresh WordPress install + // For local dev with existing site, run only parallel: npm run test:parallel + { + name: 'sequential', + testMatch: '**/onboarding.spec.ts', + use: { ...devices['Desktop Chrome'] }, + fullyParallel: false, + workers: 1, + }, + + // Main test suite - can run in parallel + // Depends on sequential in CI (fresh install needs onboarding first) + { + name: 'parallel', + testIgnore: '**/onboarding.spec.ts', + dependencies: process.env.CI ? ['sequential'] : [], + use: { ...devices['Desktop Chrome'] }, + }, + ], + + // Run local WordPress server before starting the tests + // Uncomment to use WP Playground + // webServer: { + // command: 'npx @wp-playground/cli@latest server --auto-mount --port=8080 --login', + // url: 'http://localhost:8080', + // reuseExistingServer: !process.env.CI, + // timeout: 120 * 1000, + // }, +}); diff --git a/tests/e2e/.env.example b/tests/e2e/.env.example new file mode 100644 index 0000000000..f3b3086f90 --- /dev/null +++ b/tests/e2e/.env.example @@ -0,0 +1,9 @@ +# WordPress URL for E2E tests +WORDPRESS_URL=http://localhost:8080 + +# WordPress admin credentials +WORDPRESS_ADMIN_USER=admin +WORDPRESS_ADMIN_PASSWORD=password + +# Optional: Progress Planner test token for API access +PRPL_TEST_TOKEN= diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 0000000000..f1968777f8 --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1,10 @@ +# Auth state +auth.json + +# Test artifacts +test-results/ +playwright-report/ +*.png + +# Old backup files (can be removed once migration is verified) +_old/ diff --git a/tests/e2e/api/tasks.api.ts b/tests/e2e/api/tasks.api.ts new file mode 100644 index 0000000000..a9f5d9c2aa --- /dev/null +++ b/tests/e2e/api/tasks.api.ts @@ -0,0 +1,129 @@ +import { Page, APIRequestContext } from '@playwright/test'; + +export interface Task { + ID: number; + post_name: string; + post_status: 'publish' | 'pending' | 'future' | 'trash'; + post_title: string; + post_date: string; +} + +/** + * REST API client for Progress Planner tasks. + * Uses the authenticated session from the page context. + */ +export class TasksApi { + private readonly page: Page; + private readonly request: APIRequestContext; + private readonly baseUrl: string; + + constructor(page: Page, request: APIRequestContext) { + this.page = page; + this.request = request; + this.baseUrl = process.env.WORDPRESS_URL || 'http://localhost:8080'; + } + + /** + * Get cookies from the page context for authenticated requests. + */ + private async getAuthCookies(): Promise> { + return await this.page.context().cookies(); + } + + /** + * Make an authenticated GET request to the REST API. + */ + private async get(endpoint: string): Promise { + // Suppress unused variable warning - cookies kept for future auth needs + void this.getAuthCookies(); + + const params: Record = {}; + if (process.env.PRPL_TEST_TOKEN) { + params.token = process.env.PRPL_TEST_TOKEN; + } + + const response = await this.request.get( + `${this.baseUrl}/?rest_route=${endpoint}`, + { + headers: { + 'Content-Type': 'application/json', + }, + params, + } + ); + + if (!response.ok()) { + throw new Error(`API request failed: ${response.status()} ${await response.text()}`); + } + + return await response.json(); + } + + /** + * Get all tasks. + */ + async getAllTasks(): Promise { + return await this.get('/progress-planner/v1/tasks'); + } + + /** + * Get a task by its slug/post_name. + */ + async getTask(taskId: string): Promise { + const tasks = await this.getAllTasks(); + return tasks.find((task) => task.post_name === taskId); + } + + /** + * Get tasks by status. + */ + async getTasksByStatus(status: Task['post_status']): Promise { + const tasks = await this.getAllTasks(); + return tasks.filter((task) => task.post_status === status); + } + + /** + * Assert that a task has a specific status. + */ + async expectTaskStatus(taskId: string, expectedStatus: Task['post_status']): Promise { + const task = await this.getTask(taskId); + + if (!task) { + throw new Error(`Task "${taskId}" not found`); + } + + if (task.post_status !== expectedStatus) { + throw new Error( + `Task "${taskId}" has status "${task.post_status}", expected "${expectedStatus}"` + ); + } + } + + /** + * Wait for a task to reach a specific status. + * Polls the API until the status matches or timeout. + */ + async waitForTaskStatus( + taskId: string, + expectedStatus: Task['post_status'], + options: { timeout?: number; interval?: number } = {} + ): Promise { + const timeout = options.timeout ?? 10000; + const interval = options.interval ?? 500; + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const task = await this.getTask(taskId); + + if (task?.post_status === expectedStatus) { + return task; + } + + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + throw new Error( + `Timeout waiting for task "${taskId}" to have status "${expectedStatus}"` + ); + } +} diff --git a/tests/e2e/auth.setup.js b/tests/e2e/auth.setup.js deleted file mode 100644 index c9735bdf84..0000000000 --- a/tests/e2e/auth.setup.js +++ /dev/null @@ -1,85 +0,0 @@ -const { chromium } = require( '@playwright/test' ); -const fs = require( 'fs' ); -const path = require( 'path' ); -require( 'dotenv' ).config(); - -// Add cleanup function -async function cleanup() { - const authFile = path.join( process.cwd(), 'auth.json' ); - if ( fs.existsSync( authFile ) ) { - console.log( 'Cleaning up auth.json...' ); - fs.unlinkSync( authFile ); - } -} - -// Handle async cleanup properly -async function handleCleanup() { - await cleanup(); - process.exit( 0 ); -} - -// Register cleanup on process exit -process.on( 'exit', () => cleanup() ); // exit event doesn't support async, it gets triggered between sequential & parallel tests -process.on( 'SIGINT', () => handleCleanup() ); -process.on( 'SIGTERM', () => handleCleanup() ); - -async function globalSetup() { - const authFile = path.join( process.cwd(), 'auth.json' ); - - // Check if auth.json exists - if ( fs.existsSync( authFile ) ) { - console.log( 'Using existing auth.json...' ); - return; - } - - console.log( 'Starting login process...' ); - const browser = await chromium.launch(); - const context = await browser.newContext(); - const page = await context.newPage(); - - // Set up error listener for all tests - page.on( 'pageerror', ( err ) => { - console.log( 'JS Error:', err.message ); - } ); - - try { - // Go to WordPress dashboard - const baseURL = process.env.WORDPRESS_URL || 'http://localhost:8080'; - console.log( 'Navigating to WordPress dashboard...' ); - await page.goto( `${ baseURL }/wp-login.php` ); - - // Log in - console.log( 'Logging in...' ); - await page.fill( - '#user_login', - process.env.WORDPRESS_ADMIN_USER || 'admin' - ); - await page.fill( - '#user_pass', - process.env.WORDPRESS_ADMIN_PASSWORD || 'password' - ); - await page.click( '#wp-submit' ); - - // Wait for login to complete and verify we're on the dashboard - await page.waitForURL( `${ baseURL }/wp-admin/` ); - await page.waitForSelector( '#wpadminbar' ); - console.log( 'Login successful' ); - } catch ( error ) { - console.error( '\n❌ Onboarding completion failed:', error.message ); - console.error( 'Current page URL:', page.url() ); - console.error( 'Current page content:', await page.content() ); - await page.screenshot( { path: 'onboarding-failed.png' } ); - await browser.close(); - process.exit( 1 ); - } - - console.log( 'Saving auth state...' ); - // Save the state to auth.json - await context.storageState( { path: 'auth.json' } ); - await browser.close(); - console.log( 'Global setup completed' ); -} - -// Export both functions -module.exports = globalSetup; -module.exports.globalTeardown = cleanup; diff --git a/tests/e2e/constants/selectors.js b/tests/e2e/constants/selectors.js deleted file mode 100644 index 64dad991f5..0000000000 --- a/tests/e2e/constants/selectors.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Common selectors used across tests - */ - -const SELECTORS = { - RR_ITEM_TEXT: 'h3 > span', - TODO_ITEM: 'ul#todo-list > li', - TODO_COMPLETED_ITEM: 'ul#todo-list-completed > li', - TODO_LIST: 'ul#todo-list', - TODO_LIST_COMPLETED: 'ul#todo-list-completed', -}; - -module.exports = SELECTORS; diff --git a/tests/e2e/fixtures/base.fixture.ts b/tests/e2e/fixtures/base.fixture.ts new file mode 100644 index 0000000000..e2311849f5 --- /dev/null +++ b/tests/e2e/fixtures/base.fixture.ts @@ -0,0 +1,96 @@ +import { test as base, expect } from '@playwright/test'; +import { DashboardPage } from '../pages/dashboard.page'; +import { YoastSettingsPage } from '../pages/yoast-settings.page'; +import { TasksApi } from '../api/tasks.api'; + +/** + * Custom fixture types for Progress Planner E2E tests. + */ +type ProgressPlannerFixtures = { + /** + * Dashboard page object with all Progress Planner dashboard functionality. + * Automatically navigates to the dashboard. + */ + dashboard: DashboardPage; + + /** + * Dashboard page object without automatic navigation. + * Use when you need to go somewhere else first. + */ + dashboardPage: DashboardPage; + + /** + * Yoast SEO settings page object. + * Use for testing Yoast integration features. + */ + yoastSettings: YoastSettingsPage; + + /** + * REST API client for direct task manipulation. + */ + tasksApi: TasksApi; + + /** + * Automatic cleanup after each test. + * Set to true to enable. + */ + cleanupAfterTest: boolean; +}; + +/** + * Extended test with Progress Planner fixtures. + * + * Usage: + * ```ts + * import { test, expect } from './fixtures/base.fixture'; + * + * test('my test', async ({ dashboard }) => { + * const { taskId } = await dashboard.createTodo('My task'); + * // ... + * }); + * ``` + */ +export const test = base.extend({ + // Default: no automatic cleanup + cleanupAfterTest: [false, { option: true }], + + // Dashboard page object (no auto-navigation) + dashboardPage: async ({ page }, use) => { + const dashboardPage = new DashboardPage(page); + await use(dashboardPage); + }, + + // Dashboard with auto-navigation + dashboard: async ({ page, cleanupAfterTest }, use) => { + const dashboard = new DashboardPage(page); + await dashboard.goto(); + + await use(dashboard); + + // Cleanup after test if enabled + if (cleanupAfterTest) { + try { + await dashboard.deleteAllTodos(); + } catch (err) { + console.warn('[Fixture Cleanup] Failed:', err); + } + } + }, + + // Yoast settings page object (no auto-navigation) + yoastSettings: async ({ page }, use) => { + const yoastSettings = new YoastSettingsPage(page); + await use(yoastSettings); + }, + + // REST API client + tasksApi: async ({ page, request }, use) => { + const api = new TasksApi(page, request); + await use(api); + }, +}); + +export { expect } from '@playwright/test'; + +// Re-export for convenience +export type { Page, Locator } from '@playwright/test'; diff --git a/tests/e2e/fixtures/playground.fixture.ts b/tests/e2e/fixtures/playground.fixture.ts new file mode 100644 index 0000000000..42f3583102 --- /dev/null +++ b/tests/e2e/fixtures/playground.fixture.ts @@ -0,0 +1,120 @@ +import { test as base } from '@playwright/test'; +import { spawn, ChildProcess } from 'child_process'; + +/** + * WP Playground fixture for completely isolated WordPress instances. + * + * Each test file gets its own WordPress instance with no shared state. + * Perfect for tests that modify global settings or need a clean slate. + * + * Usage: + * ```ts + * import { test, expect } from '../fixtures/playground.fixture'; + * + * test('my isolated test', async ({ page, wpUrl }) => { + * await page.goto(wpUrl + '/wp-admin/'); + * // ... + * }); + * ``` + */ + +type PlaygroundFixtures = { + /** + * URL of the WordPress instance. + */ + wpUrl: string; + + /** + * Whether the Playground server is ready. + */ + playgroundReady: boolean; +}; + +type PlaygroundWorkerFixtures = { + /** + * The Playground server process (shared per worker). + */ + playgroundServer: { url: string; process: ChildProcess }; +}; + +export const test = base.extend({ + // Worker-scoped: one Playground server per test worker + playgroundServer: [ + async ({}, use, workerInfo) => { + const port = 9400 + workerInfo.workerIndex; + const url = `http://127.0.0.1:${port}`; + + console.log(`[Worker ${workerInfo.workerIndex}] Starting Playground on port ${port}...`); + + // Start Playground server + const serverProcess = spawn('npx', [ + '@wp-playground/cli@latest', + 'server', + `--port=${port}`, + '--login', + '--wp=latest', + '--php=8.3', + // Mount plugin if in the right directory + '--auto-mount', + ], { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + }); + + // Wait for server to be ready + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Playground server failed to start within 60s')); + }, 60000); + + serverProcess.stdout?.on('data', (data: Buffer) => { + const output = data.toString(); + console.log(`[Playground] ${output}`); + + if (output.includes('WordPress is running') || output.includes(url)) { + clearTimeout(timeout); + resolve(); + } + }); + + serverProcess.stderr?.on('data', (data: Buffer) => { + console.error(`[Playground Error] ${data.toString()}`); + }); + + serverProcess.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + + serverProcess.on('exit', (code) => { + if (code !== 0 && code !== null) { + clearTimeout(timeout); + reject(new Error(`Playground exited with code ${code}`)); + } + }); + }); + + console.log(`[Worker ${workerInfo.workerIndex}] Playground ready at ${url}`); + + await use({ url, process: serverProcess }); + + // Cleanup: stop the server + console.log(`[Worker ${workerInfo.workerIndex}] Stopping Playground...`); + serverProcess.kill('SIGTERM'); + }, + { scope: 'worker', timeout: 120000 }, + ], + + // Test-scoped: provide the URL to each test + wpUrl: async ({ playgroundServer }, use) => { + await use(playgroundServer.url); + }, + + playgroundReady: async ({ playgroundServer }, use) => { + // Just ensure playgroundServer is initialized + void playgroundServer; + await use(true); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000000..53e5943b29 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,68 @@ +import { chromium, FullConfig } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const authFile = path.join(process.cwd(), 'auth.json'); + +async function globalSetup(config: FullConfig): Promise { + // Skip if auth file already exists and is recent + if (fs.existsSync(authFile)) { + const stats = fs.statSync(authFile); + const ageMinutes = (Date.now() - stats.mtimeMs) / 1000 / 60; + + // Reuse auth file if less than 30 minutes old + if (ageMinutes < 30) { + console.log('Using existing auth.json (age: ' + Math.round(ageMinutes) + ' minutes)'); + return; + } + } + + console.log('Generating fresh auth.json...'); + + const baseURL = process.env.WORDPRESS_URL || 'http://localhost:8080'; + const browser = await chromium.launch(); + const context = await browser.newContext({ + // Ignore HTTPS errors for local development with self-signed certificates + ignoreHTTPSErrors: true, + }); + const page = await context.newPage(); + + // Listen for console errors + page.on('pageerror', (err) => { + console.warn('Page error:', err.message); + }); + + try { + // Navigate to login page + await page.goto(`${baseURL}/wp-login.php`); + + // Fill login form + await page.fill('#user_login', process.env.WORDPRESS_ADMIN_USER || 'admin'); + await page.fill('#user_pass', process.env.WORDPRESS_ADMIN_PASSWORD || 'password'); + await page.click('#wp-submit'); + + // Wait for login to complete + await page.waitForURL(`${baseURL}/wp-admin/**`, { timeout: 30000 }); + await page.waitForSelector('#wpadminbar', { timeout: 10000 }); + + console.log('Login successful'); + + // Save auth state + await context.storageState({ path: authFile }); + console.log('Auth state saved to auth.json'); + } catch (error) { + console.error('Login failed:', error); + + // Take screenshot for debugging + await page.screenshot({ path: 'login-failed.png' }); + + throw error; + } finally { + await browser.close(); + } +} + +export default globalSetup; diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts new file mode 100644 index 0000000000..dec56ac0c6 --- /dev/null +++ b/tests/e2e/global-teardown.ts @@ -0,0 +1,14 @@ +import fs from 'fs'; +import path from 'path'; + +const authFile = path.join(process.cwd(), 'auth.json'); + +async function globalTeardown(): Promise { + // Clean up auth file + if (fs.existsSync(authFile)) { + console.log('Cleaning up auth.json...'); + fs.unlinkSync(authFile); + } +} + +export default globalTeardown; diff --git a/tests/e2e/helpers/cleanup.js b/tests/e2e/helpers/cleanup.js deleted file mode 100644 index 78402db599..0000000000 --- a/tests/e2e/helpers/cleanup.js +++ /dev/null @@ -1,100 +0,0 @@ -const SELECTORS = require( '../constants/selectors' ); - -/** - * Cleans up all active and completed tasks in the planner UI. - * Requires a Playwright `page`, `context`, and `baseUrl`. - * - * @param {Object} root0 - * @param {import('@playwright/test').Page} root0.page - * @param {import('@playwright/test').BrowserContext} root0.context - * @param {string} root0.baseUrl - * @return {Promise} - */ -async function cleanUpPlannerTasks( { page, context, baseUrl } ) { - try { - if ( page.isClosed?.() ) { - return; - } - - await page.goto( - `${ baseUrl }/wp-admin/admin.php?page=progress-planner` - ); - await page.waitForLoadState( 'networkidle' ); - - // Clean up ACTIVE tasks - const todoItems = page.locator( SELECTORS.TODO_ITEM ); - while ( ( await todoItems.count() ) > 0 ) { - const firstItem = todoItems.first(); - const trash = firstItem.locator( - '.prpl-suggested-task-actions-wrapper .trash' - ); - - try { - console.log( - 'deleting TODO: ', - await firstItem.locator( 'h3 > span' ).textContent() - ); - await firstItem.scrollIntoViewIfNeeded(); - await firstItem.hover(); - await trash.waitFor( { state: 'visible', timeout: 3000 } ); - await trash.click(); - await page.waitForTimeout( 1500 ); - } catch ( err ) { - console.warn( - '[Cleanup] Failed to delete active todo item:', - err.message - ); - break; - } - } - - // Clean up COMPLETED tasks - const completedDetails = page.locator( - 'details#todo-list-completed-details' - ); - if ( await completedDetails.isVisible() ) { - await completedDetails.click(); - await page.waitForTimeout( 500 ); - - const completedItems = page.locator( - SELECTORS.TODO_COMPLETED_ITEM - ); - while ( ( await completedItems.count() ) > 0 ) { - const firstCompleted = completedItems.first(); - const trash = firstCompleted.locator( - '.prpl-suggested-task-points-wrapper .trash' - ); - - try { - console.log( - 'deleting completed TODO: ', - await firstCompleted - .locator( 'h3 > span' ) - .textContent() - ); - await firstCompleted.scrollIntoViewIfNeeded(); - await firstCompleted.hover(); - await trash.waitFor( { state: 'visible', timeout: 3000 } ); - await trash.click(); - await page.waitForTimeout( 1500 ); - } catch ( err ) { - console.warn( - '[Cleanup] Failed to delete completed todo item:', - err.message - ); - break; - } - } - } - } catch ( e ) { - console.warn( '[Cleanup] Unexpected failure:', e.message ); - } - - try { - await context.close(); - } catch { - // context might already be closed - } -} - -module.exports = { cleanUpPlannerTasks }; diff --git a/tests/e2e/pages/base.page.ts b/tests/e2e/pages/base.page.ts new file mode 100644 index 0000000000..842f37f508 --- /dev/null +++ b/tests/e2e/pages/base.page.ts @@ -0,0 +1,96 @@ +import { Page, Locator, Response } from '@playwright/test'; + +/** + * Base page object with common functionality. + * All page objects should extend this class. + */ +export abstract class BasePage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** + * Navigate to the page URL. + * Subclasses should override this with their specific URL. + */ + abstract goto(): Promise; + + /** + * Wait for page to be fully loaded. + * Override in subclasses for page-specific loading indicators. + */ + async waitForReady(): Promise { + await this.page.waitForLoadState('networkidle'); + } + + /** + * Smart wait for an element with automatic retry. + * Much better than waitForTimeout! + */ + protected async waitForElement( + selector: string | Locator, + options: { state?: 'visible' | 'hidden' | 'attached'; timeout?: number } = {} + ): Promise { + const locator = typeof selector === 'string' + ? this.page.locator(selector) + : selector; + + await locator.waitFor({ + state: options.state ?? 'visible', + timeout: options.timeout ?? 10000 + }); + + return locator; + } + + /** + * Wait for a REST API response. + * Use instead of arbitrary timeouts after actions. + */ + protected async waitForApiResponse( + urlPattern: string | RegExp, + action: () => Promise + ): Promise { + const [response] = await Promise.all([ + this.page.waitForResponse( + (resp) => { + const url = resp.url(); + return typeof urlPattern === 'string' + ? url.includes(urlPattern) + : urlPattern.test(url); + }, + { timeout: 15000 } + ), + action(), + ]); + return response; + } + + /** + * Wait for animation to complete. + * Uses requestAnimationFrame instead of fixed timeout. + */ + protected async waitForAnimation(element: Locator): Promise { + await element.evaluate((el) => { + return new Promise((resolve) => { + const animations = el.getAnimations(); + if (animations.length === 0) { + resolve(); + return; + } + Promise.all(animations.map((a) => a.finished)).then(() => resolve()); + }); + }); + } + + /** + * Scroll element into view and wait for it to be stable. + */ + protected async scrollToAndWait(element: Locator): Promise { + await element.scrollIntoViewIfNeeded(); + // Wait for any scroll-triggered animations + await this.page.waitForTimeout(100); + } +} diff --git a/tests/e2e/pages/dashboard.page.ts b/tests/e2e/pages/dashboard.page.ts new file mode 100644 index 0000000000..b032e5422b --- /dev/null +++ b/tests/e2e/pages/dashboard.page.ts @@ -0,0 +1,403 @@ +import { Page, Locator, expect } from '@playwright/test'; +import { BasePage } from './base.page'; + +/** + * Selectors for the Progress Planner dashboard. + * Centralized here for easy maintenance. + */ +const SELECTORS = { + // Todo lists + todoList: 'ul#todo-list', + todoItem: 'ul#todo-list > li', + todoCompletedList: 'ul#todo-list-completed', + todoCompletedItem: 'ul#todo-list-completed > li', + todoCompletedDetails: 'details#todo-list-completed-details', + + // Todo form + newTodoInput: '#new-todo-content', + + // Task elements + taskItemText: 'h3 > span', + taskCheckbox: '.prpl-suggested-task-checkbox', + taskCheckboxLabel: 'label', + taskActionsWrapper: '.prpl-suggested-task-actions-wrapper', + taskTrashButton: '.trash', + taskMoveUpButton: '.prpl-suggested-task-button.move-up', + taskMoveDownButton: '.prpl-suggested-task-button.move-down', + taskSnoozeButton: 'button[data-action="snooze"]', + + // Suggested tasks + suggestedTasksList: '#prpl-suggested-tasks-list', + suggestedTaskCheckbox: '#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)', + + // Widgets + widgetWrapper: '.prpl-widget-wrapper.prpl-suggested-tasks', + suggestedTasksListWidget: '.prpl-widget-wrapper.prpl-suggested-tasks .prpl-suggested-tasks-list', + + // Onboarding + onboardingPopover: '#prpl-popover-onboarding', + privacyCheckboxLabel: 'label[for="prpl-privacy-checkbox"]', + tourNextButton: '.prpl-tour-next', + tourCloseButton: '#prpl-tour-close-btn', + + // Snooze + snoozeRadioGroup: 'button.prpl-toggle-radio-group', + snoozeDurationRadio: '.prpl-snooze-duration-radio-group input[type="radio"]', + + // Tour (Driver.js based) + tourStartButton: '#prpl-start-tour-icon-button', + tourPopover: '.driver-popover', + tourNextBtn: '.driver-popover-next-btn', + tourPrevBtn: '.driver-popover-prev-btn', + tourCloseBtn: '.driver-popover-close-btn', +} as const; + +export class DashboardPage extends BasePage { + // Locators (lazy-initialized for performance) + readonly todoList: Locator; + readonly todoCompletedList: Locator; + readonly newTodoInput: Locator; + readonly suggestedTasksList: Locator; + readonly onboardingPopover: Locator; + readonly tourPopover: Locator; + + constructor(page: Page) { + super(page); + this.todoList = page.locator(SELECTORS.todoList); + this.todoCompletedList = page.locator(SELECTORS.todoCompletedList); + this.newTodoInput = page.locator(SELECTORS.newTodoInput); + this.suggestedTasksList = page.locator(SELECTORS.suggestedTasksList); + this.onboardingPopover = page.locator(SELECTORS.onboardingPopover); + this.tourPopover = page.locator(SELECTORS.tourPopover); + } + + async goto(options?: { showAllRecommendations?: boolean }): Promise { + const url = options?.showAllRecommendations + ? '/wp-admin/admin.php?page=progress-planner&prpl_show_all_recommendations' + : '/wp-admin/admin.php?page=progress-planner'; + + await this.page.goto(url); + await this.waitForReady(); + } + + override async waitForReady(): Promise { + await this.page.waitForLoadState('networkidle'); + // Wait for the main dashboard widget to be visible + await this.page.locator(SELECTORS.widgetWrapper).waitFor({ + state: 'visible', + timeout: 10000 + }); + } + + // ================== + // Todo CRUD Operations + // ================== + + async createTodo(text: string): Promise<{ taskId: string; element: Locator }> { + await this.newTodoInput.fill(text); + + // Wait for the API response when pressing Enter + await this.waitForApiResponse( + '/progress-planner/v1/', + async () => { + await this.page.keyboard.press('Enter'); + } + ); + + // Find the newly created task + const todoItem = this.page.locator(SELECTORS.todoItem).first(); + await todoItem.waitFor({ state: 'visible' }); + + const taskId = await todoItem.getAttribute('data-task-id'); + if (!taskId) throw new Error('Created todo has no task ID'); + + return { taskId, element: todoItem }; + } + + async getTodoItems(): Promise { + return await this.page.locator(SELECTORS.todoItem).all(); + } + + async getTodoByText(text: string): Promise { + return this.page.locator(SELECTORS.todoItem).filter({ + has: this.page.locator(SELECTORS.taskItemText, { hasText: text }), + }); + } + + async getTodoById(taskId: string): Promise { + return this.page.locator(`li[data-task-id="${taskId}"]`); + } + + async getTodoText(item: Locator): Promise { + return await item.locator(SELECTORS.taskItemText).textContent() ?? ''; + } + + async deleteTodo(item: Locator): Promise { + await this.scrollToAndWait(item); + await item.hover(); + + const trashButton = item.locator(`${SELECTORS.taskActionsWrapper} ${SELECTORS.taskTrashButton}`); + await trashButton.waitFor({ state: 'visible' }); + + await this.waitForApiResponse( + '/progress-planner/v1/', + async () => { + await trashButton.click(); + } + ); + } + + async completeTodo(item: Locator): Promise { + const label = item.locator(SELECTORS.taskCheckboxLabel); + + await this.waitForApiResponse( + '/progress-planner/v1/', + async () => { + await label.click(); + } + ); + + // Wait for the celebration animation + await this.waitForAnimation(item); + } + + async moveTodoDown(item: Locator): Promise { + await item.hover(); + const moveDownButton = item.locator(SELECTORS.taskMoveDownButton); + await moveDownButton.waitFor({ state: 'visible' }); + + await this.waitForApiResponse( + '/progress-planner/v1/', + async () => { + await moveDownButton.click(); + } + ); + } + + async moveTodoUp(item: Locator): Promise { + await item.hover(); + const moveUpButton = item.locator(SELECTORS.taskMoveUpButton); + await moveUpButton.waitFor({ state: 'visible' }); + + await this.waitForApiResponse( + '/progress-planner/v1/', + async () => { + await moveUpButton.click(); + } + ); + } + + // ================== + // Completed Tasks + // ================== + + async openCompletedTasks(): Promise { + const details = this.page.locator(SELECTORS.todoCompletedDetails); + + // Check if already open + const isOpen = await details.getAttribute('open'); + if (isOpen !== null) return; + + await details.click(); + await this.page.locator(SELECTORS.todoCompletedItem).first().waitFor({ + state: 'visible', + timeout: 5000, + }).catch(() => { + // No completed items, that's fine + }); + } + + async getCompletedItems(): Promise { + return await this.page.locator(SELECTORS.todoCompletedItem).all(); + } + + // ================== + // Suggested Tasks + // ================== + + async getSuggestedTasksCount(): Promise { + return await this.page.locator(SELECTORS.suggestedTaskCheckbox).count(); + } + + async completeSuggestedTask(): Promise<{ taskId: string | null; previousCount: number }> { + const initialCount = await this.getSuggestedTasksCount(); + + if (initialCount === 0) { + return { taskId: null, previousCount: 0 }; + } + + const firstCheckbox = this.page.locator(SELECTORS.suggestedTaskCheckbox).first(); + const taskItem = firstCheckbox.locator('xpath=ancestor::li[1]'); + const taskId = await taskItem.getAttribute('data-task-id'); + + // Click the label (parent of checkbox) + const label = firstCheckbox.locator('..'); + + await this.waitForApiResponse( + '/progress-planner/v1/', + async () => { + await label.click(); + } + ); + + // Wait for animation + await this.waitForAnimation(taskItem); + + return { taskId, previousCount: initialCount }; + } + + // ================== + // Task Snooze + // ================== + + async snoozeTask(taskId: string, duration: '1-day' | '1-week' | '2-weeks' | '1-month'): Promise { + const taskItem = await this.getTodoById(taskId); + await taskItem.hover(); + + // Click snooze button + const snoozeButton = taskItem.locator(SELECTORS.taskSnoozeButton); + await snoozeButton.click(); + + // Open radio group + const radioGroup = taskItem.locator(SELECTORS.snoozeRadioGroup); + await radioGroup.click(); + + // Select duration + const durationRadio = taskItem.locator(`${SELECTORS.snoozeDurationRadio}[value="${duration}"]`); + const label = durationRadio.locator('xpath=ancestor::label[1]'); + + await this.waitForApiResponse( + '/progress-planner/v1/', + async () => { + await label.click(); + } + ); + } + + // ================== + // Onboarding + // ================== + + async isOnboardingVisible(): Promise { + return await this.onboardingPopover.isVisible(); + } + + async completeOnboarding(): Promise { + await expect(this.onboardingPopover).toBeVisible({ timeout: 10000 }); + + // Accept privacy policy + const privacyLabel = this.page.locator(SELECTORS.privacyCheckboxLabel); + await privacyLabel.click(); + + // Start onboarding + const startButton = this.onboardingPopover.locator(SELECTORS.tourNextButton); + await startButton.click(); + + // Wait for step to advance + await expect(this.onboardingPopover).toHaveAttribute('data-prpl-step', /^[1-9]/, { + timeout: 15000, + }); + + // Close onboarding + const closeButton = this.page.locator(SELECTORS.tourCloseButton); + await closeButton.click(); + + await expect(this.onboardingPopover).toBeHidden({ timeout: 5000 }); + } + + // ================== + // Tour (Driver.js) + // ================== + + async startTour(): Promise { + const tourButton = this.page.locator(SELECTORS.tourStartButton); + await tourButton.click(); + + await expect(this.tourPopover).toBeVisible({ timeout: 5000 }); + } + + async isTourVisible(): Promise { + return await this.tourPopover.isVisible(); + } + + async getTourStepsCount(): Promise { + return await this.page.evaluate(() => { + const tour = (window as unknown as { progressPlannerTour?: { steps?: unknown[] } }).progressPlannerTour; + return tour?.steps?.length ?? 0; + }); + } + + async clickTourNext(): Promise { + const nextButton = this.page.locator(SELECTORS.tourNextBtn); + await nextButton.click(); + } + + async getTourNextButtonText(): Promise { + const nextButton = this.page.locator(SELECTORS.tourNextBtn); + return await nextButton.textContent() ?? ''; + } + + async completeTour(): Promise { + // Start the tour if not already visible + if (!await this.isTourVisible()) { + await this.startTour(); + } + + const stepsCount = await this.getTourStepsCount(); + + for (let i = 0; i < stepsCount - 1; i++) { + await expect(this.tourPopover).toBeVisible(); + await this.clickTourNext(); + } + + // Verify final step has "Finish" button + const buttonText = await this.getTourNextButtonText(); + if (buttonText.toLowerCase() !== 'finish') { + throw new Error(`Expected "Finish" button, got "${buttonText}"`); + } + + // Click finish + await this.clickTourNext(); + + // Verify tour is closed + await expect(this.tourPopover).not.toBeVisible({ timeout: 5000 }); + } + + // ================== + // Cleanup + // ================== + + async deleteAllTodos(): Promise { + // Delete active tasks + let todoItems = await this.getTodoItems(); + + while (todoItems.length > 0) { + try { + await this.deleteTodo(todoItems[0]); + } catch (err) { + console.warn('[Cleanup] Failed to delete todo:', err); + break; + } + todoItems = await this.getTodoItems(); + } + + // Delete completed tasks + await this.openCompletedTasks(); + + let completedItems = await this.getCompletedItems(); + while (completedItems.length > 0) { + try { + const item = completedItems[0]; + await item.hover(); + const trash = item.locator('.prpl-suggested-task-points-wrapper .trash'); + await trash.waitFor({ state: 'visible', timeout: 3000 }); + await trash.click(); + await this.page.waitForResponse((r) => r.url().includes('/progress-planner/v1/')); + } catch (err) { + console.warn('[Cleanup] Failed to delete completed todo:', err); + break; + } + completedItems = await this.getCompletedItems(); + } + } +} diff --git a/tests/e2e/pages/index.ts b/tests/e2e/pages/index.ts new file mode 100644 index 0000000000..059113cdcd --- /dev/null +++ b/tests/e2e/pages/index.ts @@ -0,0 +1,3 @@ +export { BasePage } from './base.page'; +export { DashboardPage } from './dashboard.page'; +export { YoastSettingsPage } from './yoast-settings.page'; diff --git a/tests/e2e/pages/yoast-settings.page.ts b/tests/e2e/pages/yoast-settings.page.ts new file mode 100644 index 0000000000..4900d07000 --- /dev/null +++ b/tests/e2e/pages/yoast-settings.page.ts @@ -0,0 +1,156 @@ +import { Page, Locator, expect } from '@playwright/test'; +import { BasePage } from './base.page'; + +/** + * Selectors for Yoast SEO settings pages. + */ +const SELECTORS = { + // Modal + modalCloseButton: 'button.yst-modal__close-button', + + // Ravi icon elements (Progress Planner integration) + raviIconWrapper: '[data-prpl-element="ravi-icon"]', + raviIconImage: '[data-prpl-element="ravi-icon"] img', + raviIconPoints: '.prpl-form-row-points', + + // Crawl optimization page + feedCommentsToggle: 'button[data-id="input-wpseo-remove_feed_global_comments"]', + toggleFieldHeader: '.yst-toggle-field__header', + + // Site representation page + companyLogoFieldset: '#wpseo_titles-company_logo', + companyLogoLabel: '#wpseo_titles-company_logo legend.yst-label', +} as const; + +export class YoastSettingsPage extends BasePage { + constructor(page: Page) { + super(page); + } + + async goto(): Promise { + // Default to crawl optimization page + await this.gotoCrawlOptimization(); + } + + async gotoCrawlOptimization(): Promise { + await this.page.goto('/wp-admin/admin.php?page=wpseo_page_settings#/crawl-optimization'); + await this.waitForReady(); + } + + async gotoSiteRepresentation(): Promise { + await this.page.goto('/wp-admin/admin.php?page=wpseo_page_settings#/site-representation'); + await this.waitForReady(); + } + + override async waitForReady(): Promise { + await this.page.waitForLoadState('networkidle'); + + // Dismiss any modal that might be blocking + await this.dismissModal(); + } + + /** + * Dismiss the Yoast modal if it's visible. + */ + async dismissModal(): Promise { + const closeButton = this.page.locator(SELECTORS.modalCloseButton); + + try { + // Short timeout - modal may or may not exist + if (await closeButton.isVisible({ timeout: 2000 })) { + await closeButton.click(); + await closeButton.waitFor({ state: 'hidden', timeout: 3000 }); + } + } catch { + // Modal not present, that's fine + } + } + + // ================== + // Feed Comments Toggle (Crawl Optimization) + // ================== + + async getFeedCommentsToggle(): Promise { + const toggle = this.page.locator(SELECTORS.feedCommentsToggle); + await toggle.waitFor({ state: 'visible' }); + return toggle; + } + + async getFeedCommentsToggleHeader(): Promise { + const toggle = await this.getFeedCommentsToggle(); + return toggle.locator('xpath=ancestor::div[contains(@class, "yst-toggle-field__header")]'); + } + + async clickFeedCommentsToggle(): Promise { + const toggle = await this.getFeedCommentsToggle(); + await toggle.click(); + } + + // ================== + // Company Logo (Site Representation) + // ================== + + async getCompanyLogoLabel(): Promise { + const label = this.page.locator(SELECTORS.companyLogoLabel); + await label.waitFor({ state: 'visible' }); + return label; + } + + // ================== + // Ravi Icon Helpers + // ================== + + /** + * Get the Ravi icon within a parent element. + */ + getRaviIcon(parent: Locator): Locator { + return parent.locator(SELECTORS.raviIconWrapper); + } + + /** + * Get the Ravi icon image within a parent element. + */ + getRaviIconImage(parent: Locator): Locator { + return parent.locator(SELECTORS.raviIconImage); + } + + /** + * Get the points text from a Ravi icon. + */ + async getRaviIconPoints(parent: Locator): Promise { + const points = parent.locator(SELECTORS.raviIconPoints); + return await points.textContent() ?? ''; + } + + /** + * Verify a Ravi icon exists and has correct attributes. + */ + async verifyRaviIcon(parent: Locator): Promise { + const raviIcon = this.getRaviIcon(parent); + await expect(raviIcon).toBeVisible(); + + const iconImg = this.getRaviIconImage(parent); + await expect(iconImg).toBeVisible(); + await expect(iconImg).toHaveAttribute('alt', 'Ravi'); + await expect(iconImg).toHaveAttribute('width', '16'); + await expect(iconImg).toHaveAttribute('height', '16'); + } + + /** + * Verify the Ravi icon shows uncompleted state (+N points). + */ + async verifyRaviIconUncompleted(parent: Locator): Promise { + const raviIcon = this.getRaviIcon(parent); + const points = raviIcon.locator(SELECTORS.raviIconPoints); + await expect(points).toHaveText('+1'); + } + + /** + * Verify the Ravi icon shows completed state (checkmark). + */ + async verifyRaviIconCompleted(parent: Locator): Promise { + const raviIcon = this.getRaviIcon(parent); + const points = raviIcon.locator(SELECTORS.raviIconPoints); + await expect(points).toHaveText('✓'); + } +} diff --git a/tests/e2e/sequential.spec.js b/tests/e2e/sequential.spec.js deleted file mode 100644 index baf15c22a7..0000000000 --- a/tests/e2e/sequential.spec.js +++ /dev/null @@ -1,14 +0,0 @@ -const { test } = require( '@playwright/test' ); -const onboardingTests = require( './sequential/onboarding.spec' ); -const taglineTests = require( './sequential/task-tagline.spec' ); -const todoTests = require( './sequential/todo.spec' ); -const todoReorderTests = require( './sequential/todo-reorder.spec' ); -const todoCompleteTests = require( './sequential/todo-complete.spec' ); - -test.describe( 'Sequential Tests', () => { - onboardingTests( test ); - taglineTests( test ); - todoTests( test ); - todoReorderTests( test ); - todoCompleteTests( test ); -} ); diff --git a/tests/e2e/sequential/onboarding.spec.js b/tests/e2e/sequential/onboarding.spec.js deleted file mode 100644 index 32c0af537f..0000000000 --- a/tests/e2e/sequential/onboarding.spec.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * External dependencies - */ -import { test, expect } from '@playwright/test'; - -function onboardingTests( testContext = test ) { - testContext.describe( 'Progress Planner Onboarding', () => { - testContext( - 'should complete onboarding process successfully', - async ( { page } ) => { - // Navigate to Progress Planner page - await page.goto( '/wp-admin/admin.php?page=progress-planner' ); - await page.waitForLoadState( 'networkidle' ); - - // Wait for the onboarding popover to be visible - const popover = page.locator( '#prpl-popover-onboarding' ); - await expect( popover ).toBeVisible( { timeout: 10000 } ); - - // Click on the privacy policy checkbox label (clicking label triggers the checkbox) - const privacyLabel = page.locator( - 'label[for="prpl-privacy-checkbox"]' - ); - await expect( privacyLabel ).toBeVisible(); - await privacyLabel.click(); - - // Click "Start onboarding" button to accept privacy and proceed - const startButton = popover.locator( '.prpl-tour-next' ); - await expect( startButton ).toBeVisible(); - await startButton.click(); - - // Wait for step to advance (license generation happens in background) - // We should now be on step 1 or later - await expect( popover ).toHaveAttribute( - 'data-prpl-step', - /^[1-9]/, - { timeout: 15000 } - ); - - // Click the close button to exit onboarding - const closeButton = page.locator( '#prpl-tour-close-btn' ); - await closeButton.click(); - - // Verify onboarding popover is closed/hidden - await expect( popover ).toBeHidden( { timeout: 5000 } ); - - // Visit the WP Dashboard page and back to the Progress Planner page - await page.goto( '/wp-admin/' ); - await page.goto( '/wp-admin/admin.php?page=progress-planner' ); - await page.waitForLoadState( 'networkidle' ); - - // Verify onboarding doesn't auto-start (progress was saved) - // The popover element exists but should be hidden (not auto-opened) - await expect( popover ).toBeHidden( { timeout: 5000 } ); - } - ); - } ); -} - -module.exports = onboardingTests; diff --git a/tests/e2e/sequential/task-tagline.spec.js b/tests/e2e/sequential/task-tagline.spec.js deleted file mode 100644 index 10b2005f47..0000000000 --- a/tests/e2e/sequential/task-tagline.spec.js +++ /dev/null @@ -1,113 +0,0 @@ -const { test, expect } = require( '@playwright/test' ); -const { makeAuthenticatedRequest } = require( '../utils' ); - -function taglineTests( testContext = test ) { - testContext.describe( 'PRPL Complete Task', () => { - testContext( - 'Complete blog description task', - async ( { page, request } ) => { - // First, navigate to Progress Planner dashboard (to init everything) - await page.goto( - `${ process.env.WORDPRESS_URL }/wp-admin/admin.php?page=progress-planner` - ); - await page.waitForLoadState( 'networkidle' ); - - // Get initial tasks - const response = await makeAuthenticatedRequest( - page, - request, - `${ process.env.WORDPRESS_URL }/?rest_route=/progress-planner/v1/tasks` - ); - const initialTasks = await response.json(); - - // Find the blog description task - const blogDescriptionTask = initialTasks.find( - ( task ) => task.post_name === 'core-blogdescription' - ); - expect( blogDescriptionTask ).toBeDefined(); - expect( blogDescriptionTask.post_status ).toBe( 'publish' ); - - // Navigate to WordPress settings - await page.goto( - `${ process.env.WORDPRESS_URL }/wp-admin/options-general.php` - ); - await page.waitForLoadState( 'networkidle' ); - - // Fill in the tagline - await page.fill( - '#blogdescription', - 'My Awesome Site Description' - ); - - // Save changes - await page.click( '#submit' ); - await page.waitForLoadState( 'networkidle' ); - - // Wait a moment for the task status to update - await page.waitForTimeout( 1000 ); - - // Check the task status again via REST API - const finalResponse = await makeAuthenticatedRequest( - page, - request, - `${ process.env.WORDPRESS_URL }/?rest_route=/progress-planner/v1/tasks` - ); - const finalTasks = await finalResponse.json(); - - // Find the blog description task again - const updatedTask = finalTasks.find( ( task ) => - task.post_name.startsWith( 'core-blogdescription' ) - ); - expect( updatedTask ).toBeDefined(); - expect( updatedTask.post_status ).toBe( 'pending' ); - - // Go to Progress Planner dashboard - await page.goto( - `${ process.env.WORDPRESS_URL }/wp-admin/admin.php?page=progress-planner` - ); - await page.waitForLoadState( 'networkidle' ); - - // Wait for the widget container to be visible first - const widgetContainer = page.locator( - '.prpl-widget-wrapper.prpl-suggested-tasks' - ); - await expect( widgetContainer ).toBeVisible(); - - // Then wait for the tasks to be loaded in the widget - const tasksList = page.locator( - '.prpl-widget-wrapper.prpl-suggested-tasks .prpl-suggested-tasks-list' - ); - await expect( tasksList ).toBeVisible(); - - // Wait for the specific task to appear and verify its content - const taskElement = page.locator( - `li[data-task-id="core-blogdescription"]` - ); - await expect( taskElement ).toBeVisible(); - - // Wait for the celebration animation and task removal (3s delay + 1s buffer) - await page.waitForTimeout( 4000 ); - - // Verify that the task is removed from the DOM - await expect( taskElement ).toHaveCount( 0 ); - - // Check the final task status via REST API - const completedResponse = await makeAuthenticatedRequest( - page, - request, - `${ process.env.WORDPRESS_URL }/?rest_route=/progress-planner/v1/tasks` - ); - const completedTasks = await completedResponse.json(); - - // Find the blog description task one last time - const completedTask = completedTasks.find( ( task ) => - task.post_name.startsWith( 'core-blogdescription' ) - ); - expect( completedTask ).toBeDefined(); - expect( completedTask.post_status ).toBe( 'trash' ); - } - ); - } ); -} - -module.exports = taglineTests; diff --git a/tests/e2e/sequential/todo-complete.spec.js b/tests/e2e/sequential/todo-complete.spec.js deleted file mode 100644 index 122c639914..0000000000 --- a/tests/e2e/sequential/todo-complete.spec.js +++ /dev/null @@ -1,137 +0,0 @@ -const { test, expect, chromium } = require( '@playwright/test' ); -const SELECTORS = require( '../constants/selectors' ); -const { cleanUpPlannerTasks } = require( '../helpers/cleanup' ); - -const TEST_TASK_TEXT = 'Task to be completed'; - -let browser; -let context; -let page; -let taskSelector; - -function todoCompleteTests( testContext = test ) { - testContext.describe( 'Complete User Task', () => { - testContext.beforeAll( async () => { - browser = await chromium.launch(); - } ); - - testContext.beforeEach( async () => { - context = await browser.newContext(); - page = await context.newPage(); - } ); - - testContext.afterEach( async () => { - await cleanUpPlannerTasks( { - page, - context, - baseUrl: process.env.WORDPRESS_URL, - } ); - } ); - - testContext.afterAll( async () => { - await browser.close(); - } ); - - testContext( 'Create task and mark as completed', async () => { - // Navigate and create the task - await page.goto( - `${ process.env.WORDPRESS_URL }/wp-admin/admin.php?page=progress-planner` - ); - await page.waitForLoadState( 'networkidle' ); - - await page.fill( '#new-todo-content', TEST_TASK_TEXT ); - await page.keyboard.press( 'Enter' ); - await page.waitForTimeout( 1500 ); - - // Get the task selector - const todoItem = page.locator( SELECTORS.TODO_ITEM ); - const taskId = await todoItem.getAttribute( 'data-task-id' ); - taskSelector = `li[data-task-id="${ taskId }"]`; - - // Complete the task - const todoItemElement = page.locator( - `${ SELECTORS.TODO_LIST } ${ taskSelector }` - ); - await todoItemElement.locator( 'label' ).click(); - await page.waitForTimeout( 1000 ); - - // Verify task is not in active list - await expect( - page.locator( `${ SELECTORS.TODO_LIST } ${ taskSelector }` ) - ).toHaveCount( 0 ); - - // Open completed tasks - await page.locator( 'details#todo-list-completed-details' ).click(); - - // Verify task is still in completed list with correct state - const completedTask = page.locator( - `${ SELECTORS.TODO_LIST_COMPLETED } ${ taskSelector }` - ); - await expect( completedTask ).toBeVisible(); - await expect( completedTask.locator( 'h3 > span' ) ).toHaveText( - TEST_TASK_TEXT - ); - await expect( - completedTask.locator( SELECTORS.RR_ITEM_TEXT ) - ).toHaveText( TEST_TASK_TEXT ); - await expect( - completedTask.locator( '.prpl-suggested-task-checkbox' ) - ).toBeChecked(); - } ); - - testContext( - 'Verify completed task persists after reload', - async () => { - // Navigate to Progress Planner dashboard - await page.goto( - `${ process.env.WORDPRESS_URL }/wp-admin/admin.php?page=progress-planner` - ); - await page.waitForLoadState( 'networkidle' ); - - // Create a new task - await page.fill( '#new-todo-content', TEST_TASK_TEXT ); - await page.keyboard.press( 'Enter' ); - await page.waitForTimeout( 1500 ); - - // Get the task selector - const todoItem = page.locator( SELECTORS.TODO_ITEM ); - const taskId = await todoItem.getAttribute( 'data-task-id' ); - taskSelector = `li[data-task-id="${ taskId }"]`; - - // Complete the task - const todoItemElement = page.locator( - `${ SELECTORS.TODO_LIST } ${ taskSelector }` - ); - await todoItemElement.locator( 'label' ).click(); - await page.waitForTimeout( 1500 ); - - // Verify task is not in active list - await expect( - page.locator( `${ SELECTORS.TODO_LIST } ${ taskSelector }` ) - ).toHaveCount( 0 ); - - // Open completed tasks - await page - .locator( 'details#todo-list-completed-details' ) - .click(); - - // Verify task is still in completed list with correct state - const completedTask = page.locator( - `${ SELECTORS.TODO_LIST_COMPLETED } ${ taskSelector }` - ); - await expect( completedTask ).toBeVisible(); - await expect( completedTask.locator( 'h3 > span' ) ).toHaveText( - TEST_TASK_TEXT - ); - await expect( - completedTask.locator( SELECTORS.RR_ITEM_TEXT ) - ).toHaveText( TEST_TASK_TEXT ); - await expect( - completedTask.locator( '.prpl-suggested-task-checkbox' ) - ).toBeChecked(); - } - ); - } ); -} - -module.exports = todoCompleteTests; diff --git a/tests/e2e/sequential/todo-reorder.spec.js b/tests/e2e/sequential/todo-reorder.spec.js deleted file mode 100644 index 2495555f87..0000000000 --- a/tests/e2e/sequential/todo-reorder.spec.js +++ /dev/null @@ -1,111 +0,0 @@ -const { test, expect, chromium } = require( '@playwright/test' ); -const SELECTORS = require( '../constants/selectors' ); -const { cleanUpPlannerTasks } = require( '../helpers/cleanup' ); - -const FIRST_TASK_TEXT = 'First task to reorder'; -const SECOND_TASK_TEXT = 'Second task to reorder'; -const THIRD_TASK_TEXT = 'Third task to reorder'; - -let browser; -let context; -let page; - -function todoReorderTests( testContext = test ) { - testContext.describe( 'PRPL Todo Reorder', () => { - testContext.beforeAll( async () => { - browser = await chromium.launch(); - } ); - - testContext.beforeEach( async () => { - context = await browser.newContext(); - page = await context.newPage(); - } ); - - testContext.afterEach( async () => { - await cleanUpPlannerTasks( { - page, - context, - baseUrl: process.env.WORDPRESS_URL, - } ); - } ); - - testContext.afterAll( async () => { - await browser.close(); - } ); - - testContext( 'Reorder todo items', async () => { - // Navigate to Progress Planner dashboard - await page.goto( - `${ process.env.WORDPRESS_URL }/wp-admin/admin.php?page=progress-planner` - ); - await page.waitForLoadState( 'networkidle' ); - - // Create first task - await page.fill( '#new-todo-content', FIRST_TASK_TEXT ); - await page.keyboard.press( 'Enter' ); - await page.waitForTimeout( 1500 ); - - // Create second task - await page.fill( '#new-todo-content', SECOND_TASK_TEXT ); - await page.keyboard.press( 'Enter' ); - await page.waitForTimeout( 1500 ); - - // Create third task - await page.fill( '#new-todo-content', THIRD_TASK_TEXT ); - await page.keyboard.press( 'Enter' ); - await page.waitForTimeout( 1500 ); - - // Get all todo items - const todoItems = page.locator( SELECTORS.TODO_ITEM ); - - // Verify initial order - const items = await todoItems.all(); - await expect( - items[ 0 ].locator( SELECTORS.RR_ITEM_TEXT ) - ).toHaveText( FIRST_TASK_TEXT ); - await expect( - items[ 1 ].locator( SELECTORS.RR_ITEM_TEXT ) - ).toHaveText( SECOND_TASK_TEXT ); - await expect( - items[ 2 ].locator( SELECTORS.RR_ITEM_TEXT ) - ).toHaveText( THIRD_TASK_TEXT ); - - // Hover over second item and click move down button - await items[ 1 ].hover(); - await items[ 1 ] - .locator( '.prpl-suggested-task-button.move-down' ) - .click(); - await page.waitForTimeout( 1500 ); - - // Verify new order - const reorderedItems = await todoItems.all(); - await expect( - reorderedItems[ 0 ].locator( SELECTORS.RR_ITEM_TEXT ) - ).toHaveText( FIRST_TASK_TEXT ); - await expect( - reorderedItems[ 1 ].locator( SELECTORS.RR_ITEM_TEXT ) - ).toHaveText( THIRD_TASK_TEXT ); - await expect( - reorderedItems[ 2 ].locator( SELECTORS.RR_ITEM_TEXT ) - ).toHaveText( SECOND_TASK_TEXT ); - - // Reload page - await page.reload(); - await page.waitForLoadState( 'networkidle' ); - - // Verify order persists after reload - const persistedItems = await todoItems.all(); - await expect( - persistedItems[ 0 ].locator( SELECTORS.RR_ITEM_TEXT ) - ).toHaveText( FIRST_TASK_TEXT ); - await expect( - persistedItems[ 1 ].locator( SELECTORS.RR_ITEM_TEXT ) - ).toHaveText( THIRD_TASK_TEXT ); - await expect( - persistedItems[ 2 ].locator( SELECTORS.RR_ITEM_TEXT ) - ).toHaveText( SECOND_TASK_TEXT ); - } ); - } ); -} - -module.exports = todoReorderTests; diff --git a/tests/e2e/sequential/todo.spec.js b/tests/e2e/sequential/todo.spec.js deleted file mode 100644 index e05a14a46d..0000000000 --- a/tests/e2e/sequential/todo.spec.js +++ /dev/null @@ -1,83 +0,0 @@ -const { test, expect, chromium } = require( '@playwright/test' ); -const SELECTORS = require( '../constants/selectors' ); -const { cleanUpPlannerTasks } = require( '../helpers/cleanup' ); - -const CREATE_TASK_TEXT = 'Test task to create'; -const DELETE_TASK_TEXT = 'Test task to delete'; - -let browser; -let context; -let page; - -function todoTests( testContext = test ) { - testContext.describe( 'PRPL Create and Delete Todo', () => { - testContext.beforeAll( async () => { - browser = await chromium.launch(); - } ); - - testContext.beforeEach( async () => { - context = await browser.newContext(); - page = await context.newPage(); - } ); - - testContext.afterEach( async () => { - await cleanUpPlannerTasks( { - page, - context, - baseUrl: process.env.WORDPRESS_URL, - } ); - } ); - - testContext.afterAll( async () => { - await browser.close(); - } ); - - testContext( 'Create a new todo', async () => { - // Navigate to Progress Planner dashboard - await page.goto( - `${ process.env.WORDPRESS_URL }/wp-admin/admin.php?page=progress-planner` - ); - await page.waitForLoadState( 'networkidle' ); - - // Fill in the new todo input - await page.fill( '#new-todo-content', CREATE_TASK_TEXT ); - await page.keyboard.press( 'Enter' ); - await page.waitForTimeout( 500 ); - - // Verify the todo was created - const todoItem = page.locator( SELECTORS.TODO_ITEM ); - await expect( todoItem ).toHaveCount( 1 ); - await expect( - todoItem.locator( SELECTORS.RR_ITEM_TEXT ) - ).toHaveText( CREATE_TASK_TEXT ); - } ); - - testContext( 'Delete a todo', async () => { - // Navigate to Progress Planner dashboard - await page.goto( - `${ process.env.WORDPRESS_URL }/wp-admin/admin.php?page=progress-planner` - ); - await page.waitForLoadState( 'networkidle' ); - - // Create a todo to delete - await page.fill( '#new-todo-content', DELETE_TASK_TEXT ); - await page.keyboard.press( 'Enter' ); - await page.waitForTimeout( 500 ); - - // Wait for the delete button to be visible and click it - const deleteItem = page.locator( SELECTORS.TODO_ITEM ); - await deleteItem.hover(); - await deleteItem.waitFor( { state: 'visible' } ); - await deleteItem - .locator( '.prpl-suggested-task-actions-wrapper .trash' ) - .click(); - await page.waitForTimeout( 1500 ); - - // Verify the todo was deleted - const todoItem = page.locator( SELECTORS.TODO_ITEM ); - await expect( todoItem ).toHaveCount( 0 ); - } ); - } ); -} - -module.exports = todoTests; diff --git a/tests/e2e/specs/onboarding.spec.ts b/tests/e2e/specs/onboarding.spec.ts new file mode 100644 index 0000000000..3e389b1a35 --- /dev/null +++ b/tests/e2e/specs/onboarding.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '../fixtures/base.fixture'; + +test.describe('Progress Planner Onboarding', () => { + // This test needs a fresh state, so we use a separate storage state + test.use({ storageState: { cookies: [], origins: [] } }); + + test('should complete onboarding process successfully', async ({ page }) => { + // Login manually since we cleared storage state + await test.step('Login to WordPress', async () => { + await page.goto('/wp-login.php'); + await page.fill('#user_login', process.env.WORDPRESS_ADMIN_USER || 'admin'); + await page.fill('#user_pass', process.env.WORDPRESS_ADMIN_PASSWORD || 'password'); + await page.click('#wp-submit'); + await page.waitForURL('**/wp-admin/**'); + }); + + const popover = page.locator('#prpl-popover-onboarding'); + + await test.step('Navigate to Progress Planner', async () => { + await page.goto('/wp-admin/admin.php?page=progress-planner'); + await page.waitForLoadState('networkidle'); + + await expect(popover).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Accept privacy policy', async () => { + const privacyLabel = page.locator('label[for="prpl-privacy-checkbox"]'); + await expect(privacyLabel).toBeVisible(); + await privacyLabel.click(); + }); + + await test.step('Start onboarding', async () => { + const startButton = popover.locator('.prpl-tour-next'); + await expect(startButton).toBeVisible(); + await startButton.click(); + + // Wait for step to advance (license generation happens in background) + await expect(popover).toHaveAttribute('data-prpl-step', /^[1-9]/, { + timeout: 15000, + }); + }); + + await test.step('Close onboarding', async () => { + const closeButton = page.locator('#prpl-tour-close-btn'); + await closeButton.click(); + + await expect(popover).toBeHidden({ timeout: 5000 }); + }); + + await test.step('Verify onboarding does not restart on revisit', async () => { + await page.goto('/wp-admin/'); + await page.goto('/wp-admin/admin.php?page=progress-planner'); + await page.waitForLoadState('networkidle'); + + // Popover should remain hidden + await expect(popover).toBeHidden({ timeout: 5000 }); + }); + }); +}); diff --git a/tests/e2e/specs/task-dismissible.spec.ts b/tests/e2e/specs/task-dismissible.spec.ts new file mode 100644 index 0000000000..8083dd54f1 --- /dev/null +++ b/tests/e2e/specs/task-dismissible.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '../fixtures/base.fixture'; + +test.describe('Dismissible Tasks', () => { + test('should complete dismissible task if present', async ({ dashboard, tasksApi }) => { + await test.step('Check for available suggested tasks', async () => { + const initialCount = await dashboard.getSuggestedTasksCount(); + + if (initialCount === 0) { + test.skip(); + return; + } + }); + + let taskId: string | null; + let previousCount: number; + + await test.step('Complete a suggested task', async () => { + const result = await dashboard.completeSuggestedTask(); + taskId = result.taskId; + previousCount = result.previousCount; + + if (taskId === null) { + test.skip(); + return; + } + }); + + await test.step('Verify task count decreased', async () => { + const finalCount = await dashboard.getSuggestedTasksCount(); + expect(finalCount).toBe(previousCount - 1); + }); + + await test.step('Verify task is marked as completed via API', async () => { + if (taskId) { + await tasksApi.expectTaskStatus(taskId, 'trash'); + } + }); + }); +}); diff --git a/tests/e2e/specs/task-snooze.spec.ts b/tests/e2e/specs/task-snooze.spec.ts new file mode 100644 index 0000000000..58e31acbce --- /dev/null +++ b/tests/e2e/specs/task-snooze.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '../fixtures/base.fixture'; + +test.describe('Task Snooze', () => { + // Enable cleanup for this test suite + test.use({ cleanupAfterTest: true }); + + test('should snooze a task for 1 day', async ({ dashboard, tasksApi }) => { + const taskText = 'Task to snooze ' + Date.now(); + + let taskId: string; + + await test.step('Create a todo', async () => { + const result = await dashboard.createTodo(taskText); + taskId = result.taskId; + }); + + await test.step('Snooze the task for 1 day', async () => { + await dashboard.snoozeTask(taskId, '1-day'); + }); + + await test.step('Verify task is snoozed via API', async () => { + // Snoozed tasks have a 'future' status + await tasksApi.waitForTaskStatus(taskId, 'future', { timeout: 5000 }); + }); + + await test.step('Verify task is no longer in active list', async () => { + const items = await dashboard.getTodoItems(); + const taskStillVisible = await Promise.all( + items.map(async (item) => { + const text = await dashboard.getTodoText(item); + return text === taskText; + }) + ); + expect(taskStillVisible.every((visible) => !visible)).toBe(true); + }); + }); + + test('should snooze a task for 1 week', async ({ dashboard, tasksApi }) => { + const taskText = 'Week snooze task ' + Date.now(); + + let taskId: string; + + await test.step('Create a todo', async () => { + const result = await dashboard.createTodo(taskText); + taskId = result.taskId; + }); + + await test.step('Snooze the task for 1 week', async () => { + await dashboard.snoozeTask(taskId, '1-week'); + }); + + await test.step('Verify task is snoozed via API', async () => { + await tasksApi.waitForTaskStatus(taskId, 'future', { timeout: 5000 }); + }); + }); +}); diff --git a/tests/e2e/specs/task-tagline.spec.ts b/tests/e2e/specs/task-tagline.spec.ts new file mode 100644 index 0000000000..f34a30186b --- /dev/null +++ b/tests/e2e/specs/task-tagline.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '../fixtures/base.fixture'; + +test.describe('Task Tagline Completion', () => { + test('should complete blog description task when tagline is set', async ({ page, tasksApi }) => { + await test.step('Navigate to Progress Planner dashboard', async () => { + await page.goto('/wp-admin/admin.php?page=progress-planner'); + await page.waitForLoadState('networkidle'); + }); + + await test.step('Verify blog description task exists and is active', async () => { + const task = await tasksApi.getTask('core-blogdescription'); + expect(task).toBeDefined(); + expect(task?.post_status).toBe('publish'); + }); + + await test.step('Navigate to WordPress settings and set tagline', async () => { + await page.goto('/wp-admin/options-general.php'); + await page.waitForLoadState('networkidle'); + + await page.fill('#blogdescription', 'My Awesome Site Description'); + await page.click('#submit'); + await page.waitForLoadState('networkidle'); + }); + + await test.step('Verify task status changed to pending', async () => { + await tasksApi.waitForTaskStatus('core-blogdescription', 'pending', { timeout: 5000 }); + }); + + await test.step('Navigate to dashboard and verify task completion animation', async () => { + await page.goto('/wp-admin/admin.php?page=progress-planner'); + await page.waitForLoadState('networkidle'); + + // Wait for widget to be visible + const widgetContainer = page.locator('.prpl-widget-wrapper.prpl-suggested-tasks'); + await expect(widgetContainer).toBeVisible(); + + // Wait for task element to appear + const taskElement = page.locator('li[data-task-id="core-blogdescription"]'); + + // If task is visible, wait for the celebration animation + if (await taskElement.isVisible()) { + // Wait for animation and task removal + await expect(taskElement).toHaveCount(0, { timeout: 5000 }); + } + }); + + await test.step('Verify task is completed via API', async () => { + await tasksApi.expectTaskStatus('core-blogdescription', 'trash'); + }); + }); +}); diff --git a/tests/e2e/specs/todo-complete.spec.ts b/tests/e2e/specs/todo-complete.spec.ts new file mode 100644 index 0000000000..0a27e5f43d --- /dev/null +++ b/tests/e2e/specs/todo-complete.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '../fixtures/base.fixture'; + +test.describe('Todo Completion', () => { + // Enable cleanup for this test suite + test.use({ cleanupAfterTest: true }); + + test('should complete a todo and move it to completed list', async ({ dashboard }) => { + const taskText = 'Task to complete ' + Date.now(); + + await test.step('Create a todo', async () => { + await dashboard.createTodo(taskText); + const items = await dashboard.getTodoItems(); + expect(items).toHaveLength(1); + }); + + await test.step('Complete the todo', async () => { + const item = await dashboard.getTodoByText(taskText); + await dashboard.completeTodo(item); + }); + + await test.step('Verify todo moved to completed list', async () => { + // Active list should be empty + const activeItems = await dashboard.getTodoItems(); + expect(activeItems).toHaveLength(0); + + // Completed list should have the task + await dashboard.openCompletedTasks(); + const completedItems = await dashboard.getCompletedItems(); + expect(completedItems.length).toBeGreaterThan(0); + }); + }); + + test('should verify task status via API after completion', async ({ dashboard, tasksApi }) => { + const taskText = 'API verified task ' + Date.now(); + + let taskId: string; + + await test.step('Create a todo', async () => { + const result = await dashboard.createTodo(taskText); + taskId = result.taskId; + }); + + await test.step('Complete the todo', async () => { + const item = await dashboard.getTodoByText(taskText); + await dashboard.completeTodo(item); + }); + + await test.step('Verify task status via API', async () => { + await tasksApi.expectTaskStatus(taskId, 'trash'); + }); + }); + + test('should complete suggested task and decrease count', async ({ dashboard }) => { + await test.step('Complete a suggested task', async () => { + const { taskId, previousCount } = await dashboard.completeSuggestedTask(); + + if (taskId === null) { + test.skip(); + return; + } + + // Wait for the task count to decrease + const newCount = await dashboard.getSuggestedTasksCount(); + expect(newCount).toBeLessThan(previousCount); + }); + }); +}); diff --git a/tests/e2e/specs/todo-crud.spec.ts b/tests/e2e/specs/todo-crud.spec.ts new file mode 100644 index 0000000000..5d0772e835 --- /dev/null +++ b/tests/e2e/specs/todo-crud.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '../fixtures/base.fixture'; + +test.describe('Todo CRUD Operations', () => { + // Enable cleanup for this test suite + test.use({ cleanupAfterTest: true }); + + test('should create a new todo', async ({ dashboard }) => { + const taskText = 'Test task created at ' + Date.now(); + + await test.step('Create the todo', async () => { + const { taskId, element } = await dashboard.createTodo(taskText); + + expect(taskId).toBeTruthy(); + await expect(element).toBeVisible(); + }); + + await test.step('Verify todo text is correct', async () => { + const items = await dashboard.getTodoItems(); + expect(items).toHaveLength(1); + + const text = await dashboard.getTodoText(items[0]); + expect(text).toBe(taskText); + }); + }); + + test('should delete a todo', async ({ dashboard }) => { + const taskText = 'Task to be deleted'; + + await test.step('Create a todo to delete', async () => { + await dashboard.createTodo(taskText); + const items = await dashboard.getTodoItems(); + expect(items).toHaveLength(1); + }); + + await test.step('Delete the todo', async () => { + const item = await dashboard.getTodoByText(taskText); + await dashboard.deleteTodo(item); + }); + + await test.step('Verify todo was deleted', async () => { + const items = await dashboard.getTodoItems(); + expect(items).toHaveLength(0); + }); + }); + + test('should persist todo after page reload', async ({ dashboard, page }) => { + const taskText = 'Persistent task ' + Date.now(); + + await test.step('Create a todo', async () => { + await dashboard.createTodo(taskText); + }); + + await test.step('Reload the page', async () => { + await page.reload(); + await dashboard.waitForReady(); + }); + + await test.step('Verify todo still exists', async () => { + const items = await dashboard.getTodoItems(); + expect(items).toHaveLength(1); + + const text = await dashboard.getTodoText(items[0]); + expect(text).toBe(taskText); + }); + }); +}); diff --git a/tests/e2e/specs/todo-reorder.spec.ts b/tests/e2e/specs/todo-reorder.spec.ts new file mode 100644 index 0000000000..2d48ffc970 --- /dev/null +++ b/tests/e2e/specs/todo-reorder.spec.ts @@ -0,0 +1,91 @@ +import { test, expect } from '../fixtures/base.fixture'; + +test.describe('Todo Reorder Operations', () => { + // Enable cleanup for this test suite + test.use({ cleanupAfterTest: true }); + + test('should reorder todos using move down button', async ({ dashboard }) => { + await test.step('Create two todos', async () => { + await dashboard.createTodo('First task'); + await dashboard.createTodo('Second task'); + + const items = await dashboard.getTodoItems(); + expect(items).toHaveLength(2); + }); + + await test.step('Move first task down', async () => { + const items = await dashboard.getTodoItems(); + const firstItem = items[0]; + + await dashboard.moveTodoDown(firstItem); + }); + + await test.step('Verify order changed', async () => { + const items = await dashboard.getTodoItems(); + expect(items).toHaveLength(2); + + // After moving down, "Second task" should be first + const firstText = await dashboard.getTodoText(items[0]); + const secondText = await dashboard.getTodoText(items[1]); + + expect(firstText).toBe('Second task'); + expect(secondText).toBe('First task'); + }); + }); + + test('should reorder todos using move up button', async ({ dashboard }) => { + await test.step('Create two todos', async () => { + await dashboard.createTodo('First task'); + await dashboard.createTodo('Second task'); + + const items = await dashboard.getTodoItems(); + expect(items).toHaveLength(2); + }); + + await test.step('Move second task up', async () => { + const items = await dashboard.getTodoItems(); + const secondItem = items[1]; + + await dashboard.moveTodoUp(secondItem); + }); + + await test.step('Verify order changed', async () => { + const items = await dashboard.getTodoItems(); + expect(items).toHaveLength(2); + + // After moving up, "First task" should be second + const firstText = await dashboard.getTodoText(items[0]); + const secondText = await dashboard.getTodoText(items[1]); + + expect(firstText).toBe('First task'); + expect(secondText).toBe('Second task'); + }); + }); + + test('should persist order after page reload', async ({ dashboard, page }) => { + await test.step('Create and reorder todos', async () => { + await dashboard.createTodo('Task A'); + await dashboard.createTodo('Task B'); + await dashboard.createTodo('Task C'); + + // Move Task C up twice to make it first + let items = await dashboard.getTodoItems(); + await dashboard.moveTodoUp(items[2]); // C is now second + items = await dashboard.getTodoItems(); + await dashboard.moveTodoUp(items[1]); // C is now first + }); + + await test.step('Reload the page', async () => { + await page.reload(); + await dashboard.waitForReady(); + }); + + await test.step('Verify order persisted', async () => { + const items = await dashboard.getTodoItems(); + expect(items).toHaveLength(3); + + const firstText = await dashboard.getTodoText(items[0]); + expect(firstText).toBe('Task C'); + }); + }); +}); diff --git a/tests/e2e/specs/tour.spec.ts b/tests/e2e/specs/tour.spec.ts new file mode 100644 index 0000000000..b29cb492ea --- /dev/null +++ b/tests/e2e/specs/tour.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '../fixtures/base.fixture'; + +test.describe('Progress Planner Tour', () => { + test('should complete the tour from start to finish', async ({ dashboard }) => { + await test.step('Start the tour', async () => { + await dashboard.startTour(); + expect(await dashboard.isTourVisible()).toBe(true); + }); + + await test.step('Navigate through all tour steps', async () => { + const stepsCount = await dashboard.getTourStepsCount(); + expect(stepsCount).toBeGreaterThan(0); + + // Navigate through all steps except the last one + for (let i = 0; i < stepsCount - 1; i++) { + await expect(dashboard.tourPopover).toBeVisible(); + await dashboard.clickTourNext(); + } + }); + + await test.step('Verify finish button on last step', async () => { + const buttonText = await dashboard.getTourNextButtonText(); + expect(buttonText).toBe('Finish'); + }); + + await test.step('Complete the tour', async () => { + await dashboard.clickTourNext(); + await expect(dashboard.tourPopover).not.toBeVisible(); + }); + }); + + test('should be able to start tour multiple times', async ({ dashboard, page }) => { + await test.step('Complete tour first time', async () => { + await dashboard.completeTour(); + }); + + await test.step('Reload and start tour again', async () => { + await page.reload(); + await dashboard.waitForReady(); + + await dashboard.startTour(); + expect(await dashboard.isTourVisible()).toBe(true); + }); + + await test.step('Complete tour second time', async () => { + await dashboard.completeTour(); + }); + }); +}); diff --git a/tests/e2e/specs/yoast-integration.spec.ts b/tests/e2e/specs/yoast-integration.spec.ts new file mode 100644 index 0000000000..422b3e94c6 --- /dev/null +++ b/tests/e2e/specs/yoast-integration.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '../fixtures/base.fixture'; + +test.describe('Yoast SEO Integration', () => { + test('should show Ravi icon on Yoast crawl optimization page', async ({ yoastSettings }) => { + await test.step('Navigate to crawl optimization page', async () => { + await yoastSettings.gotoCrawlOptimization(); + }); + + await test.step('Find feed comments toggle', async () => { + const toggleHeader = await yoastSettings.getFeedCommentsToggleHeader(); + await expect(toggleHeader).toBeVisible(); + }); + + await test.step('Verify Ravi icon is present', async () => { + const toggleHeader = await yoastSettings.getFeedCommentsToggleHeader(); + await yoastSettings.verifyRaviIcon(toggleHeader); + }); + }); + + test('should show Ravi icon on Yoast site representation page', async ({ yoastSettings }) => { + await test.step('Navigate to site representation page', async () => { + await yoastSettings.gotoSiteRepresentation(); + }); + + await test.step('Find company logo label', async () => { + const logoLabel = await yoastSettings.getCompanyLogoLabel(); + await expect(logoLabel).toBeVisible(); + }); + + await test.step('Verify Ravi icon is present', async () => { + const logoLabel = await yoastSettings.getCompanyLogoLabel(); + await yoastSettings.verifyRaviIcon(logoLabel); + }); + }); + + test('should update Ravi icon state after completing task', async ({ yoastSettings }) => { + await test.step('Navigate to crawl optimization page', async () => { + await yoastSettings.gotoCrawlOptimization(); + }); + + await test.step('Verify initial uncompleted state', async () => { + const toggleHeader = await yoastSettings.getFeedCommentsToggleHeader(); + await yoastSettings.verifyRaviIconUncompleted(toggleHeader); + }); + + await test.step('Toggle the setting', async () => { + await yoastSettings.clickFeedCommentsToggle(); + }); + + await test.step('Verify completed state', async () => { + const toggleHeader = await yoastSettings.getFeedCommentsToggleHeader(); + await yoastSettings.verifyRaviIconCompleted(toggleHeader); + }); + }); +}); diff --git a/tests/e2e/task-dismissible.spec.js b/tests/e2e/task-dismissible.spec.js deleted file mode 100644 index b1d1a65369..0000000000 --- a/tests/e2e/task-dismissible.spec.js +++ /dev/null @@ -1,62 +0,0 @@ -const { test, expect } = require( '@playwright/test' ); -const { makeAuthenticatedRequest } = require( './utils' ); - -test.describe( 'PRPL Dismissable Tasks', () => { - test( 'Complete dismissable task if present', async ( { - page, - request, - } ) => { - // Navigate to Progress Planner dashboard - await page.goto( '/wp-admin/admin.php?page=progress-planner' ); - await page.waitForLoadState( 'networkidle' ); - - // Check if complete button exists - const initialCount = await page - .locator( - '#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)' - ) - .count(); - - if ( initialCount > 0 ) { - const completeButton = page - .locator( - '#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)' - ) - .first(); - - // Get the task ID from the button - const taskId = await completeButton - .locator( 'xpath=ancestor::li[1]' ) // .closest("li"), but playwright doesn't support it - .getAttribute( 'data-task-id' ); - - // Click the on the parent of the checkbox (label, because it intercepts pointer events) - await completeButton.locator( '..' ).click(); // parent(), but playwright doesn't support it - - // Wait for animation - await page.waitForTimeout( 3000 ); - - // Verify the task count decreased by 1 - const finalCount = await page - .locator( - '#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)' - ) - .count(); - expect( finalCount ).toBe( initialCount - 1 ); - - // Check the final task status via REST API - const completedResponse = await makeAuthenticatedRequest( - page, - request, - `${ process.env.WORDPRESS_URL }/?rest_route=/progress-planner/v1/tasks` - ); - const completedTasks = await completedResponse.json(); - - // Find the completed task - const completedTask = completedTasks.find( - ( task ) => task.post_name === taskId - ); - expect( completedTask ).toBeDefined(); - expect( completedTask.post_status ).toBe( 'trash' ); - } - } ); -} ); diff --git a/tests/e2e/task-snooze.spec.js b/tests/e2e/task-snooze.spec.js deleted file mode 100644 index a386ce846d..0000000000 --- a/tests/e2e/task-snooze.spec.js +++ /dev/null @@ -1,75 +0,0 @@ -const { test, expect } = require( '@playwright/test' ); -const { makeAuthenticatedRequest } = require( './utils' ); - -test.describe( 'PRPL Task Snooze', () => { - test( 'Snooze a task for one week', async ( { page, request } ) => { - // Navigate to Progress Planner dashboard with show all tasks parameter - await page.goto( - `${ process.env.WORDPRESS_URL }/wp-admin/admin.php?page=progress-planner&prpl_show_all_recommendations` - ); - await page.waitForLoadState( 'networkidle' ); - - // Get initial tasks - const response = await makeAuthenticatedRequest( - page, - request, - `${ process.env.WORDPRESS_URL }/?rest_route=/progress-planner/v1/tasks` - ); - const initialTasks = await response.json(); - - // Snooze task ID, Save Settings should be always available. - const snoozeTaskId = 'settings-saved'; - - // Find a task that's not completed or snoozed - const taskToSnooze = initialTasks.find( - ( task ) => task.post_name === snoozeTaskId - ); - - if ( taskToSnooze ) { - // Hover over the task to show actions - const taskElement = page.locator( - `li[data-task-id="${ taskToSnooze.post_name }"]` - ); - await taskElement.hover(); - - // Click the snooze button - const snoozeButton = taskElement.locator( - 'button[data-action="snooze"]' - ); - await snoozeButton.click(); - - // Click the radio group to show options - const radioGroup = taskElement.locator( - 'button.prpl-toggle-radio-group' - ); - await radioGroup.click(); - - // Select 1 week duration by clicking the label - await page.evaluate( ( taskToBeSnoozed ) => { - const radio = document.querySelector( - `li[data-task-id="${ taskToBeSnoozed.post_name }"] .prpl-snooze-duration-radio-group input[type="radio"][value="1-week"]` - ); - const label = radio.closest( 'label' ); - label.click(); - }, taskToSnooze ); - - // Wait for the API call to complete - await page.waitForLoadState( 'networkidle' ); - - // Wait for the task to be snoozed - await page.waitForTimeout( 1000 ); - - // Verify task status via REST API - const updatedResponse = await makeAuthenticatedRequest( - page, - request, - `${ process.env.WORDPRESS_URL }/?rest_route=/progress-planner/v1/tasks` - ); - const updatedTasks = await updatedResponse.json(); - const updatedTask = updatedTasks.find( - ( task ) => task.post_name === taskToSnooze.post_name - ); - expect( updatedTask.post_status ).toBe( 'future' ); - } - } ); -} ); diff --git a/tests/e2e/tour.spec.js b/tests/e2e/tour.spec.js deleted file mode 100644 index 4ad52ee691..0000000000 --- a/tests/e2e/tour.spec.js +++ /dev/null @@ -1,46 +0,0 @@ -const { test, expect } = require( '@playwright/test' ); - -test.describe( 'PRPL Tour', () => { - test( 'Should start the tour when clicking the tour button', async ( { - page, - } ) => { - // Navigate to Progress Planner dashboard - await page.goto( '/wp-admin/admin.php?page=progress-planner' ); - await page.waitForLoadState( 'networkidle' ); - - // Click the tour button - const tourButton = page.locator( '#prpl-start-tour-icon-button' ); - await tourButton.click(); - - // Wait for and verify the tour popover is visible - let tourPopover = page.locator( '.driver-popover' ); - await expect( tourPopover ).toBeVisible(); - - // Get the number of steps from the window object - const numberOfSteps = await page.evaluate( - () => window.progressPlannerTour.steps.length - ); - - for ( let i = 0; i < numberOfSteps - 1; i++ ) { - tourPopover = page.locator( '.driver-popover' ); - - // Wait for the popover to be visible before interacting - await expect( tourPopover ).toBeVisible(); - - // Click the "Next" button if it's not the last step - if ( i < numberOfSteps - 1 ) { - const nextButton = page.locator( '.driver-popover-next-btn' ); - await nextButton.click(); - } - } - - const nextButton = page.locator( '.driver-popover-next-btn' ); - - // Verify the button text changes to "Finish" on the last step - await expect( nextButton ).toHaveText( 'Finish' ); - - // Click the finish button and verify the tour popover closes - await nextButton.click(); - await expect( tourPopover ).not.toBeVisible(); - } ); -} ); diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000000..1a7f7d2d89 --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "./**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/tests/e2e/utils.js b/tests/e2e/utils.js deleted file mode 100644 index cc85fcfa79..0000000000 --- a/tests/e2e/utils.js +++ /dev/null @@ -1,51 +0,0 @@ -// const { test } = require( '@playwright/test' ); - -/** - * Makes an authenticated request to WordPress REST API - * @param {import('@playwright/test').Page} page - The Playwright page object - * @param {import('@playwright/test').APIRequestContext} request - The Playwright request context - * @param {string} endpoint - The API endpoint to call - * @param {Object} options - Additional request options - * @return {Promise} The API response - */ -async function makeAuthenticatedRequest( - page, - request, - endpoint, - options = {} -) { - const cookies = await page.context().cookies(); - - return request.get( endpoint, { - ...options, - headers: { - ...options.headers, - }, - cookies, - params: { - token: process.env.PRPL_TEST_TOKEN, - }, - } ); -} - -// Add timing utility -/* -const startTime = Date.now(); -const getElapsedTime = () => { - const elapsed = Date.now() - startTime; - return `${ ( elapsed / 1000 ).toFixed( 2 ) }s`; -}; - -// Log test start/end with timing -test.beforeEach( async ( {}, testInfo ) => { - console.log( `[${ getElapsedTime() }] Starting test: ${ testInfo.title }` ); -} ); - -test.afterEach( async ( {}, testInfo ) => { - console.log( `[${ getElapsedTime() }] Finished test: ${ testInfo.title }` ); -} ); -*/ - -module.exports = { - makeAuthenticatedRequest, -}; diff --git a/tests/e2e/yoast-focus-element.spec.js b/tests/e2e/yoast-focus-element.spec.js deleted file mode 100644 index 481f238acd..0000000000 --- a/tests/e2e/yoast-focus-element.spec.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * External dependencies - */ -import { test, expect } from '@playwright/test'; - -test.describe( 'Yoast Focus Element', () => { - test( 'should add Ravi icon to the feed comments toggle', async ( { - page, - } ) => { - await page.goto( - '/wp-admin/admin.php?page=wpseo_page_settings#/crawl-optimization' - ); - - // If there is an modal with overlay (which prevents clicks), close it. - const closeButton = page.locator( 'button.yst-modal__close-button' ); - if ( await closeButton.isVisible() ) { - await closeButton.click(); - } - - // Wait for the page to load and the toggle to be visible - await page.waitForSelector( - 'button[data-id="input-wpseo-remove_feed_global_comments"]' - ); - - // Find the toggle input - const toggleInput = page.locator( - 'button[data-id="input-wpseo-remove_feed_global_comments"]' - ); - - // Find the parent toggle field header - const toggleHeader = toggleInput.locator( - 'xpath=ancestor::div[contains(@class, "yst-toggle-field__header")]' - ); - - // Verify the Ravi icon exists within the toggle header - const raviIconWrapper = toggleHeader.locator( - '[data-prpl-element="ravi-icon"]' - ); - await expect( raviIconWrapper ).toBeVisible(); - - // Verify the icon image exists and has correct attributes - const iconImg = raviIconWrapper.locator( 'img' ); - await expect( iconImg ).toBeVisible(); - await expect( iconImg ).toHaveAttribute( 'alt', 'Ravi' ); - await expect( iconImg ).toHaveAttribute( 'width', '16' ); - await expect( iconImg ).toHaveAttribute( 'height', '16' ); - - // Verify that the icon is not checked - await expect( - raviIconWrapper.locator( '.prpl-form-row-points' ) - ).toHaveText( '+1' ); - - // Now click the toggle - await toggleInput.click(); - - // Verify that the icon is now checked - await expect( - raviIconWrapper.locator( '.prpl-form-row-points' ) - ).toHaveText( '✓' ); - } ); - - test( 'should add Ravi icon to the company logo upload field', async ( { - page, - } ) => { - await page.goto( - '/wp-admin/admin.php?page=wpseo_page_settings#/site-representation' - ); - - // Wait for the company logo label to be visible - await page.waitForSelector( - '#wpseo_titles-company_logo legend.yst-label' - ); - - // Find the label element - const logoLabel = page.locator( - '#wpseo_titles-company_logo legend.yst-label' - ); - - // Verify the Ravi icon exists within the label - const raviIcon = logoLabel.locator( '[data-prpl-element="ravi-icon"]' ); - await expect( raviIcon ).toBeVisible(); - - // Verify the icon image exists and has correct attributes - const iconImg = raviIcon.locator( 'img' ); - await expect( iconImg ).toBeVisible(); - await expect( iconImg ).toHaveAttribute( 'alt', 'Ravi' ); - await expect( iconImg ).toHaveAttribute( 'width', '16' ); - await expect( iconImg ).toHaveAttribute( 'height', '16' ); - } ); -} ); From 6236246232de0c5e6d804beddc723ca4a6a549dd Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Sat, 27 Dec 2025 07:37:44 +0100 Subject: [PATCH 2/7] update tests --- tests/e2e/pages/dashboard.page.ts | 42 ++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/tests/e2e/pages/dashboard.page.ts b/tests/e2e/pages/dashboard.page.ts index b032e5422b..ee7c93ffd3 100644 --- a/tests/e2e/pages/dashboard.page.ts +++ b/tests/e2e/pages/dashboard.page.ts @@ -194,6 +194,10 @@ export class DashboardPage extends BasePage { async openCompletedTasks(): Promise { const details = this.page.locator(SELECTORS.todoCompletedDetails); + // Check if details element exists and is visible + const isVisible = await details.isVisible().catch(() => false); + if (!isVisible) return; + // Check if already open const isOpen = await details.getAttribute('open'); if (isOpen !== null) return; @@ -368,14 +372,22 @@ export class DashboardPage extends BasePage { // ================== async deleteAllTodos(): Promise { + // Verify page is still accessible + try { + await this.page.waitForLoadState('domcontentloaded', { timeout: 2000 }); + } catch { + console.warn('[Cleanup] Page not accessible, skipping cleanup'); + return; + } + // Delete active tasks let todoItems = await this.getTodoItems(); while (todoItems.length > 0) { try { - await this.deleteTodo(todoItems[0]); + await this.deleteTodoQuiet(todoItems[0]); } catch (err) { - console.warn('[Cleanup] Failed to delete todo:', err); + console.warn('[Cleanup] Failed to delete todo:', (err as Error).message); break; } todoItems = await this.getTodoItems(); @@ -392,12 +404,34 @@ export class DashboardPage extends BasePage { const trash = item.locator('.prpl-suggested-task-points-wrapper .trash'); await trash.waitFor({ state: 'visible', timeout: 3000 }); await trash.click(); - await this.page.waitForResponse((r) => r.url().includes('/progress-planner/v1/')); + await this.page.waitForResponse( + (r) => r.url().includes('/progress-planner/v1/'), + { timeout: 5000 } + ); } catch (err) { - console.warn('[Cleanup] Failed to delete completed todo:', err); + console.warn('[Cleanup] Failed to delete completed todo:', (err as Error).message); break; } completedItems = await this.getCompletedItems(); } } + + /** + * Delete a todo without throwing on failure (for cleanup). + */ + private async deleteTodoQuiet(item: Locator): Promise { + await this.scrollToAndWait(item); + await item.hover(); + + const trashButton = item.locator(`${SELECTORS.taskActionsWrapper} ${SELECTORS.taskTrashButton}`); + await trashButton.waitFor({ state: 'visible', timeout: 3000 }); + + await Promise.all([ + this.page.waitForResponse( + (r) => r.url().includes('/progress-planner/v1/'), + { timeout: 5000 } + ), + trashButton.click(), + ]); + } } From 84bb62bcf4700be9017e2d54bc2e1c81935bbdec Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Sat, 27 Dec 2025 09:10:30 +0100 Subject: [PATCH 3/7] more WIP --- .github/workflows/playwright.yml | 162 ++++++++++++---------- playwright.config.ts | 38 +++-- tests/e2e/api/tasks.api.ts | 6 +- tests/e2e/blueprint.json | 26 ++++ tests/e2e/fixtures/base.fixture.ts | 13 +- tests/e2e/global-setup.ts | 67 ++++++--- tests/e2e/pages/dashboard.page.ts | 150 +++++++------------- tests/e2e/specs/onboarding.spec.ts | 39 ++++-- tests/e2e/specs/task-dismissible.spec.ts | 53 +++---- tests/e2e/specs/task-snooze.spec.ts | 82 ++++++----- tests/e2e/specs/task-tagline.spec.ts | 60 +++++--- tests/e2e/specs/todo-complete.spec.ts | 94 +++++++------ tests/e2e/specs/todo-crud.spec.ts | 66 +++------ tests/e2e/specs/todo-reorder.spec.ts | 106 ++++++-------- tests/e2e/specs/tour.spec.ts | 77 +++++----- tests/e2e/specs/yoast-integration.spec.ts | 110 +++++++++------ 16 files changed, 596 insertions(+), 553 deletions(-) create mode 100644 tests/e2e/blueprint.json diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 50192b1541..fe0139f6c6 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1,15 +1,6 @@ name: Progress Planner Playwright Tests env: - WORDPRESS_URL: http://localhost:8080 - WORDPRESS_ADMIN_USER: admin - WORDPRESS_ADMIN_PASSWORD: password - WORDPRESS_ADMIN_EMAIL: admin@example.com - WORDPRESS_TABLE_PREFIX: wp_ - WORDPRESS_DB_USER: wpuser - WORDPRESS_DB_PASSWORD: wppass - WORDPRESS_DB_NAME: wordpress - WORDPRESS_DB_PORT: 3307 # So it can run locally (hopefully). PRPL_TEST_TOKEN: 0220a2de67fc29094281088395939f58 YOAST_TOKEN: ${{ secrets.YOAST_TOKEN }} @@ -23,34 +14,70 @@ on: pull_request: jobs: + # Main E2E tests using WP Playground (fast, no Docker) e2e-tests: runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright Tests + run: npm run test:e2e + env: + PLAYGROUND: 'true' + + - name: Upload Playwright Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + + - name: Upload failure screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-screenshots + path: | + login-failed.png + test-results/ + + # Yoast Premium tests require Docker (for Composer/premium plugin installation) + yoast-premium-tests: + runs-on: ubuntu-latest + needs: e2e-tests + services: mysql: image: mariadb:10.6 env: MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: ${{ env.WORDPRESS_DB_NAME }} - MYSQL_USER: ${{ env.WORDPRESS_DB_USER }} - MYSQL_PASSWORD: ${{ env.WORDPRESS_DB_PASSWORD }} + MYSQL_DATABASE: wordpress + MYSQL_USER: wpuser + MYSQL_PASSWORD: wppass ports: - - 3307:3306 # GitHub Actions doesn't support environment variables in the ports section. + - 3307:3306 wordpress: image: wordpress:latest env: WORDPRESS_DB_HOST: mysql - WORDPRESS_DB_USER: ${{ env.WORDPRESS_DB_USER }} - WORDPRESS_DB_PASSWORD: ${{ env.WORDPRESS_DB_PASSWORD }} - WORDPRESS_DB_NAME: ${{ env.WORDPRESS_DB_NAME }} - WORDPRESS_DB_PORT: ${{ env.WORDPRESS_DB_PORT }} - WORDPRESS_TABLE_PREFIX: ${{ env.WORDPRESS_TABLE_PREFIX }} + WORDPRESS_DB_USER: wpuser + WORDPRESS_DB_PASSWORD: wppass + WORDPRESS_DB_NAME: wordpress WORDPRESS_DEBUG: 1 - WORDPRESS_URL: ${{ env.WORDPRESS_URL }} - WORDPRESS_ADMIN_USER: ${{ env.WORDPRESS_ADMIN_USER }} - WORDPRESS_ADMIN_PASSWORD: ${{ env.WORDPRESS_ADMIN_PASSWORD }} - WORDPRESS_ADMIN_EMAIL: ${{ env.WORDPRESS_ADMIN_EMAIL }} - PRPL_TEST_TOKEN: ${{ env.PRPL_TEST_TOKEN }} ports: - 8080:80 options: >- @@ -61,27 +88,31 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Install Node.js & dependencies - uses: actions/setup-node@v3 + - name: Setup Node.js + uses: actions/setup-node@v4 with: node-version: 20 - - run: npm ci - - run: npx playwright install --with-deps chromium + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium - name: Complete WordPress installation run: | - echo "Installing WordPress at: $WORDPRESS_URL" - curl --silent -X POST "$WORDPRESS_URL/wp-admin/install.php?step=2" \ + curl --silent -X POST "http://localhost:8080/wp-admin/install.php?step=2" \ -d "weblog_title=My%20WordPress%20Site" \ - -d "user_name=$WORDPRESS_ADMIN_USER" \ - -d "admin_password=$WORDPRESS_ADMIN_PASSWORD" \ - -d "admin_password2=$WORDPRESS_ADMIN_PASSWORD" \ - -d "admin_email=$WORDPRESS_ADMIN_EMAIL" \ + -d "user_name=admin" \ + -d "admin_password=password" \ + -d "admin_password2=password" \ + -d "admin_email=admin@example.com" \ -d "public=1" - - name: Install and activate plugin + - name: Setup WordPress with plugins run: | WP_CONTAINER=$(docker ps -qf "name=wordpress") @@ -90,79 +121,56 @@ jobs: docker exec $WP_CONTAINER chmod +x wp-cli.phar docker exec $WP_CONTAINER mv wp-cli.phar /usr/local/bin/wp - # Create the plugins directory in the WordPress container - docker exec $WP_CONTAINER mkdir -p /var/www/html/wp-content/plugins - - # Copy plugin files to WordPress plugins directory + # Copy and activate Progress Planner docker cp . $WP_CONTAINER:/var/www/html/wp-content/plugins/progress-planner - - # Activate the plugin using WP-CLI docker exec $WP_CONTAINER wp plugin activate progress-planner --allow-root - # Enable debug mode - docker exec $WP_CONTAINER wp option update prpl_debug true --allow-root - - # Insert test token + # Set test token docker exec $WP_CONTAINER wp option update progress_planner_test_token $PRPL_TEST_TOKEN --allow-root - # Install Yoast SEO + # Install and activate Yoast SEO (free) docker exec $WP_CONTAINER wp plugin install wordpress-seo --activate --allow-root - - name: Run Playwright Tests - run: npm run test:e2e - - # Begin Yoast SEO Premium tests - - name: Install PHP & Composer on host + - name: Install PHP & Composer run: | sudo apt-get update sudo apt-get install -y git curl unzip php-cli php-curl php-mbstring php-xml php-zip curl -sS https://getcomposer.org/installer | php sudo mv composer.phar /usr/local/bin/composer - - name: Install plugin dependencies (Composer) + - name: Install Yoast SEO Premium run: | + WP_CONTAINER=$(docker ps -qf "name=wordpress") + + # Configure Composer for Yoast composer config -g http-basic.my.yoast.com token $YOAST_TOKEN composer config repositories.my-yoast composer https://my.yoast.com/packages/ composer config --no-plugins allow-plugins.composer/installers true - composer install --working-dir=./ + composer install - - name: Require Yoast SEO Premium & copy files - run: | - WP_CONTAINER=$(docker ps -qf "name=wordpress") + # Install and copy Yoast Premium composer require yoast/wordpress-seo-premium composer dump-autoload --working-dir=./wp-content/plugins/wordpress-seo-premium docker cp ./wp-content/plugins/wordpress-seo-premium $WP_CONTAINER:/var/www/html/wp-content/plugins/wordpress-seo-premium - - name: Activate Yoast SEO Premium - run: | - WP_CONTAINER=$(docker ps -qf "name=wordpress") + # Activate and configure docker exec $WP_CONTAINER wp plugin activate wordpress-seo-premium --allow-root - - name: Update Yoast Premium settings - run: | - WP_CONTAINER=$(docker ps -qf "name=wordpress") - # Get current option value + # Disable redirect after install CURRENT_OPTION=$(docker exec $WP_CONTAINER wp option get wpseo_premium --format=json --allow-root) - # Update the option with should_redirect_after_install set to false UPDATED_OPTION=$(echo $CURRENT_OPTION | jq '.should_redirect_after_install = false') - # Save the updated option docker exec $WP_CONTAINER wp option update wpseo_premium "$UPDATED_OPTION" --format=json --allow-root - - name: Run Yoast Integration Tests with Premium + - name: Run Yoast Integration Tests run: npx playwright test --project=parallel --grep="Yoast" - # End Yoast SEO Premium tests + env: + WORDPRESS_URL: http://localhost:8080 + WORDPRESS_ADMIN_USER: admin + WORDPRESS_ADMIN_PASSWORD: password - - name: Upload Playwright Report + - name: Upload Yoast Test Report if: always() uses: actions/upload-artifact@v4 with: - name: playwright-report + name: yoast-playwright-report path: playwright-report/ - - - name: Upload Playwright screenshots as artifacts - if: failure() - uses: actions/upload-artifact@v4 - with: - name: playwright-screenshots - path: | - onboarding-failed.png # Specify the path of the screenshot you want to upload diff --git a/playwright.config.ts b/playwright.config.ts index dfc2442c18..27cc0068f8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -61,12 +61,19 @@ export default defineConfig({ // Configure projects for different browsers projects: [ - // Sequential tests that must run in order (e.g., onboarding) - // Note: In CI, onboarding runs first on fresh WordPress install - // For local dev with existing site, run only parallel: npm run test:parallel + // Sequential tests that must run in order + // Includes: onboarding (must run first on fresh install), + // todo tests (create/delete/complete/reorder - share state), + // task-tagline (modifies WordPress settings) { name: 'sequential', - testMatch: '**/onboarding.spec.ts', + testMatch: [ + '**/onboarding.spec.ts', + '**/todo-crud.spec.ts', + '**/todo-complete.spec.ts', + '**/todo-reorder.spec.ts', + '**/task-tagline.spec.ts', + ], use: { ...devices['Desktop Chrome'] }, fullyParallel: false, workers: 1, @@ -76,18 +83,23 @@ export default defineConfig({ // Depends on sequential in CI (fresh install needs onboarding first) { name: 'parallel', - testIgnore: '**/onboarding.spec.ts', + testIgnore: [ + '**/onboarding.spec.ts', + '**/todo-crud.spec.ts', + '**/todo-complete.spec.ts', + '**/todo-reorder.spec.ts', + '**/task-tagline.spec.ts', + ], dependencies: process.env.CI ? ['sequential'] : [], use: { ...devices['Desktop Chrome'] }, }, ], - // Run local WordPress server before starting the tests - // Uncomment to use WP Playground - // webServer: { - // command: 'npx @wp-playground/cli@latest server --auto-mount --port=8080 --login', - // url: 'http://localhost:8080', - // reuseExistingServer: !process.env.CI, - // timeout: 120 * 1000, - // }, + // Run WP Playground server before starting the tests + webServer: { + command: 'npx @wp-playground/cli server --mount=.:/wordpress/wp-content/plugins/progress-planner --blueprint=tests/e2e/blueprint.json --port=8080', + url: 'http://localhost:8080', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, }); diff --git a/tests/e2e/api/tasks.api.ts b/tests/e2e/api/tasks.api.ts index a9f5d9c2aa..3975573a9b 100644 --- a/tests/e2e/api/tasks.api.ts +++ b/tests/e2e/api/tasks.api.ts @@ -38,9 +38,9 @@ export class TasksApi { void this.getAuthCookies(); const params: Record = {}; - if (process.env.PRPL_TEST_TOKEN) { - params.token = process.env.PRPL_TEST_TOKEN; - } + // Use test token from environment or fallback to the value set in blueprint.json + const testToken = process.env.PRPL_TEST_TOKEN || '0220a2de67fc29094281088395939f58'; + params.token = testToken; const response = await this.request.get( `${this.baseUrl}/?rest_route=${endpoint}`, diff --git a/tests/e2e/blueprint.json b/tests/e2e/blueprint.json new file mode 100644 index 0000000000..ef4d2d1ec7 --- /dev/null +++ b/tests/e2e/blueprint.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "landingPage": "/wp-admin/", + "login": true, + "steps": [ + { + "step": "defineWpConfigConsts", + "consts": { + "IS_PLAYGROUND_PREVIEW": true + } + }, + { + "step": "setSiteOptions", + "options": { + "progress_planner_test_token": "0220a2de67fc29094281088395939f58", + "progress_planner_license_key": "test-license-for-e2e-testing", + "progress_planner_demo_data_generated": "1", + "prpl_debug": "1" + } + }, + { + "step": "wp-cli", + "command": "wp plugin activate progress-planner" + } + ] +} diff --git a/tests/e2e/fixtures/base.fixture.ts b/tests/e2e/fixtures/base.fixture.ts index e2311849f5..c9e2392830 100644 --- a/tests/e2e/fixtures/base.fixture.ts +++ b/tests/e2e/fixtures/base.fixture.ts @@ -68,12 +68,15 @@ export const test = base.extend({ await use(dashboard); // Cleanup after test if enabled + // Note: Cleanup is best-effort and should not affect test results if (cleanupAfterTest) { - try { - await dashboard.deleteAllTodos(); - } catch (err) { - console.warn('[Fixture Cleanup] Failed:', err); - } + // Set a short timeout for the entire cleanup operation + await Promise.race([ + dashboard.deleteAllTodos().catch((err) => { + console.warn('[Fixture Cleanup] Failed:', (err as Error).message); + }), + new Promise((resolve) => setTimeout(resolve, 10000)), // 10s max for cleanup + ]); } }, diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 53e5943b29..0542b1c7df 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -6,10 +6,12 @@ import dotenv from 'dotenv'; dotenv.config(); const authFile = path.join(process.cwd(), 'auth.json'); +const isPlayground = !process.env.WORDPRESS_URL || process.env.PLAYGROUND === 'true'; async function globalSetup(config: FullConfig): Promise { - // Skip if auth file already exists and is recent - if (fs.existsSync(authFile)) { + // For Playground, always generate fresh auth (each instance is new) + // For traditional WP, reuse auth if recent + if (!isPlayground && !process.env.CI && fs.existsSync(authFile)) { const stats = fs.statSync(authFile); const ageMinutes = (Date.now() - stats.mtimeMs) / 1000 / 60; @@ -25,40 +27,71 @@ async function globalSetup(config: FullConfig): Promise { const baseURL = process.env.WORDPRESS_URL || 'http://localhost:8080'; const browser = await chromium.launch(); const context = await browser.newContext({ - // Ignore HTTPS errors for local development with self-signed certificates ignoreHTTPSErrors: true, }); const page = await context.newPage(); - // Listen for console errors page.on('pageerror', (err) => { console.warn('Page error:', err.message); }); try { - // Navigate to login page - await page.goto(`${baseURL}/wp-login.php`); + if (isPlayground) { + // WP Playground with --login flag auto-authenticates + // Just navigate to admin to capture the auth state + console.log('Using WP Playground auto-login...'); + console.log(`Navigating to: ${baseURL}/wp-admin/`); - // Fill login form - await page.fill('#user_login', process.env.WORDPRESS_ADMIN_USER || 'admin'); - await page.fill('#user_pass', process.env.WORDPRESS_ADMIN_PASSWORD || 'password'); - await page.click('#wp-submit'); + // Wait for the page to load and retry a few times if needed + let retries = 3; + while (retries > 0) { + try { + const response = await page.goto(`${baseURL}/wp-admin/`, { + waitUntil: 'domcontentloaded', + timeout: 30000 + }); + console.log(`Response status: ${response?.status()}`); + console.log(`Current URL: ${page.url()}`); - // Wait for login to complete - await page.waitForURL(`${baseURL}/wp-admin/**`, { timeout: 30000 }); - await page.waitForSelector('#wpadminbar', { timeout: 10000 }); + // Check if we're on login page (not auto-logged in) + if (page.url().includes('wp-login.php')) { + console.log('Not auto-logged in, trying default credentials...'); + await page.fill('#user_login', 'admin'); + await page.fill('#user_pass', 'password'); + await page.click('#wp-submit'); + await page.waitForURL(`${baseURL}/wp-admin/**`, { timeout: 30000 }); + } - console.log('Login successful'); + await page.waitForSelector('#wpadminbar', { timeout: 30000 }); + console.log('WP Playground login successful'); + break; + } catch (retryError) { + retries--; + if (retries === 0) throw retryError; + console.log(`Retry attempt, ${retries} left...`); + await page.waitForTimeout(2000); + } + } + } else { + // Traditional WordPress login + await page.goto(`${baseURL}/wp-login.php`); + + await page.fill('#user_login', process.env.WORDPRESS_ADMIN_USER || 'admin'); + await page.fill('#user_pass', process.env.WORDPRESS_ADMIN_PASSWORD || 'password'); + await page.click('#wp-submit'); + + await page.waitForURL(`${baseURL}/wp-admin/**`, { timeout: 30000 }); + await page.waitForSelector('#wpadminbar', { timeout: 10000 }); + console.log('Login successful'); + } // Save auth state await context.storageState({ path: authFile }); console.log('Auth state saved to auth.json'); } catch (error) { console.error('Login failed:', error); - - // Take screenshot for debugging + console.log(`Final URL: ${page.url()}`); await page.screenshot({ path: 'login-failed.png' }); - throw error; } finally { await browser.close(); diff --git a/tests/e2e/pages/dashboard.page.ts b/tests/e2e/pages/dashboard.page.ts index ee7c93ffd3..c709b99b9c 100644 --- a/tests/e2e/pages/dashboard.page.ts +++ b/tests/e2e/pages/dashboard.page.ts @@ -95,14 +95,8 @@ export class DashboardPage extends BasePage { async createTodo(text: string): Promise<{ taskId: string; element: Locator }> { await this.newTodoInput.fill(text); - - // Wait for the API response when pressing Enter - await this.waitForApiResponse( - '/progress-planner/v1/', - async () => { - await this.page.keyboard.press('Enter'); - } - ); + await this.page.keyboard.press('Enter'); + await this.page.waitForTimeout(500); // Find the newly created task const todoItem = this.page.locator(SELECTORS.todoItem).first(); @@ -138,53 +132,30 @@ export class DashboardPage extends BasePage { const trashButton = item.locator(`${SELECTORS.taskActionsWrapper} ${SELECTORS.taskTrashButton}`); await trashButton.waitFor({ state: 'visible' }); - - await this.waitForApiResponse( - '/progress-planner/v1/', - async () => { - await trashButton.click(); - } - ); + await trashButton.click(); + await this.page.waitForTimeout(1500); } async completeTodo(item: Locator): Promise { const label = item.locator(SELECTORS.taskCheckboxLabel); - - await this.waitForApiResponse( - '/progress-planner/v1/', - async () => { - await label.click(); - } - ); - - // Wait for the celebration animation - await this.waitForAnimation(item); + await label.click(); + await this.page.waitForTimeout(1000); } async moveTodoDown(item: Locator): Promise { await item.hover(); const moveDownButton = item.locator(SELECTORS.taskMoveDownButton); await moveDownButton.waitFor({ state: 'visible' }); - - await this.waitForApiResponse( - '/progress-planner/v1/', - async () => { - await moveDownButton.click(); - } - ); + await moveDownButton.click(); + await this.page.waitForTimeout(1500); } async moveTodoUp(item: Locator): Promise { await item.hover(); const moveUpButton = item.locator(SELECTORS.taskMoveUpButton); await moveUpButton.waitFor({ state: 'visible' }); - - await this.waitForApiResponse( - '/progress-planner/v1/', - async () => { - await moveUpButton.click(); - } - ); + await moveUpButton.click(); + await this.page.waitForTimeout(1500); } // ================== @@ -236,16 +207,10 @@ export class DashboardPage extends BasePage { // Click the label (parent of checkbox) const label = firstCheckbox.locator('..'); - - await this.waitForApiResponse( - '/progress-planner/v1/', - async () => { - await label.click(); - } - ); + await label.click(); // Wait for animation - await this.waitForAnimation(taskItem); + await this.page.waitForTimeout(3000); return { taskId, previousCount: initialCount }; } @@ -266,16 +231,17 @@ export class DashboardPage extends BasePage { const radioGroup = taskItem.locator(SELECTORS.snoozeRadioGroup); await radioGroup.click(); - // Select duration - const durationRadio = taskItem.locator(`${SELECTORS.snoozeDurationRadio}[value="${duration}"]`); - const label = durationRadio.locator('xpath=ancestor::label[1]'); + // Select duration using page.evaluate like the original test + await this.page.evaluate(({ id, dur }) => { + const radio = document.querySelector( + `li[data-task-id="${id}"] .prpl-snooze-duration-radio-group input[type="radio"][value="${dur}"]` + ) as HTMLInputElement; + const label = radio?.closest('label'); + label?.click(); + }, { id: taskId, dur: duration }); - await this.waitForApiResponse( - '/progress-planner/v1/', - async () => { - await label.click(); - } - ); + await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); } // ================== @@ -381,57 +347,45 @@ export class DashboardPage extends BasePage { } // Delete active tasks - let todoItems = await this.getTodoItems(); + const todoItems = this.page.locator(SELECTORS.todoItem); + while ((await todoItems.count()) > 0) { + const firstItem = todoItems.first(); + const trash = firstItem.locator(`${SELECTORS.taskActionsWrapper} ${SELECTORS.taskTrashButton}`); - while (todoItems.length > 0) { try { - await this.deleteTodoQuiet(todoItems[0]); + await firstItem.scrollIntoViewIfNeeded(); + await firstItem.hover(); + await trash.waitFor({ state: 'visible', timeout: 3000 }); + await trash.click(); + await this.page.waitForTimeout(1500); } catch (err) { - console.warn('[Cleanup] Failed to delete todo:', (err as Error).message); + console.warn('[Cleanup] Failed to delete active todo item:', (err as Error).message); break; } - todoItems = await this.getTodoItems(); } // Delete completed tasks - await this.openCompletedTasks(); - - let completedItems = await this.getCompletedItems(); - while (completedItems.length > 0) { - try { - const item = completedItems[0]; - await item.hover(); - const trash = item.locator('.prpl-suggested-task-points-wrapper .trash'); - await trash.waitFor({ state: 'visible', timeout: 3000 }); - await trash.click(); - await this.page.waitForResponse( - (r) => r.url().includes('/progress-planner/v1/'), - { timeout: 5000 } - ); - } catch (err) { - console.warn('[Cleanup] Failed to delete completed todo:', (err as Error).message); - break; + const completedDetails = this.page.locator(SELECTORS.todoCompletedDetails); + if (await completedDetails.isVisible().catch(() => false)) { + await completedDetails.click(); + await this.page.waitForTimeout(500); + + const completedItems = this.page.locator(SELECTORS.todoCompletedItem); + while ((await completedItems.count()) > 0) { + const firstCompleted = completedItems.first(); + const trash = firstCompleted.locator('.prpl-suggested-task-points-wrapper .trash'); + + try { + await firstCompleted.scrollIntoViewIfNeeded(); + await firstCompleted.hover(); + await trash.waitFor({ state: 'visible', timeout: 3000 }); + await trash.click(); + await this.page.waitForTimeout(1500); + } catch (err) { + console.warn('[Cleanup] Failed to delete completed todo item:', (err as Error).message); + break; + } } - completedItems = await this.getCompletedItems(); } } - - /** - * Delete a todo without throwing on failure (for cleanup). - */ - private async deleteTodoQuiet(item: Locator): Promise { - await this.scrollToAndWait(item); - await item.hover(); - - const trashButton = item.locator(`${SELECTORS.taskActionsWrapper} ${SELECTORS.taskTrashButton}`); - await trashButton.waitFor({ state: 'visible', timeout: 3000 }); - - await Promise.all([ - this.page.waitForResponse( - (r) => r.url().includes('/progress-planner/v1/'), - { timeout: 5000 } - ), - trashButton.click(), - ]); - } } diff --git a/tests/e2e/specs/onboarding.spec.ts b/tests/e2e/specs/onboarding.spec.ts index 3e389b1a35..bdebc8b113 100644 --- a/tests/e2e/specs/onboarding.spec.ts +++ b/tests/e2e/specs/onboarding.spec.ts @@ -1,37 +1,46 @@ import { test, expect } from '../fixtures/base.fixture'; test.describe('Progress Planner Onboarding', () => { - // This test needs a fresh state, so we use a separate storage state - test.use({ storageState: { cookies: [], origins: [] } }); + // This test requires external API for license generation, which isn't available in Playground + // Skip by default in Playground environments + test.skip(({ }, testInfo) => { + // Skip if running in Playground mode (no WORDPRESS_URL set means Playground) + return !process.env.WORDPRESS_URL; + }, 'License generation requires external API (not available in Playground)'); test('should complete onboarding process successfully', async ({ page }) => { - // Login manually since we cleared storage state - await test.step('Login to WordPress', async () => { - await page.goto('/wp-login.php'); - await page.fill('#user_login', process.env.WORDPRESS_ADMIN_USER || 'admin'); - await page.fill('#user_pass', process.env.WORDPRESS_ADMIN_PASSWORD || 'password'); - await page.click('#wp-submit'); - await page.waitForURL('**/wp-admin/**'); - }); - const popover = page.locator('#prpl-popover-onboarding'); - await test.step('Navigate to Progress Planner', async () => { + await test.step('Navigate to Progress Planner and trigger onboarding', async () => { await page.goto('/wp-admin/admin.php?page=progress-planner'); await page.waitForLoadState('networkidle'); + // In Playground mode with pre-existing license, click "Show onboarding" to trigger onboarding + const showOnboardingButton = page.locator('#progress-planner-show-onboarding'); + if (await showOnboardingButton.isVisible()) { + // This button triggers AJAX that deletes license and progress, then reloads + await showOnboardingButton.click(); + + // Wait for reload + await page.waitForLoadState('networkidle'); + } + await expect(popover).toBeVisible({ timeout: 10000 }); }); - await test.step('Accept privacy policy', async () => { + await test.step('Accept privacy policy if visible', async () => { + // Privacy checkbox is only visible when no license exists const privacyLabel = page.locator('label[for="prpl-privacy-checkbox"]'); - await expect(privacyLabel).toBeVisible(); - await privacyLabel.click(); + if (await privacyLabel.isVisible({ timeout: 2000 }).catch(() => false)) { + await privacyLabel.click(); + } }); await test.step('Start onboarding', async () => { const startButton = popover.locator('.prpl-tour-next'); await expect(startButton).toBeVisible(); + + // Click the button await startButton.click(); // Wait for step to advance (license generation happens in background) diff --git a/tests/e2e/specs/task-dismissible.spec.ts b/tests/e2e/specs/task-dismissible.spec.ts index 8083dd54f1..1f43cd8b4a 100644 --- a/tests/e2e/specs/task-dismissible.spec.ts +++ b/tests/e2e/specs/task-dismissible.spec.ts @@ -1,39 +1,42 @@ import { test, expect } from '../fixtures/base.fixture'; test.describe('Dismissible Tasks', () => { - test('should complete dismissible task if present', async ({ dashboard, tasksApi }) => { - await test.step('Check for available suggested tasks', async () => { - const initialCount = await dashboard.getSuggestedTasksCount(); + test('should complete dismissible task if present', async ({ page, tasksApi }) => { + // Navigate to Progress Planner dashboard + await page.goto('/wp-admin/admin.php?page=progress-planner'); + await page.waitForLoadState('networkidle'); - if (initialCount === 0) { - test.skip(); - return; - } - }); + // Check if complete button exists + const initialCount = await page + .locator('#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)') + .count(); - let taskId: string | null; - let previousCount: number; + if (initialCount > 0) { + const completeButton = page + .locator('#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)') + .first(); - await test.step('Complete a suggested task', async () => { - const result = await dashboard.completeSuggestedTask(); - taskId = result.taskId; - previousCount = result.previousCount; + // Get the task ID from the button + const taskId = await completeButton + .locator('xpath=ancestor::li[1]') + .getAttribute('data-task-id'); - if (taskId === null) { - test.skip(); - return; - } - }); + // Click on the parent of the checkbox (label, because it intercepts pointer events) + await completeButton.locator('..').click(); + + // Wait for animation + await page.waitForTimeout(3000); - await test.step('Verify task count decreased', async () => { - const finalCount = await dashboard.getSuggestedTasksCount(); - expect(finalCount).toBe(previousCount - 1); - }); + // Verify the task count decreased by 1 + const finalCount = await page + .locator('#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)') + .count(); + expect(finalCount).toBe(initialCount - 1); - await test.step('Verify task is marked as completed via API', async () => { + // Check the final task status via REST API if (taskId) { await tasksApi.expectTaskStatus(taskId, 'trash'); } - }); + } }); }); diff --git a/tests/e2e/specs/task-snooze.spec.ts b/tests/e2e/specs/task-snooze.spec.ts index 58e31acbce..f97c319d49 100644 --- a/tests/e2e/specs/task-snooze.spec.ts +++ b/tests/e2e/specs/task-snooze.spec.ts @@ -1,56 +1,54 @@ import { test, expect } from '../fixtures/base.fixture'; test.describe('Task Snooze', () => { - // Enable cleanup for this test suite - test.use({ cleanupAfterTest: true }); + test('should snooze a task for 1 week', async ({ page, tasksApi }) => { + // Navigate with show all recommendations to ensure we have tasks + await page.goto('/wp-admin/admin.php?page=progress-planner&prpl_show_all_recommendations'); + await page.waitForLoadState('domcontentloaded'); - test('should snooze a task for 1 day', async ({ dashboard, tasksApi }) => { - const taskText = 'Task to snooze ' + Date.now(); + // Wait for the page to settle + await page.waitForTimeout(2000); - let taskId: string; + // Use a known task that should always be available: settings-saved + const snoozeTaskId = 'settings-saved'; - await test.step('Create a todo', async () => { - const result = await dashboard.createTodo(taskText); - taskId = result.taskId; - }); - - await test.step('Snooze the task for 1 day', async () => { - await dashboard.snoozeTask(taskId, '1-day'); - }); - - await test.step('Verify task is snoozed via API', async () => { - // Snoozed tasks have a 'future' status - await tasksApi.waitForTaskStatus(taskId, 'future', { timeout: 5000 }); - }); - - await test.step('Verify task is no longer in active list', async () => { - const items = await dashboard.getTodoItems(); - const taskStillVisible = await Promise.all( - items.map(async (item) => { - const text = await dashboard.getTodoText(item); - return text === taskText; - }) - ); - expect(taskStillVisible.every((visible) => !visible)).toBe(true); - }); - }); - - test('should snooze a task for 1 week', async ({ dashboard, tasksApi }) => { - const taskText = 'Week snooze task ' + Date.now(); - - let taskId: string; - - await test.step('Create a todo', async () => { - const result = await dashboard.createTodo(taskText); - taskId = result.taskId; - }); + // Verify the task exists and is active + const task = await tasksApi.getTask(snoozeTaskId); + if (!task || task.post_status !== 'publish') { + console.log(`Task ${snoozeTaskId} not available (status: ${task?.post_status || 'not found'}), skipping`); + test.skip(); + return; + } await test.step('Snooze the task for 1 week', async () => { - await dashboard.snoozeTask(taskId, '1-week'); + const taskItem = page.locator(`li[data-task-id="${snoozeTaskId}"]`); + await expect(taskItem).toBeVisible({ timeout: 10000 }); + await taskItem.hover(); + + // Click snooze button + const snoozeButton = taskItem.locator('button[data-action="snooze"]'); + await snoozeButton.click(); + + // Open radio group + const radioGroup = taskItem.locator('button.prpl-toggle-radio-group'); + await radioGroup.click(); + + // Select 1 week duration by clicking the label + await page.evaluate((taskId) => { + const radio = document.querySelector( + `li[data-task-id="${taskId}"] .prpl-snooze-duration-radio-group input[type="radio"][value="1-week"]` + ) as HTMLInputElement; + const label = radio?.closest('label'); + label?.click(); + }, snoozeTaskId); + + // Wait for the API call to complete + await page.waitForTimeout(2000); }); await test.step('Verify task is snoozed via API', async () => { - await tasksApi.waitForTaskStatus(taskId, 'future', { timeout: 5000 }); + const updatedTask = await tasksApi.getTask(snoozeTaskId); + expect(updatedTask?.post_status).toBe('future'); }); }); }); diff --git a/tests/e2e/specs/task-tagline.spec.ts b/tests/e2e/specs/task-tagline.spec.ts index f34a30186b..0d0e9d8532 100644 --- a/tests/e2e/specs/task-tagline.spec.ts +++ b/tests/e2e/specs/task-tagline.spec.ts @@ -2,50 +2,72 @@ import { test, expect } from '../fixtures/base.fixture'; test.describe('Task Tagline Completion', () => { test('should complete blog description task when tagline is set', async ({ page, tasksApi }) => { - await test.step('Navigate to Progress Planner dashboard', async () => { + await test.step('Navigate to Progress Planner dashboard to init', async () => { await page.goto('/wp-admin/admin.php?page=progress-planner'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); + // Wait for page to settle + await page.waitForTimeout(1000); }); await test.step('Verify blog description task exists and is active', async () => { const task = await tasksApi.getTask('core-blogdescription'); - expect(task).toBeDefined(); - expect(task?.post_status).toBe('publish'); + if (!task || task.post_status !== 'publish') { + // Task doesn't exist or isn't active - skip test + console.log('Task core-blogdescription not available, skipping test'); + test.skip(); + return; + } + expect(task.post_status).toBe('publish'); }); await test.step('Navigate to WordPress settings and set tagline', async () => { await page.goto('/wp-admin/options-general.php'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); await page.fill('#blogdescription', 'My Awesome Site Description'); await page.click('#submit'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); + + // Wait for task status to update + await page.waitForTimeout(500); }); await test.step('Verify task status changed to pending', async () => { - await tasksApi.waitForTaskStatus('core-blogdescription', 'pending', { timeout: 5000 }); + const task = await tasksApi.getTask('core-blogdescription'); + expect(task).toBeDefined(); + expect(task?.post_status).toBe('pending'); }); - await test.step('Navigate to dashboard and verify task completion animation', async () => { + await test.step('Navigate to dashboard and verify task completion', async () => { await page.goto('/wp-admin/admin.php?page=progress-planner'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); - // Wait for widget to be visible + // Wait for widget container to be visible const widgetContainer = page.locator('.prpl-widget-wrapper.prpl-suggested-tasks'); - await expect(widgetContainer).toBeVisible(); + await expect(widgetContainer).toBeVisible({ timeout: 10000 }); - // Wait for task element to appear + // Wait for tasks list to be visible + const tasksList = page.locator('.prpl-widget-wrapper.prpl-suggested-tasks .prpl-suggested-tasks-list'); + await expect(tasksList).toBeVisible(); + + // Wait for the specific task to appear const taskElement = page.locator('li[data-task-id="core-blogdescription"]'); + await expect(taskElement).toBeVisible(); - // If task is visible, wait for the celebration animation - if (await taskElement.isVisible()) { - // Wait for animation and task removal - await expect(taskElement).toHaveCount(0, { timeout: 5000 }); - } + // Wait for the celebration animation and task removal (3s delay + 1s buffer) + await page.waitForTimeout(4000); + + // Verify task is removed from DOM + await expect(taskElement).toHaveCount(0); }); - await test.step('Verify task is completed via API', async () => { - await tasksApi.expectTaskStatus('core-blogdescription', 'trash'); + await test.step('Verify task is no longer active via API', async () => { + const task = await tasksApi.getTask('core-blogdescription'); + // Task should either be trash or undefined (fully deleted) + if (task) { + expect(task.post_status).toBe('trash'); + } + // If task is undefined, it was deleted which is also acceptable }); }); }); diff --git a/tests/e2e/specs/todo-complete.spec.ts b/tests/e2e/specs/todo-complete.spec.ts index 0a27e5f43d..c64c4cd562 100644 --- a/tests/e2e/specs/todo-complete.spec.ts +++ b/tests/e2e/specs/todo-complete.spec.ts @@ -1,67 +1,75 @@ import { test, expect } from '../fixtures/base.fixture'; +const TEST_TASK_TEXT = 'Task to be completed'; + test.describe('Todo Completion', () => { // Enable cleanup for this test suite test.use({ cleanupAfterTest: true }); - test('should complete a todo and move it to completed list', async ({ dashboard }) => { - const taskText = 'Task to complete ' + Date.now(); + test('should create task and mark as completed', async ({ page, dashboard }) => { + let taskSelector: string; + + await test.step('Navigate and create the task', async () => { + await page.fill('#new-todo-content', TEST_TASK_TEXT); + await page.keyboard.press('Enter'); + await page.waitForTimeout(1500); - await test.step('Create a todo', async () => { - await dashboard.createTodo(taskText); - const items = await dashboard.getTodoItems(); - expect(items).toHaveLength(1); + // Get the task selector + const todoItem = page.locator('ul#todo-list > li'); + const taskId = await todoItem.getAttribute('data-task-id'); + taskSelector = `li[data-task-id="${taskId}"]`; }); - await test.step('Complete the todo', async () => { - const item = await dashboard.getTodoByText(taskText); - await dashboard.completeTodo(item); + await test.step('Complete the task', async () => { + const todoItemElement = page.locator(`ul#todo-list ${taskSelector}`); + await todoItemElement.locator('label').click(); + await page.waitForTimeout(1000); }); - await test.step('Verify todo moved to completed list', async () => { - // Active list should be empty - const activeItems = await dashboard.getTodoItems(); - expect(activeItems).toHaveLength(0); + await test.step('Verify task is not in active list', async () => { + await expect(page.locator(`ul#todo-list ${taskSelector}`)).toHaveCount(0); + }); - // Completed list should have the task - await dashboard.openCompletedTasks(); - const completedItems = await dashboard.getCompletedItems(); - expect(completedItems.length).toBeGreaterThan(0); + await test.step('Open completed tasks and verify', async () => { + await page.locator('details#todo-list-completed-details').click(); + + // Verify task is in completed list with correct state + const completedTask = page.locator(`ul#todo-list-completed ${taskSelector}`); + await expect(completedTask).toBeVisible(); + await expect(completedTask.locator('h3 > span')).toHaveText(TEST_TASK_TEXT); + await expect(completedTask.locator('.prpl-suggested-task-checkbox')).toBeChecked(); }); }); - test('should verify task status via API after completion', async ({ dashboard, tasksApi }) => { - const taskText = 'API verified task ' + Date.now(); + test('should verify completed task persists after reload', async ({ page, dashboard }) => { + let taskSelector: string; - let taskId: string; + await test.step('Create and complete a task', async () => { + await page.fill('#new-todo-content', TEST_TASK_TEXT); + await page.keyboard.press('Enter'); + await page.waitForTimeout(1500); - await test.step('Create a todo', async () => { - const result = await dashboard.createTodo(taskText); - taskId = result.taskId; - }); + // Get the task selector + const todoItem = page.locator('ul#todo-list > li'); + const taskId = await todoItem.getAttribute('data-task-id'); + taskSelector = `li[data-task-id="${taskId}"]`; - await test.step('Complete the todo', async () => { - const item = await dashboard.getTodoByText(taskText); - await dashboard.completeTodo(item); - }); - - await test.step('Verify task status via API', async () => { - await tasksApi.expectTaskStatus(taskId, 'trash'); - }); - }); + // Complete the task + const todoItemElement = page.locator(`ul#todo-list ${taskSelector}`); + await todoItemElement.locator('label').click(); + await page.waitForTimeout(1500); - test('should complete suggested task and decrease count', async ({ dashboard }) => { - await test.step('Complete a suggested task', async () => { - const { taskId, previousCount } = await dashboard.completeSuggestedTask(); + // Verify task is not in active list + await expect(page.locator(`ul#todo-list ${taskSelector}`)).toHaveCount(0); - if (taskId === null) { - test.skip(); - return; - } + // Open completed tasks + await page.locator('details#todo-list-completed-details').click(); - // Wait for the task count to decrease - const newCount = await dashboard.getSuggestedTasksCount(); - expect(newCount).toBeLessThan(previousCount); + // Verify task is in completed list + const completedTask = page.locator(`ul#todo-list-completed ${taskSelector}`); + await expect(completedTask).toBeVisible(); + await expect(completedTask.locator('h3 > span')).toHaveText(TEST_TASK_TEXT); + await expect(completedTask.locator('.prpl-suggested-task-checkbox')).toBeChecked(); }); }); }); diff --git a/tests/e2e/specs/todo-crud.spec.ts b/tests/e2e/specs/todo-crud.spec.ts index 5d0772e835..4aeaa31e81 100644 --- a/tests/e2e/specs/todo-crud.spec.ts +++ b/tests/e2e/specs/todo-crud.spec.ts @@ -1,66 +1,44 @@ import { test, expect } from '../fixtures/base.fixture'; +const CREATE_TASK_TEXT = 'Test task to create'; +const DELETE_TASK_TEXT = 'Test task to delete'; + test.describe('Todo CRUD Operations', () => { // Enable cleanup for this test suite test.use({ cleanupAfterTest: true }); - test('should create a new todo', async ({ dashboard }) => { - const taskText = 'Test task created at ' + Date.now(); - + test('should create a new todo', async ({ page, dashboard }) => { await test.step('Create the todo', async () => { - const { taskId, element } = await dashboard.createTodo(taskText); - - expect(taskId).toBeTruthy(); - await expect(element).toBeVisible(); + await page.fill('#new-todo-content', CREATE_TASK_TEXT); + await page.keyboard.press('Enter'); + await page.waitForTimeout(500); }); - await test.step('Verify todo text is correct', async () => { - const items = await dashboard.getTodoItems(); - expect(items).toHaveLength(1); - - const text = await dashboard.getTodoText(items[0]); - expect(text).toBe(taskText); + await test.step('Verify todo was created', async () => { + const todoItem = page.locator('ul#todo-list > li'); + await expect(todoItem).toHaveCount(1); + await expect(todoItem.locator('h3 > span')).toHaveText(CREATE_TASK_TEXT); }); }); - test('should delete a todo', async ({ dashboard }) => { - const taskText = 'Task to be deleted'; - + test('should delete a todo', async ({ page, dashboard }) => { await test.step('Create a todo to delete', async () => { - await dashboard.createTodo(taskText); - const items = await dashboard.getTodoItems(); - expect(items).toHaveLength(1); + await page.fill('#new-todo-content', DELETE_TASK_TEXT); + await page.keyboard.press('Enter'); + await page.waitForTimeout(500); }); await test.step('Delete the todo', async () => { - const item = await dashboard.getTodoByText(taskText); - await dashboard.deleteTodo(item); + const deleteItem = page.locator('ul#todo-list > li'); + await deleteItem.hover(); + await deleteItem.waitFor({ state: 'visible' }); + await deleteItem.locator('.prpl-suggested-task-actions-wrapper .trash').click(); + await page.waitForTimeout(1500); }); await test.step('Verify todo was deleted', async () => { - const items = await dashboard.getTodoItems(); - expect(items).toHaveLength(0); - }); - }); - - test('should persist todo after page reload', async ({ dashboard, page }) => { - const taskText = 'Persistent task ' + Date.now(); - - await test.step('Create a todo', async () => { - await dashboard.createTodo(taskText); - }); - - await test.step('Reload the page', async () => { - await page.reload(); - await dashboard.waitForReady(); - }); - - await test.step('Verify todo still exists', async () => { - const items = await dashboard.getTodoItems(); - expect(items).toHaveLength(1); - - const text = await dashboard.getTodoText(items[0]); - expect(text).toBe(taskText); + const todoItem = page.locator('ul#todo-list > li'); + await expect(todoItem).toHaveCount(0); }); }); }); diff --git a/tests/e2e/specs/todo-reorder.spec.ts b/tests/e2e/specs/todo-reorder.spec.ts index 2d48ffc970..8bd9adc4a9 100644 --- a/tests/e2e/specs/todo-reorder.spec.ts +++ b/tests/e2e/specs/todo-reorder.spec.ts @@ -1,91 +1,65 @@ import { test, expect } from '../fixtures/base.fixture'; +const FIRST_TASK_TEXT = 'First task to reorder'; +const SECOND_TASK_TEXT = 'Second task to reorder'; +const THIRD_TASK_TEXT = 'Third task to reorder'; + test.describe('Todo Reorder Operations', () => { // Enable cleanup for this test suite test.use({ cleanupAfterTest: true }); - test('should reorder todos using move down button', async ({ dashboard }) => { - await test.step('Create two todos', async () => { - await dashboard.createTodo('First task'); - await dashboard.createTodo('Second task'); - - const items = await dashboard.getTodoItems(); - expect(items).toHaveLength(2); - }); - - await test.step('Move first task down', async () => { - const items = await dashboard.getTodoItems(); - const firstItem = items[0]; - - await dashboard.moveTodoDown(firstItem); - }); - - await test.step('Verify order changed', async () => { - const items = await dashboard.getTodoItems(); - expect(items).toHaveLength(2); + test('should reorder todo items', async ({ page, dashboard }) => { + await test.step('Create three todos', async () => { + await page.fill('#new-todo-content', FIRST_TASK_TEXT); + await page.keyboard.press('Enter'); + await page.waitForTimeout(1500); - // After moving down, "Second task" should be first - const firstText = await dashboard.getTodoText(items[0]); - const secondText = await dashboard.getTodoText(items[1]); + await page.fill('#new-todo-content', SECOND_TASK_TEXT); + await page.keyboard.press('Enter'); + await page.waitForTimeout(1500); - expect(firstText).toBe('Second task'); - expect(secondText).toBe('First task'); + await page.fill('#new-todo-content', THIRD_TASK_TEXT); + await page.keyboard.press('Enter'); + await page.waitForTimeout(1500); }); - }); - test('should reorder todos using move up button', async ({ dashboard }) => { - await test.step('Create two todos', async () => { - await dashboard.createTodo('First task'); - await dashboard.createTodo('Second task'); + await test.step('Verify initial order', async () => { + const todoItems = page.locator('ul#todo-list > li'); + const items = await todoItems.all(); - const items = await dashboard.getTodoItems(); - expect(items).toHaveLength(2); + await expect(items[0].locator('h3 > span')).toHaveText(FIRST_TASK_TEXT); + await expect(items[1].locator('h3 > span')).toHaveText(SECOND_TASK_TEXT); + await expect(items[2].locator('h3 > span')).toHaveText(THIRD_TASK_TEXT); }); - await test.step('Move second task up', async () => { - const items = await dashboard.getTodoItems(); - const secondItem = items[1]; + await test.step('Move second item down', async () => { + const todoItems = page.locator('ul#todo-list > li'); + const items = await todoItems.all(); - await dashboard.moveTodoUp(secondItem); + await items[1].hover(); + await items[1].locator('.prpl-suggested-task-button.move-down').click(); + await page.waitForTimeout(1500); }); - await test.step('Verify order changed', async () => { - const items = await dashboard.getTodoItems(); - expect(items).toHaveLength(2); - - // After moving up, "First task" should be second - const firstText = await dashboard.getTodoText(items[0]); - const secondText = await dashboard.getTodoText(items[1]); + await test.step('Verify new order', async () => { + const todoItems = page.locator('ul#todo-list > li'); + const reorderedItems = await todoItems.all(); - expect(firstText).toBe('First task'); - expect(secondText).toBe('Second task'); + await expect(reorderedItems[0].locator('h3 > span')).toHaveText(FIRST_TASK_TEXT); + await expect(reorderedItems[1].locator('h3 > span')).toHaveText(THIRD_TASK_TEXT); + await expect(reorderedItems[2].locator('h3 > span')).toHaveText(SECOND_TASK_TEXT); }); - }); - - test('should persist order after page reload', async ({ dashboard, page }) => { - await test.step('Create and reorder todos', async () => { - await dashboard.createTodo('Task A'); - await dashboard.createTodo('Task B'); - await dashboard.createTodo('Task C'); - // Move Task C up twice to make it first - let items = await dashboard.getTodoItems(); - await dashboard.moveTodoUp(items[2]); // C is now second - items = await dashboard.getTodoItems(); - await dashboard.moveTodoUp(items[1]); // C is now first - }); - - await test.step('Reload the page', async () => { + await test.step('Reload page and verify order persists', async () => { await page.reload(); - await dashboard.waitForReady(); - }); + await page.waitForLoadState('networkidle'); - await test.step('Verify order persisted', async () => { - const items = await dashboard.getTodoItems(); - expect(items).toHaveLength(3); + const todoItems = page.locator('ul#todo-list > li'); + const persistedItems = await todoItems.all(); - const firstText = await dashboard.getTodoText(items[0]); - expect(firstText).toBe('Task C'); + await expect(persistedItems[0].locator('h3 > span')).toHaveText(FIRST_TASK_TEXT); + await expect(persistedItems[1].locator('h3 > span')).toHaveText(THIRD_TASK_TEXT); + await expect(persistedItems[2].locator('h3 > span')).toHaveText(SECOND_TASK_TEXT); }); }); }); diff --git a/tests/e2e/specs/tour.spec.ts b/tests/e2e/specs/tour.spec.ts index b29cb492ea..c741b8c8b2 100644 --- a/tests/e2e/specs/tour.spec.ts +++ b/tests/e2e/specs/tour.spec.ts @@ -1,49 +1,44 @@ -import { test, expect } from '../fixtures/base.fixture'; +import { test, expect } from '@playwright/test'; test.describe('Progress Planner Tour', () => { - test('should complete the tour from start to finish', async ({ dashboard }) => { - await test.step('Start the tour', async () => { - await dashboard.startTour(); - expect(await dashboard.isTourVisible()).toBe(true); - }); - - await test.step('Navigate through all tour steps', async () => { - const stepsCount = await dashboard.getTourStepsCount(); - expect(stepsCount).toBeGreaterThan(0); - - // Navigate through all steps except the last one - for (let i = 0; i < stepsCount - 1; i++) { - await expect(dashboard.tourPopover).toBeVisible(); - await dashboard.clickTourNext(); + test('should start the tour when clicking the tour button', async ({ page }) => { + // Navigate to Progress Planner dashboard + await page.goto('/wp-admin/admin.php?page=progress-planner'); + await page.waitForLoadState('networkidle'); + + // Click the tour button + const tourButton = page.locator('#prpl-start-tour-icon-button'); + await tourButton.click(); + + // Wait for and verify the tour popover is visible + let tourPopover = page.locator('.driver-popover'); + await expect(tourPopover).toBeVisible(); + + // Get the number of steps from the window object + const numberOfSteps = await page.evaluate( + () => (window as unknown as { progressPlannerTour: { steps: unknown[] } }).progressPlannerTour.steps.length + ); + + for (let i = 0; i < numberOfSteps - 1; i++) { + tourPopover = page.locator('.driver-popover'); + + // Wait for the popover to be visible before interacting + await expect(tourPopover).toBeVisible(); + + // Click the "Next" button if it's not the last step + if (i < numberOfSteps - 1) { + const nextButton = page.locator('.driver-popover-next-btn'); + await nextButton.click(); } - }); + } - await test.step('Verify finish button on last step', async () => { - const buttonText = await dashboard.getTourNextButtonText(); - expect(buttonText).toBe('Finish'); - }); + const nextButton = page.locator('.driver-popover-next-btn'); - await test.step('Complete the tour', async () => { - await dashboard.clickTourNext(); - await expect(dashboard.tourPopover).not.toBeVisible(); - }); - }); - - test('should be able to start tour multiple times', async ({ dashboard, page }) => { - await test.step('Complete tour first time', async () => { - await dashboard.completeTour(); - }); - - await test.step('Reload and start tour again', async () => { - await page.reload(); - await dashboard.waitForReady(); - - await dashboard.startTour(); - expect(await dashboard.isTourVisible()).toBe(true); - }); + // Verify the button text changes to "Finish" on the last step + await expect(nextButton).toHaveText('Finish'); - await test.step('Complete tour second time', async () => { - await dashboard.completeTour(); - }); + // Click the finish button and verify the tour popover closes + await nextButton.click(); + await expect(tourPopover).not.toBeVisible(); }); }); diff --git a/tests/e2e/specs/yoast-integration.spec.ts b/tests/e2e/specs/yoast-integration.spec.ts index 422b3e94c6..bf07f0c067 100644 --- a/tests/e2e/specs/yoast-integration.spec.ts +++ b/tests/e2e/specs/yoast-integration.spec.ts @@ -1,55 +1,75 @@ -import { test, expect } from '../fixtures/base.fixture'; - -test.describe('Yoast SEO Integration', () => { - test('should show Ravi icon on Yoast crawl optimization page', async ({ yoastSettings }) => { - await test.step('Navigate to crawl optimization page', async () => { - await yoastSettings.gotoCrawlOptimization(); - }); - - await test.step('Find feed comments toggle', async () => { - const toggleHeader = await yoastSettings.getFeedCommentsToggleHeader(); - await expect(toggleHeader).toBeVisible(); - }); - - await test.step('Verify Ravi icon is present', async () => { - const toggleHeader = await yoastSettings.getFeedCommentsToggleHeader(); - await yoastSettings.verifyRaviIcon(toggleHeader); - }); - }); +import { test, expect } from '@playwright/test'; + +test.describe('Yoast Focus Element', () => { + test('should add Ravi icon to the feed comments toggle', async ({ page }) => { + await page.goto('/wp-admin/admin.php?page=wpseo_page_settings#/crawl-optimization'); + + // Skip if Yoast settings page doesn't load (not installed or wrong version) + if (await page.locator('text=Sorry, you are not allowed').isVisible()) { + test.skip(); + return; + } + + // If there is a modal with overlay (which prevents clicks), close it. + const closeButton = page.locator('button.yst-modal__close-button'); + if (await closeButton.isVisible()) { + await closeButton.click(); + } + + // Wait for the page to load and the toggle to be visible + await page.waitForSelector('button[data-id="input-wpseo-remove_feed_global_comments"]'); + + // Find the toggle input + const toggleInput = page.locator('button[data-id="input-wpseo-remove_feed_global_comments"]'); - test('should show Ravi icon on Yoast site representation page', async ({ yoastSettings }) => { - await test.step('Navigate to site representation page', async () => { - await yoastSettings.gotoSiteRepresentation(); - }); + // Find the parent toggle field header + const toggleHeader = toggleInput.locator('xpath=ancestor::div[contains(@class, "yst-toggle-field__header")]'); - await test.step('Find company logo label', async () => { - const logoLabel = await yoastSettings.getCompanyLogoLabel(); - await expect(logoLabel).toBeVisible(); - }); + // Verify the Ravi icon exists within the toggle header + const raviIconWrapper = toggleHeader.locator('[data-prpl-element="ravi-icon"]'); + await expect(raviIconWrapper).toBeVisible(); - await test.step('Verify Ravi icon is present', async () => { - const logoLabel = await yoastSettings.getCompanyLogoLabel(); - await yoastSettings.verifyRaviIcon(logoLabel); - }); + // Verify the icon image exists and has correct attributes + const iconImg = raviIconWrapper.locator('img'); + await expect(iconImg).toBeVisible(); + await expect(iconImg).toHaveAttribute('alt', 'Ravi'); + await expect(iconImg).toHaveAttribute('width', '16'); + await expect(iconImg).toHaveAttribute('height', '16'); + + // Verify that the icon is not checked + await expect(raviIconWrapper.locator('.prpl-form-row-points')).toHaveText('+1'); + + // Now click the toggle + await toggleInput.click(); + + // Verify that the icon is now checked + await expect(raviIconWrapper.locator('.prpl-form-row-points')).toHaveText('✓'); }); - test('should update Ravi icon state after completing task', async ({ yoastSettings }) => { - await test.step('Navigate to crawl optimization page', async () => { - await yoastSettings.gotoCrawlOptimization(); - }); + test('should add Ravi icon to the company logo upload field', async ({ page }) => { + await page.goto('/wp-admin/admin.php?page=wpseo_page_settings#/site-representation'); + + // Skip if Yoast settings page doesn't load + if (await page.locator('text=Sorry, you are not allowed').isVisible()) { + test.skip(); + return; + } + + // Wait for the company logo label to be visible + await page.waitForSelector('#wpseo_titles-company_logo legend.yst-label'); - await test.step('Verify initial uncompleted state', async () => { - const toggleHeader = await yoastSettings.getFeedCommentsToggleHeader(); - await yoastSettings.verifyRaviIconUncompleted(toggleHeader); - }); + // Find the label element + const logoLabel = page.locator('#wpseo_titles-company_logo legend.yst-label'); - await test.step('Toggle the setting', async () => { - await yoastSettings.clickFeedCommentsToggle(); - }); + // Verify the Ravi icon exists within the label + const raviIcon = logoLabel.locator('[data-prpl-element="ravi-icon"]'); + await expect(raviIcon).toBeVisible(); - await test.step('Verify completed state', async () => { - const toggleHeader = await yoastSettings.getFeedCommentsToggleHeader(); - await yoastSettings.verifyRaviIconCompleted(toggleHeader); - }); + // Verify the icon image exists and has correct attributes + const iconImg = raviIcon.locator('img'); + await expect(iconImg).toBeVisible(); + await expect(iconImg).toHaveAttribute('alt', 'Ravi'); + await expect(iconImg).toHaveAttribute('width', '16'); + await expect(iconImg).toHaveAttribute('height', '16'); }); }); From e8148c7674892727dab254d540b1b0bfd282ed3a Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Tue, 30 Dec 2025 12:20:47 +0100 Subject: [PATCH 4/7] Always reuse if server is already running --- playwright.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 27cc0068f8..a85d661069 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -99,7 +99,8 @@ export default defineConfig({ webServer: { command: 'npx @wp-playground/cli server --mount=.:/wordpress/wp-content/plugins/progress-planner --blueprint=tests/e2e/blueprint.json --port=8080', url: 'http://localhost:8080', - reuseExistingServer: !process.env.CI, + // Always reuse if server is already running (needed for Yoast tests which use Docker WordPress) + reuseExistingServer: true, timeout: 120 * 1000, }, }); From 1ed8e53ec6658826545e92a18f075827fbd995e1 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Tue, 30 Dec 2025 12:35:08 +0100 Subject: [PATCH 5/7] tabs not spaces --- tests/e2e/api/tasks.api.ts | 254 +++---- tests/e2e/fixtures/base.fixture.ts | 123 ++-- tests/e2e/fixtures/playground.fixture.ts | 209 +++--- tests/e2e/pages/base.page.ts | 172 ++--- tests/e2e/pages/dashboard.page.ts | 824 ++++++++++++---------- tests/e2e/pages/yoast-settings.page.ts | 303 ++++---- tests/e2e/specs/onboarding.spec.ts | 120 ++-- tests/e2e/specs/task-dismissible.spec.ts | 89 +-- tests/e2e/specs/task-snooze.spec.ts | 116 +-- tests/e2e/specs/task-tagline.spec.ts | 132 ++-- tests/e2e/specs/todo-complete.spec.ts | 168 +++-- tests/e2e/specs/todo-crud.spec.ts | 72 +- tests/e2e/specs/todo-reorder.spec.ts | 114 +-- tests/e2e/specs/tour.spec.ts | 91 +-- tests/e2e/specs/yoast-integration.spec.ts | 174 +++-- 15 files changed, 1617 insertions(+), 1344 deletions(-) diff --git a/tests/e2e/api/tasks.api.ts b/tests/e2e/api/tasks.api.ts index 3975573a9b..a3e06d16ca 100644 --- a/tests/e2e/api/tasks.api.ts +++ b/tests/e2e/api/tasks.api.ts @@ -1,11 +1,11 @@ import { Page, APIRequestContext } from '@playwright/test'; export interface Task { - ID: number; - post_name: string; - post_status: 'publish' | 'pending' | 'future' | 'trash'; - post_title: string; - post_date: string; + ID: number; + post_name: string; + post_status: 'publish' | 'pending' | 'future' | 'trash'; + post_title: string; + post_date: string; } /** @@ -13,117 +13,135 @@ export interface Task { * Uses the authenticated session from the page context. */ export class TasksApi { - private readonly page: Page; - private readonly request: APIRequestContext; - private readonly baseUrl: string; - - constructor(page: Page, request: APIRequestContext) { - this.page = page; - this.request = request; - this.baseUrl = process.env.WORDPRESS_URL || 'http://localhost:8080'; - } - - /** - * Get cookies from the page context for authenticated requests. - */ - private async getAuthCookies(): Promise> { - return await this.page.context().cookies(); - } - - /** - * Make an authenticated GET request to the REST API. - */ - private async get(endpoint: string): Promise { - // Suppress unused variable warning - cookies kept for future auth needs - void this.getAuthCookies(); - - const params: Record = {}; - // Use test token from environment or fallback to the value set in blueprint.json - const testToken = process.env.PRPL_TEST_TOKEN || '0220a2de67fc29094281088395939f58'; - params.token = testToken; - - const response = await this.request.get( - `${this.baseUrl}/?rest_route=${endpoint}`, - { - headers: { - 'Content-Type': 'application/json', - }, - params, - } - ); - - if (!response.ok()) { - throw new Error(`API request failed: ${response.status()} ${await response.text()}`); - } - - return await response.json(); - } - - /** - * Get all tasks. - */ - async getAllTasks(): Promise { - return await this.get('/progress-planner/v1/tasks'); - } - - /** - * Get a task by its slug/post_name. - */ - async getTask(taskId: string): Promise { - const tasks = await this.getAllTasks(); - return tasks.find((task) => task.post_name === taskId); - } - - /** - * Get tasks by status. - */ - async getTasksByStatus(status: Task['post_status']): Promise { - const tasks = await this.getAllTasks(); - return tasks.filter((task) => task.post_status === status); - } - - /** - * Assert that a task has a specific status. - */ - async expectTaskStatus(taskId: string, expectedStatus: Task['post_status']): Promise { - const task = await this.getTask(taskId); - - if (!task) { - throw new Error(`Task "${taskId}" not found`); - } - - if (task.post_status !== expectedStatus) { - throw new Error( - `Task "${taskId}" has status "${task.post_status}", expected "${expectedStatus}"` - ); - } - } - - /** - * Wait for a task to reach a specific status. - * Polls the API until the status matches or timeout. - */ - async waitForTaskStatus( - taskId: string, - expectedStatus: Task['post_status'], - options: { timeout?: number; interval?: number } = {} - ): Promise { - const timeout = options.timeout ?? 10000; - const interval = options.interval ?? 500; - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const task = await this.getTask(taskId); - - if (task?.post_status === expectedStatus) { - return task; - } - - await new Promise((resolve) => setTimeout(resolve, interval)); - } - - throw new Error( - `Timeout waiting for task "${taskId}" to have status "${expectedStatus}"` - ); - } + private readonly page: Page; + private readonly request: APIRequestContext; + private readonly baseUrl: string; + + constructor( page: Page, request: APIRequestContext ) { + this.page = page; + this.request = request; + this.baseUrl = process.env.WORDPRESS_URL || 'http://localhost:8080'; + } + + /** + * Get cookies from the page context for authenticated requests. + */ + private async getAuthCookies(): Promise< + Array< { name: string; value: string } > + > { + return await this.page.context().cookies(); + } + + /** + * Make an authenticated GET request to the REST API. + * @param endpoint + */ + private async get< T >( endpoint: string ): Promise< T > { + // Suppress unused variable warning - cookies kept for future auth needs + void this.getAuthCookies(); + + const params: Record< string, string > = {}; + // Use test token from environment or fallback to the value set in blueprint.json + const testToken = + process.env.PRPL_TEST_TOKEN || '0220a2de67fc29094281088395939f58'; + params.token = testToken; + + const response = await this.request.get( + `${ this.baseUrl }/?rest_route=${ endpoint }`, + { + headers: { + 'Content-Type': 'application/json', + }, + params, + } + ); + + if ( ! response.ok() ) { + throw new Error( + `API request failed: ${ response.status() } ${ await response.text() }` + ); + } + + return await response.json(); + } + + /** + * Get all tasks. + */ + async getAllTasks(): Promise< Task[] > { + return await this.get< Task[] >( '/progress-planner/v1/tasks' ); + } + + /** + * Get a task by its slug/post_name. + * @param taskId + */ + async getTask( taskId: string ): Promise< Task | undefined > { + const tasks = await this.getAllTasks(); + return tasks.find( ( task ) => task.post_name === taskId ); + } + + /** + * Get tasks by status. + * @param status + */ + async getTasksByStatus( status: Task[ 'post_status' ] ): Promise< Task[] > { + const tasks = await this.getAllTasks(); + return tasks.filter( ( task ) => task.post_status === status ); + } + + /** + * Assert that a task has a specific status. + * @param taskId + * @param expectedStatus + */ + async expectTaskStatus( + taskId: string, + expectedStatus: Task[ 'post_status' ] + ): Promise< void > { + const task = await this.getTask( taskId ); + + if ( ! task ) { + throw new Error( `Task "${ taskId }" not found` ); + } + + if ( task.post_status !== expectedStatus ) { + throw new Error( + `Task "${ taskId }" has status "${ task.post_status }", expected "${ expectedStatus }"` + ); + } + } + + /** + * Wait for a task to reach a specific status. + * Polls the API until the status matches or timeout. + * @param taskId + * @param expectedStatus + * @param options + * @param options.timeout + * @param options.interval + */ + async waitForTaskStatus( + taskId: string, + expectedStatus: Task[ 'post_status' ], + options: { timeout?: number; interval?: number } = {} + ): Promise< Task > { + const timeout = options.timeout ?? 10000; + const interval = options.interval ?? 500; + const startTime = Date.now(); + + while ( Date.now() - startTime < timeout ) { + const task = await this.getTask( taskId ); + + if ( task?.post_status === expectedStatus ) { + return task; + } + + await new Promise( ( resolve ) => setTimeout( resolve, interval ) ); + } + + throw new Error( + `Timeout waiting for task "${ taskId }" to have status "${ expectedStatus }"` + ); + } } diff --git a/tests/e2e/fixtures/base.fixture.ts b/tests/e2e/fixtures/base.fixture.ts index c9e2392830..435d610b09 100644 --- a/tests/e2e/fixtures/base.fixture.ts +++ b/tests/e2e/fixtures/base.fixture.ts @@ -7,34 +7,34 @@ import { TasksApi } from '../api/tasks.api'; * Custom fixture types for Progress Planner E2E tests. */ type ProgressPlannerFixtures = { - /** - * Dashboard page object with all Progress Planner dashboard functionality. - * Automatically navigates to the dashboard. - */ - dashboard: DashboardPage; + /** + * Dashboard page object with all Progress Planner dashboard functionality. + * Automatically navigates to the dashboard. + */ + dashboard: DashboardPage; - /** - * Dashboard page object without automatic navigation. - * Use when you need to go somewhere else first. - */ - dashboardPage: DashboardPage; + /** + * Dashboard page object without automatic navigation. + * Use when you need to go somewhere else first. + */ + dashboardPage: DashboardPage; - /** - * Yoast SEO settings page object. - * Use for testing Yoast integration features. - */ - yoastSettings: YoastSettingsPage; + /** + * Yoast SEO settings page object. + * Use for testing Yoast integration features. + */ + yoastSettings: YoastSettingsPage; - /** - * REST API client for direct task manipulation. - */ - tasksApi: TasksApi; + /** + * REST API client for direct task manipulation. + */ + tasksApi: TasksApi; - /** - * Automatic cleanup after each test. - * Set to true to enable. - */ - cleanupAfterTest: boolean; + /** + * Automatic cleanup after each test. + * Set to true to enable. + */ + cleanupAfterTest: boolean; }; /** @@ -50,48 +50,51 @@ type ProgressPlannerFixtures = { * }); * ``` */ -export const test = base.extend({ - // Default: no automatic cleanup - cleanupAfterTest: [false, { option: true }], +export const test = base.extend< ProgressPlannerFixtures >( { + // Default: no automatic cleanup + cleanupAfterTest: [ false, { option: true } ], - // Dashboard page object (no auto-navigation) - dashboardPage: async ({ page }, use) => { - const dashboardPage = new DashboardPage(page); - await use(dashboardPage); - }, + // Dashboard page object (no auto-navigation) + dashboardPage: async ( { page }, use ) => { + const dashboardPage = new DashboardPage( page ); + await use( dashboardPage ); + }, - // Dashboard with auto-navigation - dashboard: async ({ page, cleanupAfterTest }, use) => { - const dashboard = new DashboardPage(page); - await dashboard.goto(); + // Dashboard with auto-navigation + dashboard: async ( { page, cleanupAfterTest }, use ) => { + const dashboard = new DashboardPage( page ); + await dashboard.goto(); - await use(dashboard); + await use( dashboard ); - // Cleanup after test if enabled - // Note: Cleanup is best-effort and should not affect test results - if (cleanupAfterTest) { - // Set a short timeout for the entire cleanup operation - await Promise.race([ - dashboard.deleteAllTodos().catch((err) => { - console.warn('[Fixture Cleanup] Failed:', (err as Error).message); - }), - new Promise((resolve) => setTimeout(resolve, 10000)), // 10s max for cleanup - ]); - } - }, + // Cleanup after test if enabled + // Note: Cleanup is best-effort and should not affect test results + if ( cleanupAfterTest ) { + // Set a short timeout for the entire cleanup operation + await Promise.race( [ + dashboard.deleteAllTodos().catch( ( err ) => { + console.warn( + '[Fixture Cleanup] Failed:', + ( err as Error ).message + ); + } ), + new Promise( ( resolve ) => setTimeout( resolve, 10000 ) ), // 10s max for cleanup + ] ); + } + }, - // Yoast settings page object (no auto-navigation) - yoastSettings: async ({ page }, use) => { - const yoastSettings = new YoastSettingsPage(page); - await use(yoastSettings); - }, + // Yoast settings page object (no auto-navigation) + yoastSettings: async ( { page }, use ) => { + const yoastSettings = new YoastSettingsPage( page ); + await use( yoastSettings ); + }, - // REST API client - tasksApi: async ({ page, request }, use) => { - const api = new TasksApi(page, request); - await use(api); - }, -}); + // REST API client + tasksApi: async ( { page, request }, use ) => { + const api = new TasksApi( page, request ); + await use( api ); + }, +} ); export { expect } from '@playwright/test'; diff --git a/tests/e2e/fixtures/playground.fixture.ts b/tests/e2e/fixtures/playground.fixture.ts index 42f3583102..0234d4fbe3 100644 --- a/tests/e2e/fixtures/playground.fixture.ts +++ b/tests/e2e/fixtures/playground.fixture.ts @@ -19,102 +19,127 @@ import { spawn, ChildProcess } from 'child_process'; */ type PlaygroundFixtures = { - /** - * URL of the WordPress instance. - */ - wpUrl: string; - - /** - * Whether the Playground server is ready. - */ - playgroundReady: boolean; + /** + * URL of the WordPress instance. + */ + wpUrl: string; + + /** + * Whether the Playground server is ready. + */ + playgroundReady: boolean; }; type PlaygroundWorkerFixtures = { - /** - * The Playground server process (shared per worker). - */ - playgroundServer: { url: string; process: ChildProcess }; + /** + * The Playground server process (shared per worker). + */ + playgroundServer: { url: string; process: ChildProcess }; }; -export const test = base.extend({ - // Worker-scoped: one Playground server per test worker - playgroundServer: [ - async ({}, use, workerInfo) => { - const port = 9400 + workerInfo.workerIndex; - const url = `http://127.0.0.1:${port}`; - - console.log(`[Worker ${workerInfo.workerIndex}] Starting Playground on port ${port}...`); - - // Start Playground server - const serverProcess = spawn('npx', [ - '@wp-playground/cli@latest', - 'server', - `--port=${port}`, - '--login', - '--wp=latest', - '--php=8.3', - // Mount plugin if in the right directory - '--auto-mount', - ], { - stdio: ['ignore', 'pipe', 'pipe'], - shell: true, - }); - - // Wait for server to be ready - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Playground server failed to start within 60s')); - }, 60000); - - serverProcess.stdout?.on('data', (data: Buffer) => { - const output = data.toString(); - console.log(`[Playground] ${output}`); - - if (output.includes('WordPress is running') || output.includes(url)) { - clearTimeout(timeout); - resolve(); - } - }); - - serverProcess.stderr?.on('data', (data: Buffer) => { - console.error(`[Playground Error] ${data.toString()}`); - }); - - serverProcess.on('error', (err) => { - clearTimeout(timeout); - reject(err); - }); - - serverProcess.on('exit', (code) => { - if (code !== 0 && code !== null) { - clearTimeout(timeout); - reject(new Error(`Playground exited with code ${code}`)); - } - }); - }); - - console.log(`[Worker ${workerInfo.workerIndex}] Playground ready at ${url}`); - - await use({ url, process: serverProcess }); - - // Cleanup: stop the server - console.log(`[Worker ${workerInfo.workerIndex}] Stopping Playground...`); - serverProcess.kill('SIGTERM'); - }, - { scope: 'worker', timeout: 120000 }, - ], - - // Test-scoped: provide the URL to each test - wpUrl: async ({ playgroundServer }, use) => { - await use(playgroundServer.url); - }, - - playgroundReady: async ({ playgroundServer }, use) => { - // Just ensure playgroundServer is initialized - void playgroundServer; - await use(true); - }, -}); +export const test = base.extend< PlaygroundFixtures, PlaygroundWorkerFixtures >( + { + // Worker-scoped: one Playground server per test worker + playgroundServer: [ + async ( {}, use, workerInfo ) => { + const port = 9400 + workerInfo.workerIndex; + const url = `http://127.0.0.1:${ port }`; + + console.log( + `[Worker ${ workerInfo.workerIndex }] Starting Playground on port ${ port }...` + ); + + // Start Playground server + const serverProcess = spawn( + 'npx', + [ + '@wp-playground/cli@latest', + 'server', + `--port=${ port }`, + '--login', + '--wp=latest', + '--php=8.3', + // Mount plugin if in the right directory + '--auto-mount', + ], + { + stdio: [ 'ignore', 'pipe', 'pipe' ], + shell: true, + } + ); + + // Wait for server to be ready + await new Promise< void >( ( resolve, reject ) => { + const timeout = setTimeout( () => { + reject( + new Error( + 'Playground server failed to start within 60s' + ) + ); + }, 60000 ); + + serverProcess.stdout?.on( 'data', ( data: Buffer ) => { + const output = data.toString(); + console.log( `[Playground] ${ output }` ); + + if ( + output.includes( 'WordPress is running' ) || + output.includes( url ) + ) { + clearTimeout( timeout ); + resolve(); + } + } ); + + serverProcess.stderr?.on( 'data', ( data: Buffer ) => { + console.error( + `[Playground Error] ${ data.toString() }` + ); + } ); + + serverProcess.on( 'error', ( err ) => { + clearTimeout( timeout ); + reject( err ); + } ); + + serverProcess.on( 'exit', ( code ) => { + if ( code !== 0 && code !== null ) { + clearTimeout( timeout ); + reject( + new Error( + `Playground exited with code ${ code }` + ) + ); + } + } ); + } ); + + console.log( + `[Worker ${ workerInfo.workerIndex }] Playground ready at ${ url }` + ); + + await use( { url, process: serverProcess } ); + + // Cleanup: stop the server + console.log( + `[Worker ${ workerInfo.workerIndex }] Stopping Playground...` + ); + serverProcess.kill( 'SIGTERM' ); + }, + { scope: 'worker', timeout: 120000 }, + ], + + // Test-scoped: provide the URL to each test + wpUrl: async ( { playgroundServer }, use ) => { + await use( playgroundServer.url ); + }, + + playgroundReady: async ( { playgroundServer }, use ) => { + // Just ensure playgroundServer is initialized + void playgroundServer; + await use( true ); + }, + } +); export { expect } from '@playwright/test'; diff --git a/tests/e2e/pages/base.page.ts b/tests/e2e/pages/base.page.ts index 842f37f508..286c26005c 100644 --- a/tests/e2e/pages/base.page.ts +++ b/tests/e2e/pages/base.page.ts @@ -5,92 +5,106 @@ import { Page, Locator, Response } from '@playwright/test'; * All page objects should extend this class. */ export abstract class BasePage { - readonly page: Page; + readonly page: Page; - constructor(page: Page) { - this.page = page; - } + constructor( page: Page ) { + this.page = page; + } - /** - * Navigate to the page URL. - * Subclasses should override this with their specific URL. - */ - abstract goto(): Promise; + /** + * Navigate to the page URL. + * Subclasses should override this with their specific URL. + */ + abstract goto(): Promise< void >; - /** - * Wait for page to be fully loaded. - * Override in subclasses for page-specific loading indicators. - */ - async waitForReady(): Promise { - await this.page.waitForLoadState('networkidle'); - } + /** + * Wait for page to be fully loaded. + * Override in subclasses for page-specific loading indicators. + */ + async waitForReady(): Promise< void > { + await this.page.waitForLoadState( 'networkidle' ); + } - /** - * Smart wait for an element with automatic retry. - * Much better than waitForTimeout! - */ - protected async waitForElement( - selector: string | Locator, - options: { state?: 'visible' | 'hidden' | 'attached'; timeout?: number } = {} - ): Promise { - const locator = typeof selector === 'string' - ? this.page.locator(selector) - : selector; + /** + * Smart wait for an element with automatic retry. + * Much better than waitForTimeout! + * @param selector + * @param options + * @param options.state + * @param options.timeout + */ + protected async waitForElement( + selector: string | Locator, + options: { + state?: 'visible' | 'hidden' | 'attached'; + timeout?: number; + } = {} + ): Promise< Locator > { + const locator = + typeof selector === 'string' + ? this.page.locator( selector ) + : selector; - await locator.waitFor({ - state: options.state ?? 'visible', - timeout: options.timeout ?? 10000 - }); + await locator.waitFor( { + state: options.state ?? 'visible', + timeout: options.timeout ?? 10000, + } ); - return locator; - } + return locator; + } - /** - * Wait for a REST API response. - * Use instead of arbitrary timeouts after actions. - */ - protected async waitForApiResponse( - urlPattern: string | RegExp, - action: () => Promise - ): Promise { - const [response] = await Promise.all([ - this.page.waitForResponse( - (resp) => { - const url = resp.url(); - return typeof urlPattern === 'string' - ? url.includes(urlPattern) - : urlPattern.test(url); - }, - { timeout: 15000 } - ), - action(), - ]); - return response; - } + /** + * Wait for a REST API response. + * Use instead of arbitrary timeouts after actions. + * @param urlPattern + * @param action + */ + protected async waitForApiResponse( + urlPattern: string | RegExp, + action: () => Promise< void > + ): Promise< Response > { + const [ response ] = await Promise.all( [ + this.page.waitForResponse( + ( resp ) => { + const url = resp.url(); + return typeof urlPattern === 'string' + ? url.includes( urlPattern ) + : urlPattern.test( url ); + }, + { timeout: 15000 } + ), + action(), + ] ); + return response; + } - /** - * Wait for animation to complete. - * Uses requestAnimationFrame instead of fixed timeout. - */ - protected async waitForAnimation(element: Locator): Promise { - await element.evaluate((el) => { - return new Promise((resolve) => { - const animations = el.getAnimations(); - if (animations.length === 0) { - resolve(); - return; - } - Promise.all(animations.map((a) => a.finished)).then(() => resolve()); - }); - }); - } + /** + * Wait for animation to complete. + * Uses requestAnimationFrame instead of fixed timeout. + * @param element + */ + protected async waitForAnimation( element: Locator ): Promise< void > { + await element.evaluate( ( el ) => { + return new Promise< void >( ( resolve ) => { + const animations = el.getAnimations(); + if ( animations.length === 0 ) { + resolve(); + return; + } + Promise.all( animations.map( ( a ) => a.finished ) ).then( () => + resolve() + ); + } ); + } ); + } - /** - * Scroll element into view and wait for it to be stable. - */ - protected async scrollToAndWait(element: Locator): Promise { - await element.scrollIntoViewIfNeeded(); - // Wait for any scroll-triggered animations - await this.page.waitForTimeout(100); - } + /** + * Scroll element into view and wait for it to be stable. + * @param element + */ + protected async scrollToAndWait( element: Locator ): Promise< void > { + await element.scrollIntoViewIfNeeded(); + // Wait for any scroll-triggered animations + await this.page.waitForTimeout( 100 ); + } } diff --git a/tests/e2e/pages/dashboard.page.ts b/tests/e2e/pages/dashboard.page.ts index c709b99b9c..68bdbd1c38 100644 --- a/tests/e2e/pages/dashboard.page.ts +++ b/tests/e2e/pages/dashboard.page.ts @@ -6,386 +6,452 @@ import { BasePage } from './base.page'; * Centralized here for easy maintenance. */ const SELECTORS = { - // Todo lists - todoList: 'ul#todo-list', - todoItem: 'ul#todo-list > li', - todoCompletedList: 'ul#todo-list-completed', - todoCompletedItem: 'ul#todo-list-completed > li', - todoCompletedDetails: 'details#todo-list-completed-details', - - // Todo form - newTodoInput: '#new-todo-content', - - // Task elements - taskItemText: 'h3 > span', - taskCheckbox: '.prpl-suggested-task-checkbox', - taskCheckboxLabel: 'label', - taskActionsWrapper: '.prpl-suggested-task-actions-wrapper', - taskTrashButton: '.trash', - taskMoveUpButton: '.prpl-suggested-task-button.move-up', - taskMoveDownButton: '.prpl-suggested-task-button.move-down', - taskSnoozeButton: 'button[data-action="snooze"]', - - // Suggested tasks - suggestedTasksList: '#prpl-suggested-tasks-list', - suggestedTaskCheckbox: '#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)', - - // Widgets - widgetWrapper: '.prpl-widget-wrapper.prpl-suggested-tasks', - suggestedTasksListWidget: '.prpl-widget-wrapper.prpl-suggested-tasks .prpl-suggested-tasks-list', - - // Onboarding - onboardingPopover: '#prpl-popover-onboarding', - privacyCheckboxLabel: 'label[for="prpl-privacy-checkbox"]', - tourNextButton: '.prpl-tour-next', - tourCloseButton: '#prpl-tour-close-btn', - - // Snooze - snoozeRadioGroup: 'button.prpl-toggle-radio-group', - snoozeDurationRadio: '.prpl-snooze-duration-radio-group input[type="radio"]', - - // Tour (Driver.js based) - tourStartButton: '#prpl-start-tour-icon-button', - tourPopover: '.driver-popover', - tourNextBtn: '.driver-popover-next-btn', - tourPrevBtn: '.driver-popover-prev-btn', - tourCloseBtn: '.driver-popover-close-btn', + // Todo lists + todoList: 'ul#todo-list', + todoItem: 'ul#todo-list > li', + todoCompletedList: 'ul#todo-list-completed', + todoCompletedItem: 'ul#todo-list-completed > li', + todoCompletedDetails: 'details#todo-list-completed-details', + + // Todo form + newTodoInput: '#new-todo-content', + + // Task elements + taskItemText: 'h3 > span', + taskCheckbox: '.prpl-suggested-task-checkbox', + taskCheckboxLabel: 'label', + taskActionsWrapper: '.prpl-suggested-task-actions-wrapper', + taskTrashButton: '.trash', + taskMoveUpButton: '.prpl-suggested-task-button.move-up', + taskMoveDownButton: '.prpl-suggested-task-button.move-down', + taskSnoozeButton: 'button[data-action="snooze"]', + + // Suggested tasks + suggestedTasksList: '#prpl-suggested-tasks-list', + suggestedTaskCheckbox: + '#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)', + + // Widgets + widgetWrapper: '.prpl-widget-wrapper.prpl-suggested-tasks', + suggestedTasksListWidget: + '.prpl-widget-wrapper.prpl-suggested-tasks .prpl-suggested-tasks-list', + + // Onboarding + onboardingPopover: '#prpl-popover-onboarding', + privacyCheckboxLabel: 'label[for="prpl-privacy-checkbox"]', + tourNextButton: '.prpl-tour-next', + tourCloseButton: '#prpl-tour-close-btn', + + // Snooze + snoozeRadioGroup: 'button.prpl-toggle-radio-group', + snoozeDurationRadio: + '.prpl-snooze-duration-radio-group input[type="radio"]', + + // Tour (Driver.js based) + tourStartButton: '#prpl-start-tour-icon-button', + tourPopover: '.driver-popover', + tourNextBtn: '.driver-popover-next-btn', + tourPrevBtn: '.driver-popover-prev-btn', + tourCloseBtn: '.driver-popover-close-btn', } as const; export class DashboardPage extends BasePage { - // Locators (lazy-initialized for performance) - readonly todoList: Locator; - readonly todoCompletedList: Locator; - readonly newTodoInput: Locator; - readonly suggestedTasksList: Locator; - readonly onboardingPopover: Locator; - readonly tourPopover: Locator; - - constructor(page: Page) { - super(page); - this.todoList = page.locator(SELECTORS.todoList); - this.todoCompletedList = page.locator(SELECTORS.todoCompletedList); - this.newTodoInput = page.locator(SELECTORS.newTodoInput); - this.suggestedTasksList = page.locator(SELECTORS.suggestedTasksList); - this.onboardingPopover = page.locator(SELECTORS.onboardingPopover); - this.tourPopover = page.locator(SELECTORS.tourPopover); - } - - async goto(options?: { showAllRecommendations?: boolean }): Promise { - const url = options?.showAllRecommendations - ? '/wp-admin/admin.php?page=progress-planner&prpl_show_all_recommendations' - : '/wp-admin/admin.php?page=progress-planner'; - - await this.page.goto(url); - await this.waitForReady(); - } - - override async waitForReady(): Promise { - await this.page.waitForLoadState('networkidle'); - // Wait for the main dashboard widget to be visible - await this.page.locator(SELECTORS.widgetWrapper).waitFor({ - state: 'visible', - timeout: 10000 - }); - } - - // ================== - // Todo CRUD Operations - // ================== - - async createTodo(text: string): Promise<{ taskId: string; element: Locator }> { - await this.newTodoInput.fill(text); - await this.page.keyboard.press('Enter'); - await this.page.waitForTimeout(500); - - // Find the newly created task - const todoItem = this.page.locator(SELECTORS.todoItem).first(); - await todoItem.waitFor({ state: 'visible' }); - - const taskId = await todoItem.getAttribute('data-task-id'); - if (!taskId) throw new Error('Created todo has no task ID'); - - return { taskId, element: todoItem }; - } - - async getTodoItems(): Promise { - return await this.page.locator(SELECTORS.todoItem).all(); - } - - async getTodoByText(text: string): Promise { - return this.page.locator(SELECTORS.todoItem).filter({ - has: this.page.locator(SELECTORS.taskItemText, { hasText: text }), - }); - } - - async getTodoById(taskId: string): Promise { - return this.page.locator(`li[data-task-id="${taskId}"]`); - } - - async getTodoText(item: Locator): Promise { - return await item.locator(SELECTORS.taskItemText).textContent() ?? ''; - } - - async deleteTodo(item: Locator): Promise { - await this.scrollToAndWait(item); - await item.hover(); - - const trashButton = item.locator(`${SELECTORS.taskActionsWrapper} ${SELECTORS.taskTrashButton}`); - await trashButton.waitFor({ state: 'visible' }); - await trashButton.click(); - await this.page.waitForTimeout(1500); - } - - async completeTodo(item: Locator): Promise { - const label = item.locator(SELECTORS.taskCheckboxLabel); - await label.click(); - await this.page.waitForTimeout(1000); - } - - async moveTodoDown(item: Locator): Promise { - await item.hover(); - const moveDownButton = item.locator(SELECTORS.taskMoveDownButton); - await moveDownButton.waitFor({ state: 'visible' }); - await moveDownButton.click(); - await this.page.waitForTimeout(1500); - } - - async moveTodoUp(item: Locator): Promise { - await item.hover(); - const moveUpButton = item.locator(SELECTORS.taskMoveUpButton); - await moveUpButton.waitFor({ state: 'visible' }); - await moveUpButton.click(); - await this.page.waitForTimeout(1500); - } - - // ================== - // Completed Tasks - // ================== - - async openCompletedTasks(): Promise { - const details = this.page.locator(SELECTORS.todoCompletedDetails); - - // Check if details element exists and is visible - const isVisible = await details.isVisible().catch(() => false); - if (!isVisible) return; - - // Check if already open - const isOpen = await details.getAttribute('open'); - if (isOpen !== null) return; - - await details.click(); - await this.page.locator(SELECTORS.todoCompletedItem).first().waitFor({ - state: 'visible', - timeout: 5000, - }).catch(() => { - // No completed items, that's fine - }); - } - - async getCompletedItems(): Promise { - return await this.page.locator(SELECTORS.todoCompletedItem).all(); - } - - // ================== - // Suggested Tasks - // ================== - - async getSuggestedTasksCount(): Promise { - return await this.page.locator(SELECTORS.suggestedTaskCheckbox).count(); - } - - async completeSuggestedTask(): Promise<{ taskId: string | null; previousCount: number }> { - const initialCount = await this.getSuggestedTasksCount(); - - if (initialCount === 0) { - return { taskId: null, previousCount: 0 }; - } - - const firstCheckbox = this.page.locator(SELECTORS.suggestedTaskCheckbox).first(); - const taskItem = firstCheckbox.locator('xpath=ancestor::li[1]'); - const taskId = await taskItem.getAttribute('data-task-id'); - - // Click the label (parent of checkbox) - const label = firstCheckbox.locator('..'); - await label.click(); - - // Wait for animation - await this.page.waitForTimeout(3000); - - return { taskId, previousCount: initialCount }; - } - - // ================== - // Task Snooze - // ================== - - async snoozeTask(taskId: string, duration: '1-day' | '1-week' | '2-weeks' | '1-month'): Promise { - const taskItem = await this.getTodoById(taskId); - await taskItem.hover(); - - // Click snooze button - const snoozeButton = taskItem.locator(SELECTORS.taskSnoozeButton); - await snoozeButton.click(); - - // Open radio group - const radioGroup = taskItem.locator(SELECTORS.snoozeRadioGroup); - await radioGroup.click(); - - // Select duration using page.evaluate like the original test - await this.page.evaluate(({ id, dur }) => { - const radio = document.querySelector( - `li[data-task-id="${id}"] .prpl-snooze-duration-radio-group input[type="radio"][value="${dur}"]` - ) as HTMLInputElement; - const label = radio?.closest('label'); - label?.click(); - }, { id: taskId, dur: duration }); - - await this.page.waitForLoadState('networkidle'); - await this.page.waitForTimeout(1000); - } - - // ================== - // Onboarding - // ================== - - async isOnboardingVisible(): Promise { - return await this.onboardingPopover.isVisible(); - } - - async completeOnboarding(): Promise { - await expect(this.onboardingPopover).toBeVisible({ timeout: 10000 }); - - // Accept privacy policy - const privacyLabel = this.page.locator(SELECTORS.privacyCheckboxLabel); - await privacyLabel.click(); - - // Start onboarding - const startButton = this.onboardingPopover.locator(SELECTORS.tourNextButton); - await startButton.click(); - - // Wait for step to advance - await expect(this.onboardingPopover).toHaveAttribute('data-prpl-step', /^[1-9]/, { - timeout: 15000, - }); - - // Close onboarding - const closeButton = this.page.locator(SELECTORS.tourCloseButton); - await closeButton.click(); - - await expect(this.onboardingPopover).toBeHidden({ timeout: 5000 }); - } - - // ================== - // Tour (Driver.js) - // ================== - - async startTour(): Promise { - const tourButton = this.page.locator(SELECTORS.tourStartButton); - await tourButton.click(); - - await expect(this.tourPopover).toBeVisible({ timeout: 5000 }); - } - - async isTourVisible(): Promise { - return await this.tourPopover.isVisible(); - } - - async getTourStepsCount(): Promise { - return await this.page.evaluate(() => { - const tour = (window as unknown as { progressPlannerTour?: { steps?: unknown[] } }).progressPlannerTour; - return tour?.steps?.length ?? 0; - }); - } - - async clickTourNext(): Promise { - const nextButton = this.page.locator(SELECTORS.tourNextBtn); - await nextButton.click(); - } - - async getTourNextButtonText(): Promise { - const nextButton = this.page.locator(SELECTORS.tourNextBtn); - return await nextButton.textContent() ?? ''; - } - - async completeTour(): Promise { - // Start the tour if not already visible - if (!await this.isTourVisible()) { - await this.startTour(); - } - - const stepsCount = await this.getTourStepsCount(); - - for (let i = 0; i < stepsCount - 1; i++) { - await expect(this.tourPopover).toBeVisible(); - await this.clickTourNext(); - } - - // Verify final step has "Finish" button - const buttonText = await this.getTourNextButtonText(); - if (buttonText.toLowerCase() !== 'finish') { - throw new Error(`Expected "Finish" button, got "${buttonText}"`); - } - - // Click finish - await this.clickTourNext(); - - // Verify tour is closed - await expect(this.tourPopover).not.toBeVisible({ timeout: 5000 }); - } - - // ================== - // Cleanup - // ================== - - async deleteAllTodos(): Promise { - // Verify page is still accessible - try { - await this.page.waitForLoadState('domcontentloaded', { timeout: 2000 }); - } catch { - console.warn('[Cleanup] Page not accessible, skipping cleanup'); - return; - } - - // Delete active tasks - const todoItems = this.page.locator(SELECTORS.todoItem); - while ((await todoItems.count()) > 0) { - const firstItem = todoItems.first(); - const trash = firstItem.locator(`${SELECTORS.taskActionsWrapper} ${SELECTORS.taskTrashButton}`); - - try { - await firstItem.scrollIntoViewIfNeeded(); - await firstItem.hover(); - await trash.waitFor({ state: 'visible', timeout: 3000 }); - await trash.click(); - await this.page.waitForTimeout(1500); - } catch (err) { - console.warn('[Cleanup] Failed to delete active todo item:', (err as Error).message); - break; - } - } - - // Delete completed tasks - const completedDetails = this.page.locator(SELECTORS.todoCompletedDetails); - if (await completedDetails.isVisible().catch(() => false)) { - await completedDetails.click(); - await this.page.waitForTimeout(500); - - const completedItems = this.page.locator(SELECTORS.todoCompletedItem); - while ((await completedItems.count()) > 0) { - const firstCompleted = completedItems.first(); - const trash = firstCompleted.locator('.prpl-suggested-task-points-wrapper .trash'); - - try { - await firstCompleted.scrollIntoViewIfNeeded(); - await firstCompleted.hover(); - await trash.waitFor({ state: 'visible', timeout: 3000 }); - await trash.click(); - await this.page.waitForTimeout(1500); - } catch (err) { - console.warn('[Cleanup] Failed to delete completed todo item:', (err as Error).message); - break; - } - } - } - } + // Locators (lazy-initialized for performance) + readonly todoList: Locator; + readonly todoCompletedList: Locator; + readonly newTodoInput: Locator; + readonly suggestedTasksList: Locator; + readonly onboardingPopover: Locator; + readonly tourPopover: Locator; + + constructor( page: Page ) { + super( page ); + this.todoList = page.locator( SELECTORS.todoList ); + this.todoCompletedList = page.locator( SELECTORS.todoCompletedList ); + this.newTodoInput = page.locator( SELECTORS.newTodoInput ); + this.suggestedTasksList = page.locator( SELECTORS.suggestedTasksList ); + this.onboardingPopover = page.locator( SELECTORS.onboardingPopover ); + this.tourPopover = page.locator( SELECTORS.tourPopover ); + } + + async goto( options?: { + showAllRecommendations?: boolean; + } ): Promise< void > { + const url = options?.showAllRecommendations + ? '/wp-admin/admin.php?page=progress-planner&prpl_show_all_recommendations' + : '/wp-admin/admin.php?page=progress-planner'; + + await this.page.goto( url ); + await this.waitForReady(); + } + + override async waitForReady(): Promise< void > { + await this.page.waitForLoadState( 'networkidle' ); + // Wait for the main dashboard widget to be visible + await this.page.locator( SELECTORS.widgetWrapper ).waitFor( { + state: 'visible', + timeout: 10000, + } ); + } + + // ================== + // Todo CRUD Operations + // ================== + + async createTodo( + text: string + ): Promise< { taskId: string; element: Locator } > { + await this.newTodoInput.fill( text ); + await this.page.keyboard.press( 'Enter' ); + await this.page.waitForTimeout( 500 ); + + // Find the newly created task + const todoItem = this.page.locator( SELECTORS.todoItem ).first(); + await todoItem.waitFor( { state: 'visible' } ); + + const taskId = await todoItem.getAttribute( 'data-task-id' ); + if ( ! taskId ) { + throw new Error( 'Created todo has no task ID' ); + } + + return { taskId, element: todoItem }; + } + + async getTodoItems(): Promise< Locator[] > { + return await this.page.locator( SELECTORS.todoItem ).all(); + } + + async getTodoByText( text: string ): Promise< Locator > { + return this.page.locator( SELECTORS.todoItem ).filter( { + has: this.page.locator( SELECTORS.taskItemText, { hasText: text } ), + } ); + } + + async getTodoById( taskId: string ): Promise< Locator > { + return this.page.locator( `li[data-task-id="${ taskId }"]` ); + } + + async getTodoText( item: Locator ): Promise< string > { + return ( + ( await item.locator( SELECTORS.taskItemText ).textContent() ) ?? '' + ); + } + + async deleteTodo( item: Locator ): Promise< void > { + await this.scrollToAndWait( item ); + await item.hover(); + + const trashButton = item.locator( + `${ SELECTORS.taskActionsWrapper } ${ SELECTORS.taskTrashButton }` + ); + await trashButton.waitFor( { state: 'visible' } ); + await trashButton.click(); + await this.page.waitForTimeout( 1500 ); + } + + async completeTodo( item: Locator ): Promise< void > { + const label = item.locator( SELECTORS.taskCheckboxLabel ); + await label.click(); + await this.page.waitForTimeout( 1000 ); + } + + async moveTodoDown( item: Locator ): Promise< void > { + await item.hover(); + const moveDownButton = item.locator( SELECTORS.taskMoveDownButton ); + await moveDownButton.waitFor( { state: 'visible' } ); + await moveDownButton.click(); + await this.page.waitForTimeout( 1500 ); + } + + async moveTodoUp( item: Locator ): Promise< void > { + await item.hover(); + const moveUpButton = item.locator( SELECTORS.taskMoveUpButton ); + await moveUpButton.waitFor( { state: 'visible' } ); + await moveUpButton.click(); + await this.page.waitForTimeout( 1500 ); + } + + // ================== + // Completed Tasks + // ================== + + async openCompletedTasks(): Promise< void > { + const details = this.page.locator( SELECTORS.todoCompletedDetails ); + + // Check if details element exists and is visible + const isVisible = await details.isVisible().catch( () => false ); + if ( ! isVisible ) { + return; + } + + // Check if already open + const isOpen = await details.getAttribute( 'open' ); + if ( isOpen !== null ) { + return; + } + + await details.click(); + await this.page + .locator( SELECTORS.todoCompletedItem ) + .first() + .waitFor( { + state: 'visible', + timeout: 5000, + } ) + .catch( () => { + // No completed items, that's fine + } ); + } + + async getCompletedItems(): Promise< Locator[] > { + return await this.page.locator( SELECTORS.todoCompletedItem ).all(); + } + + // ================== + // Suggested Tasks + // ================== + + async getSuggestedTasksCount(): Promise< number > { + return await this.page + .locator( SELECTORS.suggestedTaskCheckbox ) + .count(); + } + + async completeSuggestedTask(): Promise< { + taskId: string | null; + previousCount: number; + } > { + const initialCount = await this.getSuggestedTasksCount(); + + if ( initialCount === 0 ) { + return { taskId: null, previousCount: 0 }; + } + + const firstCheckbox = this.page + .locator( SELECTORS.suggestedTaskCheckbox ) + .first(); + const taskItem = firstCheckbox.locator( 'xpath=ancestor::li[1]' ); + const taskId = await taskItem.getAttribute( 'data-task-id' ); + + // Click the label (parent of checkbox) + const label = firstCheckbox.locator( '..' ); + await label.click(); + + // Wait for animation + await this.page.waitForTimeout( 3000 ); + + return { taskId, previousCount: initialCount }; + } + + // ================== + // Task Snooze + // ================== + + async snoozeTask( + taskId: string, + duration: '1-day' | '1-week' | '2-weeks' | '1-month' + ): Promise< void > { + const taskItem = await this.getTodoById( taskId ); + await taskItem.hover(); + + // Click snooze button + const snoozeButton = taskItem.locator( SELECTORS.taskSnoozeButton ); + await snoozeButton.click(); + + // Open radio group + const radioGroup = taskItem.locator( SELECTORS.snoozeRadioGroup ); + await radioGroup.click(); + + // Select duration using page.evaluate like the original test + await this.page.evaluate( + ( { id, dur } ) => { + const radio = document.querySelector( + `li[data-task-id="${ id }"] .prpl-snooze-duration-radio-group input[type="radio"][value="${ dur }"]` + ) as HTMLInputElement; + const label = radio?.closest( 'label' ); + label?.click(); + }, + { id: taskId, dur: duration } + ); + + await this.page.waitForLoadState( 'networkidle' ); + await this.page.waitForTimeout( 1000 ); + } + + // ================== + // Onboarding + // ================== + + async isOnboardingVisible(): Promise< boolean > { + return await this.onboardingPopover.isVisible(); + } + + async completeOnboarding(): Promise< void > { + await expect( this.onboardingPopover ).toBeVisible( { + timeout: 10000, + } ); + + // Accept privacy policy + const privacyLabel = this.page.locator( + SELECTORS.privacyCheckboxLabel + ); + await privacyLabel.click(); + + // Start onboarding + const startButton = this.onboardingPopover.locator( + SELECTORS.tourNextButton + ); + await startButton.click(); + + // Wait for step to advance + await expect( this.onboardingPopover ).toHaveAttribute( + 'data-prpl-step', + /^[1-9]/, + { + timeout: 15000, + } + ); + + // Close onboarding + const closeButton = this.page.locator( SELECTORS.tourCloseButton ); + await closeButton.click(); + + await expect( this.onboardingPopover ).toBeHidden( { timeout: 5000 } ); + } + + // ================== + // Tour (Driver.js) + // ================== + + async startTour(): Promise< void > { + const tourButton = this.page.locator( SELECTORS.tourStartButton ); + await tourButton.click(); + + await expect( this.tourPopover ).toBeVisible( { timeout: 5000 } ); + } + + async isTourVisible(): Promise< boolean > { + return await this.tourPopover.isVisible(); + } + + async getTourStepsCount(): Promise< number > { + return await this.page.evaluate( () => { + const tour = ( + window as unknown as { + progressPlannerTour?: { steps?: unknown[] }; + } + ).progressPlannerTour; + return tour?.steps?.length ?? 0; + } ); + } + + async clickTourNext(): Promise< void > { + const nextButton = this.page.locator( SELECTORS.tourNextBtn ); + await nextButton.click(); + } + + async getTourNextButtonText(): Promise< string > { + const nextButton = this.page.locator( SELECTORS.tourNextBtn ); + return ( await nextButton.textContent() ) ?? ''; + } + + async completeTour(): Promise< void > { + // Start the tour if not already visible + if ( ! ( await this.isTourVisible() ) ) { + await this.startTour(); + } + + const stepsCount = await this.getTourStepsCount(); + + for ( let i = 0; i < stepsCount - 1; i++ ) { + await expect( this.tourPopover ).toBeVisible(); + await this.clickTourNext(); + } + + // Verify final step has "Finish" button + const buttonText = await this.getTourNextButtonText(); + if ( buttonText.toLowerCase() !== 'finish' ) { + throw new Error( + `Expected "Finish" button, got "${ buttonText }"` + ); + } + + // Click finish + await this.clickTourNext(); + + // Verify tour is closed + await expect( this.tourPopover ).not.toBeVisible( { timeout: 5000 } ); + } + + // ================== + // Cleanup + // ================== + + async deleteAllTodos(): Promise< void > { + // Verify page is still accessible + try { + await this.page.waitForLoadState( 'domcontentloaded', { + timeout: 2000, + } ); + } catch { + console.warn( '[Cleanup] Page not accessible, skipping cleanup' ); + return; + } + + // Delete active tasks + const todoItems = this.page.locator( SELECTORS.todoItem ); + while ( ( await todoItems.count() ) > 0 ) { + const firstItem = todoItems.first(); + const trash = firstItem.locator( + `${ SELECTORS.taskActionsWrapper } ${ SELECTORS.taskTrashButton }` + ); + + try { + await firstItem.scrollIntoViewIfNeeded(); + await firstItem.hover(); + await trash.waitFor( { state: 'visible', timeout: 3000 } ); + await trash.click(); + await this.page.waitForTimeout( 1500 ); + } catch ( err ) { + console.warn( + '[Cleanup] Failed to delete active todo item:', + ( err as Error ).message + ); + break; + } + } + + // Delete completed tasks + const completedDetails = this.page.locator( + SELECTORS.todoCompletedDetails + ); + if ( await completedDetails.isVisible().catch( () => false ) ) { + await completedDetails.click(); + await this.page.waitForTimeout( 500 ); + + const completedItems = this.page.locator( + SELECTORS.todoCompletedItem + ); + while ( ( await completedItems.count() ) > 0 ) { + const firstCompleted = completedItems.first(); + const trash = firstCompleted.locator( + '.prpl-suggested-task-points-wrapper .trash' + ); + + try { + await firstCompleted.scrollIntoViewIfNeeded(); + await firstCompleted.hover(); + await trash.waitFor( { state: 'visible', timeout: 3000 } ); + await trash.click(); + await this.page.waitForTimeout( 1500 ); + } catch ( err ) { + console.warn( + '[Cleanup] Failed to delete completed todo item:', + ( err as Error ).message + ); + break; + } + } + } + } } diff --git a/tests/e2e/pages/yoast-settings.page.ts b/tests/e2e/pages/yoast-settings.page.ts index 4900d07000..ce291557e6 100644 --- a/tests/e2e/pages/yoast-settings.page.ts +++ b/tests/e2e/pages/yoast-settings.page.ts @@ -5,152 +5,165 @@ import { BasePage } from './base.page'; * Selectors for Yoast SEO settings pages. */ const SELECTORS = { - // Modal - modalCloseButton: 'button.yst-modal__close-button', - - // Ravi icon elements (Progress Planner integration) - raviIconWrapper: '[data-prpl-element="ravi-icon"]', - raviIconImage: '[data-prpl-element="ravi-icon"] img', - raviIconPoints: '.prpl-form-row-points', - - // Crawl optimization page - feedCommentsToggle: 'button[data-id="input-wpseo-remove_feed_global_comments"]', - toggleFieldHeader: '.yst-toggle-field__header', - - // Site representation page - companyLogoFieldset: '#wpseo_titles-company_logo', - companyLogoLabel: '#wpseo_titles-company_logo legend.yst-label', + // Modal + modalCloseButton: 'button.yst-modal__close-button', + + // Ravi icon elements (Progress Planner integration) + raviIconWrapper: '[data-prpl-element="ravi-icon"]', + raviIconImage: '[data-prpl-element="ravi-icon"] img', + raviIconPoints: '.prpl-form-row-points', + + // Crawl optimization page + feedCommentsToggle: + 'button[data-id="input-wpseo-remove_feed_global_comments"]', + toggleFieldHeader: '.yst-toggle-field__header', + + // Site representation page + companyLogoFieldset: '#wpseo_titles-company_logo', + companyLogoLabel: '#wpseo_titles-company_logo legend.yst-label', } as const; export class YoastSettingsPage extends BasePage { - constructor(page: Page) { - super(page); - } - - async goto(): Promise { - // Default to crawl optimization page - await this.gotoCrawlOptimization(); - } - - async gotoCrawlOptimization(): Promise { - await this.page.goto('/wp-admin/admin.php?page=wpseo_page_settings#/crawl-optimization'); - await this.waitForReady(); - } - - async gotoSiteRepresentation(): Promise { - await this.page.goto('/wp-admin/admin.php?page=wpseo_page_settings#/site-representation'); - await this.waitForReady(); - } - - override async waitForReady(): Promise { - await this.page.waitForLoadState('networkidle'); - - // Dismiss any modal that might be blocking - await this.dismissModal(); - } - - /** - * Dismiss the Yoast modal if it's visible. - */ - async dismissModal(): Promise { - const closeButton = this.page.locator(SELECTORS.modalCloseButton); - - try { - // Short timeout - modal may or may not exist - if (await closeButton.isVisible({ timeout: 2000 })) { - await closeButton.click(); - await closeButton.waitFor({ state: 'hidden', timeout: 3000 }); - } - } catch { - // Modal not present, that's fine - } - } - - // ================== - // Feed Comments Toggle (Crawl Optimization) - // ================== - - async getFeedCommentsToggle(): Promise { - const toggle = this.page.locator(SELECTORS.feedCommentsToggle); - await toggle.waitFor({ state: 'visible' }); - return toggle; - } - - async getFeedCommentsToggleHeader(): Promise { - const toggle = await this.getFeedCommentsToggle(); - return toggle.locator('xpath=ancestor::div[contains(@class, "yst-toggle-field__header")]'); - } - - async clickFeedCommentsToggle(): Promise { - const toggle = await this.getFeedCommentsToggle(); - await toggle.click(); - } - - // ================== - // Company Logo (Site Representation) - // ================== - - async getCompanyLogoLabel(): Promise { - const label = this.page.locator(SELECTORS.companyLogoLabel); - await label.waitFor({ state: 'visible' }); - return label; - } - - // ================== - // Ravi Icon Helpers - // ================== - - /** - * Get the Ravi icon within a parent element. - */ - getRaviIcon(parent: Locator): Locator { - return parent.locator(SELECTORS.raviIconWrapper); - } - - /** - * Get the Ravi icon image within a parent element. - */ - getRaviIconImage(parent: Locator): Locator { - return parent.locator(SELECTORS.raviIconImage); - } - - /** - * Get the points text from a Ravi icon. - */ - async getRaviIconPoints(parent: Locator): Promise { - const points = parent.locator(SELECTORS.raviIconPoints); - return await points.textContent() ?? ''; - } - - /** - * Verify a Ravi icon exists and has correct attributes. - */ - async verifyRaviIcon(parent: Locator): Promise { - const raviIcon = this.getRaviIcon(parent); - await expect(raviIcon).toBeVisible(); - - const iconImg = this.getRaviIconImage(parent); - await expect(iconImg).toBeVisible(); - await expect(iconImg).toHaveAttribute('alt', 'Ravi'); - await expect(iconImg).toHaveAttribute('width', '16'); - await expect(iconImg).toHaveAttribute('height', '16'); - } - - /** - * Verify the Ravi icon shows uncompleted state (+N points). - */ - async verifyRaviIconUncompleted(parent: Locator): Promise { - const raviIcon = this.getRaviIcon(parent); - const points = raviIcon.locator(SELECTORS.raviIconPoints); - await expect(points).toHaveText('+1'); - } - - /** - * Verify the Ravi icon shows completed state (checkmark). - */ - async verifyRaviIconCompleted(parent: Locator): Promise { - const raviIcon = this.getRaviIcon(parent); - const points = raviIcon.locator(SELECTORS.raviIconPoints); - await expect(points).toHaveText('✓'); - } + constructor( page: Page ) { + super( page ); + } + + async goto(): Promise< void > { + // Default to crawl optimization page + await this.gotoCrawlOptimization(); + } + + async gotoCrawlOptimization(): Promise< void > { + await this.page.goto( + '/wp-admin/admin.php?page=wpseo_page_settings#/crawl-optimization' + ); + await this.waitForReady(); + } + + async gotoSiteRepresentation(): Promise< void > { + await this.page.goto( + '/wp-admin/admin.php?page=wpseo_page_settings#/site-representation' + ); + await this.waitForReady(); + } + + override async waitForReady(): Promise< void > { + await this.page.waitForLoadState( 'networkidle' ); + + // Dismiss any modal that might be blocking + await this.dismissModal(); + } + + /** + * Dismiss the Yoast modal if it's visible. + */ + async dismissModal(): Promise< void > { + const closeButton = this.page.locator( SELECTORS.modalCloseButton ); + + try { + // Short timeout - modal may or may not exist + if ( await closeButton.isVisible( { timeout: 2000 } ) ) { + await closeButton.click(); + await closeButton.waitFor( { state: 'hidden', timeout: 3000 } ); + } + } catch { + // Modal not present, that's fine + } + } + + // ================== + // Feed Comments Toggle (Crawl Optimization) + // ================== + + async getFeedCommentsToggle(): Promise< Locator > { + const toggle = this.page.locator( SELECTORS.feedCommentsToggle ); + await toggle.waitFor( { state: 'visible' } ); + return toggle; + } + + async getFeedCommentsToggleHeader(): Promise< Locator > { + const toggle = await this.getFeedCommentsToggle(); + return toggle.locator( + 'xpath=ancestor::div[contains(@class, "yst-toggle-field__header")]' + ); + } + + async clickFeedCommentsToggle(): Promise< void > { + const toggle = await this.getFeedCommentsToggle(); + await toggle.click(); + } + + // ================== + // Company Logo (Site Representation) + // ================== + + async getCompanyLogoLabel(): Promise< Locator > { + const label = this.page.locator( SELECTORS.companyLogoLabel ); + await label.waitFor( { state: 'visible' } ); + return label; + } + + // ================== + // Ravi Icon Helpers + // ================== + + /** + * Get the Ravi icon within a parent element. + * @param parent + */ + getRaviIcon( parent: Locator ): Locator { + return parent.locator( SELECTORS.raviIconWrapper ); + } + + /** + * Get the Ravi icon image within a parent element. + * @param parent + */ + getRaviIconImage( parent: Locator ): Locator { + return parent.locator( SELECTORS.raviIconImage ); + } + + /** + * Get the points text from a Ravi icon. + * @param parent + */ + async getRaviIconPoints( parent: Locator ): Promise< string > { + const points = parent.locator( SELECTORS.raviIconPoints ); + return ( await points.textContent() ) ?? ''; + } + + /** + * Verify a Ravi icon exists and has correct attributes. + * @param parent + */ + async verifyRaviIcon( parent: Locator ): Promise< void > { + const raviIcon = this.getRaviIcon( parent ); + await expect( raviIcon ).toBeVisible(); + + const iconImg = this.getRaviIconImage( parent ); + await expect( iconImg ).toBeVisible(); + await expect( iconImg ).toHaveAttribute( 'alt', 'Ravi' ); + await expect( iconImg ).toHaveAttribute( 'width', '16' ); + await expect( iconImg ).toHaveAttribute( 'height', '16' ); + } + + /** + * Verify the Ravi icon shows uncompleted state (+N points). + * @param parent + */ + async verifyRaviIconUncompleted( parent: Locator ): Promise< void > { + const raviIcon = this.getRaviIcon( parent ); + const points = raviIcon.locator( SELECTORS.raviIconPoints ); + await expect( points ).toHaveText( '+1' ); + } + + /** + * Verify the Ravi icon shows completed state (checkmark). + * @param parent + */ + async verifyRaviIconCompleted( parent: Locator ): Promise< void > { + const raviIcon = this.getRaviIcon( parent ); + const points = raviIcon.locator( SELECTORS.raviIconPoints ); + await expect( points ).toHaveText( '✓' ); + } } diff --git a/tests/e2e/specs/onboarding.spec.ts b/tests/e2e/specs/onboarding.spec.ts index bdebc8b113..6dd82b4e7a 100644 --- a/tests/e2e/specs/onboarding.spec.ts +++ b/tests/e2e/specs/onboarding.spec.ts @@ -1,68 +1,82 @@ import { test, expect } from '../fixtures/base.fixture'; -test.describe('Progress Planner Onboarding', () => { - // This test requires external API for license generation, which isn't available in Playground - // Skip by default in Playground environments - test.skip(({ }, testInfo) => { - // Skip if running in Playground mode (no WORDPRESS_URL set means Playground) - return !process.env.WORDPRESS_URL; - }, 'License generation requires external API (not available in Playground)'); +test.describe( 'Progress Planner Onboarding', () => { + // This test requires external API for license generation, which isn't available in Playground + // Skip by default in Playground environments + test.skip( ( {}, testInfo ) => { + // Skip if running in Playground mode (no WORDPRESS_URL set means Playground) + return ! process.env.WORDPRESS_URL; + }, 'License generation requires external API (not available in Playground)' ); - test('should complete onboarding process successfully', async ({ page }) => { - const popover = page.locator('#prpl-popover-onboarding'); + test( 'should complete onboarding process successfully', async ( { + page, + } ) => { + const popover = page.locator( '#prpl-popover-onboarding' ); - await test.step('Navigate to Progress Planner and trigger onboarding', async () => { - await page.goto('/wp-admin/admin.php?page=progress-planner'); - await page.waitForLoadState('networkidle'); + await test.step( 'Navigate to Progress Planner and trigger onboarding', async () => { + await page.goto( '/wp-admin/admin.php?page=progress-planner' ); + await page.waitForLoadState( 'networkidle' ); - // In Playground mode with pre-existing license, click "Show onboarding" to trigger onboarding - const showOnboardingButton = page.locator('#progress-planner-show-onboarding'); - if (await showOnboardingButton.isVisible()) { - // This button triggers AJAX that deletes license and progress, then reloads - await showOnboardingButton.click(); + // In Playground mode with pre-existing license, click "Show onboarding" to trigger onboarding + const showOnboardingButton = page.locator( + '#progress-planner-show-onboarding' + ); + if ( await showOnboardingButton.isVisible() ) { + // This button triggers AJAX that deletes license and progress, then reloads + await showOnboardingButton.click(); - // Wait for reload - await page.waitForLoadState('networkidle'); - } + // Wait for reload + await page.waitForLoadState( 'networkidle' ); + } - await expect(popover).toBeVisible({ timeout: 10000 }); - }); + await expect( popover ).toBeVisible( { timeout: 10000 } ); + } ); - await test.step('Accept privacy policy if visible', async () => { - // Privacy checkbox is only visible when no license exists - const privacyLabel = page.locator('label[for="prpl-privacy-checkbox"]'); - if (await privacyLabel.isVisible({ timeout: 2000 }).catch(() => false)) { - await privacyLabel.click(); - } - }); + await test.step( 'Accept privacy policy if visible', async () => { + // Privacy checkbox is only visible when no license exists + const privacyLabel = page.locator( + 'label[for="prpl-privacy-checkbox"]' + ); + if ( + await privacyLabel + .isVisible( { timeout: 2000 } ) + .catch( () => false ) + ) { + await privacyLabel.click(); + } + } ); - await test.step('Start onboarding', async () => { - const startButton = popover.locator('.prpl-tour-next'); - await expect(startButton).toBeVisible(); + await test.step( 'Start onboarding', async () => { + const startButton = popover.locator( '.prpl-tour-next' ); + await expect( startButton ).toBeVisible(); - // Click the button - await startButton.click(); + // Click the button + await startButton.click(); - // Wait for step to advance (license generation happens in background) - await expect(popover).toHaveAttribute('data-prpl-step', /^[1-9]/, { - timeout: 15000, - }); - }); + // Wait for step to advance (license generation happens in background) + await expect( popover ).toHaveAttribute( + 'data-prpl-step', + /^[1-9]/, + { + timeout: 15000, + } + ); + } ); - await test.step('Close onboarding', async () => { - const closeButton = page.locator('#prpl-tour-close-btn'); - await closeButton.click(); + await test.step( 'Close onboarding', async () => { + const closeButton = page.locator( '#prpl-tour-close-btn' ); + await closeButton.click(); - await expect(popover).toBeHidden({ timeout: 5000 }); - }); + await expect( popover ).toBeHidden( { timeout: 5000 } ); + } ); - await test.step('Verify onboarding does not restart on revisit', async () => { - await page.goto('/wp-admin/'); - await page.goto('/wp-admin/admin.php?page=progress-planner'); - await page.waitForLoadState('networkidle'); + await test.step( 'Verify onboarding does not restart on revisit', async () => { + await page.goto( '/wp-admin/' ); + await page.goto( '/wp-admin/admin.php?page=progress-planner' ); + await page.waitForLoadState( 'networkidle' ); - // Popover should remain hidden - await expect(popover).toBeHidden({ timeout: 5000 }); - }); - }); -}); + // Popover should remain hidden + await expect( popover ).toBeHidden( { timeout: 5000 } ); + } ); + } ); +} ); diff --git a/tests/e2e/specs/task-dismissible.spec.ts b/tests/e2e/specs/task-dismissible.spec.ts index 1f43cd8b4a..5026c09798 100644 --- a/tests/e2e/specs/task-dismissible.spec.ts +++ b/tests/e2e/specs/task-dismissible.spec.ts @@ -1,42 +1,51 @@ import { test, expect } from '../fixtures/base.fixture'; -test.describe('Dismissible Tasks', () => { - test('should complete dismissible task if present', async ({ page, tasksApi }) => { - // Navigate to Progress Planner dashboard - await page.goto('/wp-admin/admin.php?page=progress-planner'); - await page.waitForLoadState('networkidle'); - - // Check if complete button exists - const initialCount = await page - .locator('#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)') - .count(); - - if (initialCount > 0) { - const completeButton = page - .locator('#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)') - .first(); - - // Get the task ID from the button - const taskId = await completeButton - .locator('xpath=ancestor::li[1]') - .getAttribute('data-task-id'); - - // Click on the parent of the checkbox (label, because it intercepts pointer events) - await completeButton.locator('..').click(); - - // Wait for animation - await page.waitForTimeout(3000); - - // Verify the task count decreased by 1 - const finalCount = await page - .locator('#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)') - .count(); - expect(finalCount).toBe(initialCount - 1); - - // Check the final task status via REST API - if (taskId) { - await tasksApi.expectTaskStatus(taskId, 'trash'); - } - } - }); -}); +test.describe( 'Dismissible Tasks', () => { + test( 'should complete dismissible task if present', async ( { + page, + tasksApi, + } ) => { + // Navigate to Progress Planner dashboard + await page.goto( '/wp-admin/admin.php?page=progress-planner' ); + await page.waitForLoadState( 'networkidle' ); + + // Check if complete button exists + const initialCount = await page + .locator( + '#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)' + ) + .count(); + + if ( initialCount > 0 ) { + const completeButton = page + .locator( + '#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)' + ) + .first(); + + // Get the task ID from the button + const taskId = await completeButton + .locator( 'xpath=ancestor::li[1]' ) + .getAttribute( 'data-task-id' ); + + // Click on the parent of the checkbox (label, because it intercepts pointer events) + await completeButton.locator( '..' ).click(); + + // Wait for animation + await page.waitForTimeout( 3000 ); + + // Verify the task count decreased by 1 + const finalCount = await page + .locator( + '#prpl-suggested-tasks-list .prpl-suggested-task-checkbox:not(:disabled)' + ) + .count(); + expect( finalCount ).toBe( initialCount - 1 ); + + // Check the final task status via REST API + if ( taskId ) { + await tasksApi.expectTaskStatus( taskId, 'trash' ); + } + } + } ); +} ); diff --git a/tests/e2e/specs/task-snooze.spec.ts b/tests/e2e/specs/task-snooze.spec.ts index f97c319d49..8c8016d035 100644 --- a/tests/e2e/specs/task-snooze.spec.ts +++ b/tests/e2e/specs/task-snooze.spec.ts @@ -1,54 +1,66 @@ import { test, expect } from '../fixtures/base.fixture'; -test.describe('Task Snooze', () => { - test('should snooze a task for 1 week', async ({ page, tasksApi }) => { - // Navigate with show all recommendations to ensure we have tasks - await page.goto('/wp-admin/admin.php?page=progress-planner&prpl_show_all_recommendations'); - await page.waitForLoadState('domcontentloaded'); - - // Wait for the page to settle - await page.waitForTimeout(2000); - - // Use a known task that should always be available: settings-saved - const snoozeTaskId = 'settings-saved'; - - // Verify the task exists and is active - const task = await tasksApi.getTask(snoozeTaskId); - if (!task || task.post_status !== 'publish') { - console.log(`Task ${snoozeTaskId} not available (status: ${task?.post_status || 'not found'}), skipping`); - test.skip(); - return; - } - - await test.step('Snooze the task for 1 week', async () => { - const taskItem = page.locator(`li[data-task-id="${snoozeTaskId}"]`); - await expect(taskItem).toBeVisible({ timeout: 10000 }); - await taskItem.hover(); - - // Click snooze button - const snoozeButton = taskItem.locator('button[data-action="snooze"]'); - await snoozeButton.click(); - - // Open radio group - const radioGroup = taskItem.locator('button.prpl-toggle-radio-group'); - await radioGroup.click(); - - // Select 1 week duration by clicking the label - await page.evaluate((taskId) => { - const radio = document.querySelector( - `li[data-task-id="${taskId}"] .prpl-snooze-duration-radio-group input[type="radio"][value="1-week"]` - ) as HTMLInputElement; - const label = radio?.closest('label'); - label?.click(); - }, snoozeTaskId); - - // Wait for the API call to complete - await page.waitForTimeout(2000); - }); - - await test.step('Verify task is snoozed via API', async () => { - const updatedTask = await tasksApi.getTask(snoozeTaskId); - expect(updatedTask?.post_status).toBe('future'); - }); - }); -}); +test.describe( 'Task Snooze', () => { + test( 'should snooze a task for 1 week', async ( { page, tasksApi } ) => { + // Navigate with show all recommendations to ensure we have tasks + await page.goto( + '/wp-admin/admin.php?page=progress-planner&prpl_show_all_recommendations' + ); + await page.waitForLoadState( 'domcontentloaded' ); + + // Wait for the page to settle + await page.waitForTimeout( 2000 ); + + // Use a known task that should always be available: settings-saved + const snoozeTaskId = 'settings-saved'; + + // Verify the task exists and is active + const task = await tasksApi.getTask( snoozeTaskId ); + if ( ! task || task.post_status !== 'publish' ) { + console.log( + `Task ${ snoozeTaskId } not available (status: ${ + task?.post_status || 'not found' + }), skipping` + ); + test.skip(); + return; + } + + await test.step( 'Snooze the task for 1 week', async () => { + const taskItem = page.locator( + `li[data-task-id="${ snoozeTaskId }"]` + ); + await expect( taskItem ).toBeVisible( { timeout: 10000 } ); + await taskItem.hover(); + + // Click snooze button + const snoozeButton = taskItem.locator( + 'button[data-action="snooze"]' + ); + await snoozeButton.click(); + + // Open radio group + const radioGroup = taskItem.locator( + 'button.prpl-toggle-radio-group' + ); + await radioGroup.click(); + + // Select 1 week duration by clicking the label + await page.evaluate( ( taskId ) => { + const radio = document.querySelector( + `li[data-task-id="${ taskId }"] .prpl-snooze-duration-radio-group input[type="radio"][value="1-week"]` + ) as HTMLInputElement; + const label = radio?.closest( 'label' ); + label?.click(); + }, snoozeTaskId ); + + // Wait for the API call to complete + await page.waitForTimeout( 2000 ); + } ); + + await test.step( 'Verify task is snoozed via API', async () => { + const updatedTask = await tasksApi.getTask( snoozeTaskId ); + expect( updatedTask?.post_status ).toBe( 'future' ); + } ); + } ); +} ); diff --git a/tests/e2e/specs/task-tagline.spec.ts b/tests/e2e/specs/task-tagline.spec.ts index 0d0e9d8532..f936229fd6 100644 --- a/tests/e2e/specs/task-tagline.spec.ts +++ b/tests/e2e/specs/task-tagline.spec.ts @@ -1,73 +1,87 @@ import { test, expect } from '../fixtures/base.fixture'; -test.describe('Task Tagline Completion', () => { - test('should complete blog description task when tagline is set', async ({ page, tasksApi }) => { - await test.step('Navigate to Progress Planner dashboard to init', async () => { - await page.goto('/wp-admin/admin.php?page=progress-planner'); - await page.waitForLoadState('domcontentloaded'); - // Wait for page to settle - await page.waitForTimeout(1000); - }); +test.describe( 'Task Tagline Completion', () => { + test( 'should complete blog description task when tagline is set', async ( { + page, + tasksApi, + } ) => { + await test.step( 'Navigate to Progress Planner dashboard to init', async () => { + await page.goto( '/wp-admin/admin.php?page=progress-planner' ); + await page.waitForLoadState( 'domcontentloaded' ); + // Wait for page to settle + await page.waitForTimeout( 1000 ); + } ); - await test.step('Verify blog description task exists and is active', async () => { - const task = await tasksApi.getTask('core-blogdescription'); - if (!task || task.post_status !== 'publish') { - // Task doesn't exist or isn't active - skip test - console.log('Task core-blogdescription not available, skipping test'); - test.skip(); - return; - } - expect(task.post_status).toBe('publish'); - }); + await test.step( 'Verify blog description task exists and is active', async () => { + const task = await tasksApi.getTask( 'core-blogdescription' ); + if ( ! task || task.post_status !== 'publish' ) { + // Task doesn't exist or isn't active - skip test + console.log( + 'Task core-blogdescription not available, skipping test' + ); + test.skip(); + return; + } + expect( task.post_status ).toBe( 'publish' ); + } ); - await test.step('Navigate to WordPress settings and set tagline', async () => { - await page.goto('/wp-admin/options-general.php'); - await page.waitForLoadState('domcontentloaded'); + await test.step( 'Navigate to WordPress settings and set tagline', async () => { + await page.goto( '/wp-admin/options-general.php' ); + await page.waitForLoadState( 'domcontentloaded' ); - await page.fill('#blogdescription', 'My Awesome Site Description'); - await page.click('#submit'); - await page.waitForLoadState('domcontentloaded'); + await page.fill( + '#blogdescription', + 'My Awesome Site Description' + ); + await page.click( '#submit' ); + await page.waitForLoadState( 'domcontentloaded' ); - // Wait for task status to update - await page.waitForTimeout(500); - }); + // Wait for task status to update + await page.waitForTimeout( 500 ); + } ); - await test.step('Verify task status changed to pending', async () => { - const task = await tasksApi.getTask('core-blogdescription'); - expect(task).toBeDefined(); - expect(task?.post_status).toBe('pending'); - }); + await test.step( 'Verify task status changed to pending', async () => { + const task = await tasksApi.getTask( 'core-blogdescription' ); + expect( task ).toBeDefined(); + expect( task?.post_status ).toBe( 'pending' ); + } ); - await test.step('Navigate to dashboard and verify task completion', async () => { - await page.goto('/wp-admin/admin.php?page=progress-planner'); - await page.waitForLoadState('domcontentloaded'); + await test.step( 'Navigate to dashboard and verify task completion', async () => { + await page.goto( '/wp-admin/admin.php?page=progress-planner' ); + await page.waitForLoadState( 'domcontentloaded' ); - // Wait for widget container to be visible - const widgetContainer = page.locator('.prpl-widget-wrapper.prpl-suggested-tasks'); - await expect(widgetContainer).toBeVisible({ timeout: 10000 }); + // Wait for widget container to be visible + const widgetContainer = page.locator( + '.prpl-widget-wrapper.prpl-suggested-tasks' + ); + await expect( widgetContainer ).toBeVisible( { timeout: 10000 } ); - // Wait for tasks list to be visible - const tasksList = page.locator('.prpl-widget-wrapper.prpl-suggested-tasks .prpl-suggested-tasks-list'); - await expect(tasksList).toBeVisible(); + // Wait for tasks list to be visible + const tasksList = page.locator( + '.prpl-widget-wrapper.prpl-suggested-tasks .prpl-suggested-tasks-list' + ); + await expect( tasksList ).toBeVisible(); - // Wait for the specific task to appear - const taskElement = page.locator('li[data-task-id="core-blogdescription"]'); - await expect(taskElement).toBeVisible(); + // Wait for the specific task to appear + const taskElement = page.locator( + 'li[data-task-id="core-blogdescription"]' + ); + await expect( taskElement ).toBeVisible(); - // Wait for the celebration animation and task removal (3s delay + 1s buffer) - await page.waitForTimeout(4000); + // Wait for the celebration animation and task removal (3s delay + 1s buffer) + await page.waitForTimeout( 4000 ); - // Verify task is removed from DOM - await expect(taskElement).toHaveCount(0); - }); + // Verify task is removed from DOM + await expect( taskElement ).toHaveCount( 0 ); + } ); - await test.step('Verify task is no longer active via API', async () => { - const task = await tasksApi.getTask('core-blogdescription'); - // Task should either be trash or undefined (fully deleted) - if (task) { - expect(task.post_status).toBe('trash'); - } - // If task is undefined, it was deleted which is also acceptable - }); - }); -}); + await test.step( 'Verify task is no longer active via API', async () => { + const task = await tasksApi.getTask( 'core-blogdescription' ); + // Task should either be trash or undefined (fully deleted) + if ( task ) { + expect( task.post_status ).toBe( 'trash' ); + } + // If task is undefined, it was deleted which is also acceptable + } ); + } ); +} ); diff --git a/tests/e2e/specs/todo-complete.spec.ts b/tests/e2e/specs/todo-complete.spec.ts index c64c4cd562..46d7485084 100644 --- a/tests/e2e/specs/todo-complete.spec.ts +++ b/tests/e2e/specs/todo-complete.spec.ts @@ -2,74 +2,100 @@ import { test, expect } from '../fixtures/base.fixture'; const TEST_TASK_TEXT = 'Task to be completed'; -test.describe('Todo Completion', () => { - // Enable cleanup for this test suite - test.use({ cleanupAfterTest: true }); - - test('should create task and mark as completed', async ({ page, dashboard }) => { - let taskSelector: string; - - await test.step('Navigate and create the task', async () => { - await page.fill('#new-todo-content', TEST_TASK_TEXT); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1500); - - // Get the task selector - const todoItem = page.locator('ul#todo-list > li'); - const taskId = await todoItem.getAttribute('data-task-id'); - taskSelector = `li[data-task-id="${taskId}"]`; - }); - - await test.step('Complete the task', async () => { - const todoItemElement = page.locator(`ul#todo-list ${taskSelector}`); - await todoItemElement.locator('label').click(); - await page.waitForTimeout(1000); - }); - - await test.step('Verify task is not in active list', async () => { - await expect(page.locator(`ul#todo-list ${taskSelector}`)).toHaveCount(0); - }); - - await test.step('Open completed tasks and verify', async () => { - await page.locator('details#todo-list-completed-details').click(); - - // Verify task is in completed list with correct state - const completedTask = page.locator(`ul#todo-list-completed ${taskSelector}`); - await expect(completedTask).toBeVisible(); - await expect(completedTask.locator('h3 > span')).toHaveText(TEST_TASK_TEXT); - await expect(completedTask.locator('.prpl-suggested-task-checkbox')).toBeChecked(); - }); - }); - - test('should verify completed task persists after reload', async ({ page, dashboard }) => { - let taskSelector: string; - - await test.step('Create and complete a task', async () => { - await page.fill('#new-todo-content', TEST_TASK_TEXT); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1500); - - // Get the task selector - const todoItem = page.locator('ul#todo-list > li'); - const taskId = await todoItem.getAttribute('data-task-id'); - taskSelector = `li[data-task-id="${taskId}"]`; - - // Complete the task - const todoItemElement = page.locator(`ul#todo-list ${taskSelector}`); - await todoItemElement.locator('label').click(); - await page.waitForTimeout(1500); - - // Verify task is not in active list - await expect(page.locator(`ul#todo-list ${taskSelector}`)).toHaveCount(0); - - // Open completed tasks - await page.locator('details#todo-list-completed-details').click(); - - // Verify task is in completed list - const completedTask = page.locator(`ul#todo-list-completed ${taskSelector}`); - await expect(completedTask).toBeVisible(); - await expect(completedTask.locator('h3 > span')).toHaveText(TEST_TASK_TEXT); - await expect(completedTask.locator('.prpl-suggested-task-checkbox')).toBeChecked(); - }); - }); -}); +test.describe( 'Todo Completion', () => { + // Enable cleanup for this test suite + test.use( { cleanupAfterTest: true } ); + + test( 'should create task and mark as completed', async ( { + page, + dashboard, + } ) => { + let taskSelector: string; + + await test.step( 'Navigate and create the task', async () => { + await page.fill( '#new-todo-content', TEST_TASK_TEXT ); + await page.keyboard.press( 'Enter' ); + await page.waitForTimeout( 1500 ); + + // Get the task selector + const todoItem = page.locator( 'ul#todo-list > li' ); + const taskId = await todoItem.getAttribute( 'data-task-id' ); + taskSelector = `li[data-task-id="${ taskId }"]`; + } ); + + await test.step( 'Complete the task', async () => { + const todoItemElement = page.locator( + `ul#todo-list ${ taskSelector }` + ); + await todoItemElement.locator( 'label' ).click(); + await page.waitForTimeout( 1000 ); + } ); + + await test.step( 'Verify task is not in active list', async () => { + await expect( + page.locator( `ul#todo-list ${ taskSelector }` ) + ).toHaveCount( 0 ); + } ); + + await test.step( 'Open completed tasks and verify', async () => { + await page.locator( 'details#todo-list-completed-details' ).click(); + + // Verify task is in completed list with correct state + const completedTask = page.locator( + `ul#todo-list-completed ${ taskSelector }` + ); + await expect( completedTask ).toBeVisible(); + await expect( completedTask.locator( 'h3 > span' ) ).toHaveText( + TEST_TASK_TEXT + ); + await expect( + completedTask.locator( '.prpl-suggested-task-checkbox' ) + ).toBeChecked(); + } ); + } ); + + test( 'should verify completed task persists after reload', async ( { + page, + dashboard, + } ) => { + let taskSelector: string; + + await test.step( 'Create and complete a task', async () => { + await page.fill( '#new-todo-content', TEST_TASK_TEXT ); + await page.keyboard.press( 'Enter' ); + await page.waitForTimeout( 1500 ); + + // Get the task selector + const todoItem = page.locator( 'ul#todo-list > li' ); + const taskId = await todoItem.getAttribute( 'data-task-id' ); + taskSelector = `li[data-task-id="${ taskId }"]`; + + // Complete the task + const todoItemElement = page.locator( + `ul#todo-list ${ taskSelector }` + ); + await todoItemElement.locator( 'label' ).click(); + await page.waitForTimeout( 1500 ); + + // Verify task is not in active list + await expect( + page.locator( `ul#todo-list ${ taskSelector }` ) + ).toHaveCount( 0 ); + + // Open completed tasks + await page.locator( 'details#todo-list-completed-details' ).click(); + + // Verify task is in completed list + const completedTask = page.locator( + `ul#todo-list-completed ${ taskSelector }` + ); + await expect( completedTask ).toBeVisible(); + await expect( completedTask.locator( 'h3 > span' ) ).toHaveText( + TEST_TASK_TEXT + ); + await expect( + completedTask.locator( '.prpl-suggested-task-checkbox' ) + ).toBeChecked(); + } ); + } ); +} ); diff --git a/tests/e2e/specs/todo-crud.spec.ts b/tests/e2e/specs/todo-crud.spec.ts index 4aeaa31e81..74dd24e4c2 100644 --- a/tests/e2e/specs/todo-crud.spec.ts +++ b/tests/e2e/specs/todo-crud.spec.ts @@ -3,42 +3,46 @@ import { test, expect } from '../fixtures/base.fixture'; const CREATE_TASK_TEXT = 'Test task to create'; const DELETE_TASK_TEXT = 'Test task to delete'; -test.describe('Todo CRUD Operations', () => { - // Enable cleanup for this test suite - test.use({ cleanupAfterTest: true }); +test.describe( 'Todo CRUD Operations', () => { + // Enable cleanup for this test suite + test.use( { cleanupAfterTest: true } ); - test('should create a new todo', async ({ page, dashboard }) => { - await test.step('Create the todo', async () => { - await page.fill('#new-todo-content', CREATE_TASK_TEXT); - await page.keyboard.press('Enter'); - await page.waitForTimeout(500); - }); + test( 'should create a new todo', async ( { page, dashboard } ) => { + await test.step( 'Create the todo', async () => { + await page.fill( '#new-todo-content', CREATE_TASK_TEXT ); + await page.keyboard.press( 'Enter' ); + await page.waitForTimeout( 500 ); + } ); - await test.step('Verify todo was created', async () => { - const todoItem = page.locator('ul#todo-list > li'); - await expect(todoItem).toHaveCount(1); - await expect(todoItem.locator('h3 > span')).toHaveText(CREATE_TASK_TEXT); - }); - }); + await test.step( 'Verify todo was created', async () => { + const todoItem = page.locator( 'ul#todo-list > li' ); + await expect( todoItem ).toHaveCount( 1 ); + await expect( todoItem.locator( 'h3 > span' ) ).toHaveText( + CREATE_TASK_TEXT + ); + } ); + } ); - test('should delete a todo', async ({ page, dashboard }) => { - await test.step('Create a todo to delete', async () => { - await page.fill('#new-todo-content', DELETE_TASK_TEXT); - await page.keyboard.press('Enter'); - await page.waitForTimeout(500); - }); + test( 'should delete a todo', async ( { page, dashboard } ) => { + await test.step( 'Create a todo to delete', async () => { + await page.fill( '#new-todo-content', DELETE_TASK_TEXT ); + await page.keyboard.press( 'Enter' ); + await page.waitForTimeout( 500 ); + } ); - await test.step('Delete the todo', async () => { - const deleteItem = page.locator('ul#todo-list > li'); - await deleteItem.hover(); - await deleteItem.waitFor({ state: 'visible' }); - await deleteItem.locator('.prpl-suggested-task-actions-wrapper .trash').click(); - await page.waitForTimeout(1500); - }); + await test.step( 'Delete the todo', async () => { + const deleteItem = page.locator( 'ul#todo-list > li' ); + await deleteItem.hover(); + await deleteItem.waitFor( { state: 'visible' } ); + await deleteItem + .locator( '.prpl-suggested-task-actions-wrapper .trash' ) + .click(); + await page.waitForTimeout( 1500 ); + } ); - await test.step('Verify todo was deleted', async () => { - const todoItem = page.locator('ul#todo-list > li'); - await expect(todoItem).toHaveCount(0); - }); - }); -}); + await test.step( 'Verify todo was deleted', async () => { + const todoItem = page.locator( 'ul#todo-list > li' ); + await expect( todoItem ).toHaveCount( 0 ); + } ); + } ); +} ); diff --git a/tests/e2e/specs/todo-reorder.spec.ts b/tests/e2e/specs/todo-reorder.spec.ts index 8bd9adc4a9..8a495f6685 100644 --- a/tests/e2e/specs/todo-reorder.spec.ts +++ b/tests/e2e/specs/todo-reorder.spec.ts @@ -4,62 +4,82 @@ const FIRST_TASK_TEXT = 'First task to reorder'; const SECOND_TASK_TEXT = 'Second task to reorder'; const THIRD_TASK_TEXT = 'Third task to reorder'; -test.describe('Todo Reorder Operations', () => { - // Enable cleanup for this test suite - test.use({ cleanupAfterTest: true }); +test.describe( 'Todo Reorder Operations', () => { + // Enable cleanup for this test suite + test.use( { cleanupAfterTest: true } ); - test('should reorder todo items', async ({ page, dashboard }) => { - await test.step('Create three todos', async () => { - await page.fill('#new-todo-content', FIRST_TASK_TEXT); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1500); + test( 'should reorder todo items', async ( { page, dashboard } ) => { + await test.step( 'Create three todos', async () => { + await page.fill( '#new-todo-content', FIRST_TASK_TEXT ); + await page.keyboard.press( 'Enter' ); + await page.waitForTimeout( 1500 ); - await page.fill('#new-todo-content', SECOND_TASK_TEXT); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1500); + await page.fill( '#new-todo-content', SECOND_TASK_TEXT ); + await page.keyboard.press( 'Enter' ); + await page.waitForTimeout( 1500 ); - await page.fill('#new-todo-content', THIRD_TASK_TEXT); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1500); - }); + await page.fill( '#new-todo-content', THIRD_TASK_TEXT ); + await page.keyboard.press( 'Enter' ); + await page.waitForTimeout( 1500 ); + } ); - await test.step('Verify initial order', async () => { - const todoItems = page.locator('ul#todo-list > li'); - const items = await todoItems.all(); + await test.step( 'Verify initial order', async () => { + const todoItems = page.locator( 'ul#todo-list > li' ); + const items = await todoItems.all(); - await expect(items[0].locator('h3 > span')).toHaveText(FIRST_TASK_TEXT); - await expect(items[1].locator('h3 > span')).toHaveText(SECOND_TASK_TEXT); - await expect(items[2].locator('h3 > span')).toHaveText(THIRD_TASK_TEXT); - }); + await expect( items[ 0 ].locator( 'h3 > span' ) ).toHaveText( + FIRST_TASK_TEXT + ); + await expect( items[ 1 ].locator( 'h3 > span' ) ).toHaveText( + SECOND_TASK_TEXT + ); + await expect( items[ 2 ].locator( 'h3 > span' ) ).toHaveText( + THIRD_TASK_TEXT + ); + } ); - await test.step('Move second item down', async () => { - const todoItems = page.locator('ul#todo-list > li'); - const items = await todoItems.all(); + await test.step( 'Move second item down', async () => { + const todoItems = page.locator( 'ul#todo-list > li' ); + const items = await todoItems.all(); - await items[1].hover(); - await items[1].locator('.prpl-suggested-task-button.move-down').click(); - await page.waitForTimeout(1500); - }); + await items[ 1 ].hover(); + await items[ 1 ] + .locator( '.prpl-suggested-task-button.move-down' ) + .click(); + await page.waitForTimeout( 1500 ); + } ); - await test.step('Verify new order', async () => { - const todoItems = page.locator('ul#todo-list > li'); - const reorderedItems = await todoItems.all(); + await test.step( 'Verify new order', async () => { + const todoItems = page.locator( 'ul#todo-list > li' ); + const reorderedItems = await todoItems.all(); - await expect(reorderedItems[0].locator('h3 > span')).toHaveText(FIRST_TASK_TEXT); - await expect(reorderedItems[1].locator('h3 > span')).toHaveText(THIRD_TASK_TEXT); - await expect(reorderedItems[2].locator('h3 > span')).toHaveText(SECOND_TASK_TEXT); - }); + await expect( + reorderedItems[ 0 ].locator( 'h3 > span' ) + ).toHaveText( FIRST_TASK_TEXT ); + await expect( + reorderedItems[ 1 ].locator( 'h3 > span' ) + ).toHaveText( THIRD_TASK_TEXT ); + await expect( + reorderedItems[ 2 ].locator( 'h3 > span' ) + ).toHaveText( SECOND_TASK_TEXT ); + } ); - await test.step('Reload page and verify order persists', async () => { - await page.reload(); - await page.waitForLoadState('networkidle'); + await test.step( 'Reload page and verify order persists', async () => { + await page.reload(); + await page.waitForLoadState( 'networkidle' ); - const todoItems = page.locator('ul#todo-list > li'); - const persistedItems = await todoItems.all(); + const todoItems = page.locator( 'ul#todo-list > li' ); + const persistedItems = await todoItems.all(); - await expect(persistedItems[0].locator('h3 > span')).toHaveText(FIRST_TASK_TEXT); - await expect(persistedItems[1].locator('h3 > span')).toHaveText(THIRD_TASK_TEXT); - await expect(persistedItems[2].locator('h3 > span')).toHaveText(SECOND_TASK_TEXT); - }); - }); -}); + await expect( + persistedItems[ 0 ].locator( 'h3 > span' ) + ).toHaveText( FIRST_TASK_TEXT ); + await expect( + persistedItems[ 1 ].locator( 'h3 > span' ) + ).toHaveText( THIRD_TASK_TEXT ); + await expect( + persistedItems[ 2 ].locator( 'h3 > span' ) + ).toHaveText( SECOND_TASK_TEXT ); + } ); + } ); +} ); diff --git a/tests/e2e/specs/tour.spec.ts b/tests/e2e/specs/tour.spec.ts index c741b8c8b2..539fb62bb4 100644 --- a/tests/e2e/specs/tour.spec.ts +++ b/tests/e2e/specs/tour.spec.ts @@ -1,44 +1,51 @@ import { test, expect } from '@playwright/test'; -test.describe('Progress Planner Tour', () => { - test('should start the tour when clicking the tour button', async ({ page }) => { - // Navigate to Progress Planner dashboard - await page.goto('/wp-admin/admin.php?page=progress-planner'); - await page.waitForLoadState('networkidle'); - - // Click the tour button - const tourButton = page.locator('#prpl-start-tour-icon-button'); - await tourButton.click(); - - // Wait for and verify the tour popover is visible - let tourPopover = page.locator('.driver-popover'); - await expect(tourPopover).toBeVisible(); - - // Get the number of steps from the window object - const numberOfSteps = await page.evaluate( - () => (window as unknown as { progressPlannerTour: { steps: unknown[] } }).progressPlannerTour.steps.length - ); - - for (let i = 0; i < numberOfSteps - 1; i++) { - tourPopover = page.locator('.driver-popover'); - - // Wait for the popover to be visible before interacting - await expect(tourPopover).toBeVisible(); - - // Click the "Next" button if it's not the last step - if (i < numberOfSteps - 1) { - const nextButton = page.locator('.driver-popover-next-btn'); - await nextButton.click(); - } - } - - const nextButton = page.locator('.driver-popover-next-btn'); - - // Verify the button text changes to "Finish" on the last step - await expect(nextButton).toHaveText('Finish'); - - // Click the finish button and verify the tour popover closes - await nextButton.click(); - await expect(tourPopover).not.toBeVisible(); - }); -}); +test.describe( 'Progress Planner Tour', () => { + test( 'should start the tour when clicking the tour button', async ( { + page, + } ) => { + // Navigate to Progress Planner dashboard + await page.goto( '/wp-admin/admin.php?page=progress-planner' ); + await page.waitForLoadState( 'networkidle' ); + + // Click the tour button + const tourButton = page.locator( '#prpl-start-tour-icon-button' ); + await tourButton.click(); + + // Wait for and verify the tour popover is visible + let tourPopover = page.locator( '.driver-popover' ); + await expect( tourPopover ).toBeVisible(); + + // Get the number of steps from the window object + const numberOfSteps = await page.evaluate( + () => + ( + window as unknown as { + progressPlannerTour: { steps: unknown[] }; + } + ).progressPlannerTour.steps.length + ); + + for ( let i = 0; i < numberOfSteps - 1; i++ ) { + tourPopover = page.locator( '.driver-popover' ); + + // Wait for the popover to be visible before interacting + await expect( tourPopover ).toBeVisible(); + + // Click the "Next" button if it's not the last step + if ( i < numberOfSteps - 1 ) { + const nextButton = page.locator( '.driver-popover-next-btn' ); + await nextButton.click(); + } + } + + const nextButton = page.locator( '.driver-popover-next-btn' ); + + // Verify the button text changes to "Finish" on the last step + await expect( nextButton ).toHaveText( 'Finish' ); + + // Click the finish button and verify the tour popover closes + await nextButton.click(); + await expect( tourPopover ).not.toBeVisible(); + } ); +} ); diff --git a/tests/e2e/specs/yoast-integration.spec.ts b/tests/e2e/specs/yoast-integration.spec.ts index bf07f0c067..9731f884ad 100644 --- a/tests/e2e/specs/yoast-integration.spec.ts +++ b/tests/e2e/specs/yoast-integration.spec.ts @@ -1,75 +1,103 @@ import { test, expect } from '@playwright/test'; -test.describe('Yoast Focus Element', () => { - test('should add Ravi icon to the feed comments toggle', async ({ page }) => { - await page.goto('/wp-admin/admin.php?page=wpseo_page_settings#/crawl-optimization'); - - // Skip if Yoast settings page doesn't load (not installed or wrong version) - if (await page.locator('text=Sorry, you are not allowed').isVisible()) { - test.skip(); - return; - } - - // If there is a modal with overlay (which prevents clicks), close it. - const closeButton = page.locator('button.yst-modal__close-button'); - if (await closeButton.isVisible()) { - await closeButton.click(); - } - - // Wait for the page to load and the toggle to be visible - await page.waitForSelector('button[data-id="input-wpseo-remove_feed_global_comments"]'); - - // Find the toggle input - const toggleInput = page.locator('button[data-id="input-wpseo-remove_feed_global_comments"]'); - - // Find the parent toggle field header - const toggleHeader = toggleInput.locator('xpath=ancestor::div[contains(@class, "yst-toggle-field__header")]'); - - // Verify the Ravi icon exists within the toggle header - const raviIconWrapper = toggleHeader.locator('[data-prpl-element="ravi-icon"]'); - await expect(raviIconWrapper).toBeVisible(); - - // Verify the icon image exists and has correct attributes - const iconImg = raviIconWrapper.locator('img'); - await expect(iconImg).toBeVisible(); - await expect(iconImg).toHaveAttribute('alt', 'Ravi'); - await expect(iconImg).toHaveAttribute('width', '16'); - await expect(iconImg).toHaveAttribute('height', '16'); - - // Verify that the icon is not checked - await expect(raviIconWrapper.locator('.prpl-form-row-points')).toHaveText('+1'); - - // Now click the toggle - await toggleInput.click(); - - // Verify that the icon is now checked - await expect(raviIconWrapper.locator('.prpl-form-row-points')).toHaveText('✓'); - }); - - test('should add Ravi icon to the company logo upload field', async ({ page }) => { - await page.goto('/wp-admin/admin.php?page=wpseo_page_settings#/site-representation'); - - // Skip if Yoast settings page doesn't load - if (await page.locator('text=Sorry, you are not allowed').isVisible()) { - test.skip(); - return; - } - - // Wait for the company logo label to be visible - await page.waitForSelector('#wpseo_titles-company_logo legend.yst-label'); - - // Find the label element - const logoLabel = page.locator('#wpseo_titles-company_logo legend.yst-label'); - - // Verify the Ravi icon exists within the label - const raviIcon = logoLabel.locator('[data-prpl-element="ravi-icon"]'); - await expect(raviIcon).toBeVisible(); - - // Verify the icon image exists and has correct attributes - const iconImg = raviIcon.locator('img'); - await expect(iconImg).toBeVisible(); - await expect(iconImg).toHaveAttribute('alt', 'Ravi'); - await expect(iconImg).toHaveAttribute('width', '16'); - await expect(iconImg).toHaveAttribute('height', '16'); - }); -}); +test.describe( 'Yoast Focus Element', () => { + test( 'should add Ravi icon to the feed comments toggle', async ( { + page, + } ) => { + await page.goto( + '/wp-admin/admin.php?page=wpseo_page_settings#/crawl-optimization' + ); + + // Skip if Yoast settings page doesn't load (not installed or wrong version) + if ( + await page.locator( 'text=Sorry, you are not allowed' ).isVisible() + ) { + test.skip(); + return; + } + + // If there is a modal with overlay (which prevents clicks), close it. + const closeButton = page.locator( 'button.yst-modal__close-button' ); + if ( await closeButton.isVisible() ) { + await closeButton.click(); + } + + // Wait for the page to load and the toggle to be visible + await page.waitForSelector( + 'button[data-id="input-wpseo-remove_feed_global_comments"]' + ); + + // Find the toggle input + const toggleInput = page.locator( + 'button[data-id="input-wpseo-remove_feed_global_comments"]' + ); + + // Find the parent toggle field header + const toggleHeader = toggleInput.locator( + 'xpath=ancestor::div[contains(@class, "yst-toggle-field__header")]' + ); + + // Verify the Ravi icon exists within the toggle header + const raviIconWrapper = toggleHeader.locator( + '[data-prpl-element="ravi-icon"]' + ); + await expect( raviIconWrapper ).toBeVisible(); + + // Verify the icon image exists and has correct attributes + const iconImg = raviIconWrapper.locator( 'img' ); + await expect( iconImg ).toBeVisible(); + await expect( iconImg ).toHaveAttribute( 'alt', 'Ravi' ); + await expect( iconImg ).toHaveAttribute( 'width', '16' ); + await expect( iconImg ).toHaveAttribute( 'height', '16' ); + + // Verify that the icon is not checked + await expect( + raviIconWrapper.locator( '.prpl-form-row-points' ) + ).toHaveText( '+1' ); + + // Now click the toggle + await toggleInput.click(); + + // Verify that the icon is now checked + await expect( + raviIconWrapper.locator( '.prpl-form-row-points' ) + ).toHaveText( '✓' ); + } ); + + test( 'should add Ravi icon to the company logo upload field', async ( { + page, + } ) => { + await page.goto( + '/wp-admin/admin.php?page=wpseo_page_settings#/site-representation' + ); + + // Skip if Yoast settings page doesn't load + if ( + await page.locator( 'text=Sorry, you are not allowed' ).isVisible() + ) { + test.skip(); + return; + } + + // Wait for the company logo label to be visible + await page.waitForSelector( + '#wpseo_titles-company_logo legend.yst-label' + ); + + // Find the label element + const logoLabel = page.locator( + '#wpseo_titles-company_logo legend.yst-label' + ); + + // Verify the Ravi icon exists within the label + const raviIcon = logoLabel.locator( '[data-prpl-element="ravi-icon"]' ); + await expect( raviIcon ).toBeVisible(); + + // Verify the icon image exists and has correct attributes + const iconImg = raviIcon.locator( 'img' ); + await expect( iconImg ).toBeVisible(); + await expect( iconImg ).toHaveAttribute( 'alt', 'Ravi' ); + await expect( iconImg ).toHaveAttribute( 'width', '16' ); + await expect( iconImg ).toHaveAttribute( 'height', '16' ); + } ); +} ); From ea1b94d3455179d3d150517f3a8277e1775d8cd3 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Tue, 30 Dec 2025 13:34:49 +0100 Subject: [PATCH 6/7] Run Yoast tests in parallel with main e2e tests --- .github/workflows/playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index fe0139f6c6..46faf42e69 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -56,9 +56,9 @@ jobs: test-results/ # Yoast Premium tests require Docker (for Composer/premium plugin installation) + # Runs in parallel with e2e-tests - Docker spins up while Playground tests run yoast-premium-tests: runs-on: ubuntu-latest - needs: e2e-tests services: mysql: From 4ff50a1c00c91f346d0c9ff3e18fd8132dfc24ba Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Tue, 30 Dec 2025 13:48:10 +0100 Subject: [PATCH 7/7] Retry CI