From d91ae87daf97056dc5ba3385ecfe216188259ab6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Jul 2026 12:29:39 +0000 Subject: [PATCH] release: v0.2.5 --- .distignore | 50 - .github/workflows/ci.yml | 78 +- .github/workflows/pre-release.yml | 113 +- .gitignore | 1 + .puballow | 66 - .wp-env.json | 12 + CHANGELOG.md | 4 + CONTRIBUTORS.md | 271 +-- README.md | 2 +- airo-wp.php | 4 +- composer.lock | 2 +- data/block-animation-attributes.php | 38 +- data/icon-svg-library.php | 34 +- data/svg-pattern-data.php | 4 +- functions/index.php | 4 + .../Common/DynamicTags/ImageResolver.php | 4 +- includes/Blocks/Common/Forms/FormHandler.php | 19 +- .../Blocks/Common/Forms/FormSubmissions.php | 2 +- includes/Blocks/Common/Query/FilterIndex.php | 2 +- .../Tools/GlobalStyles/ListGlobalStyles.php | 19 +- includes/Mcp/Tools/Plugins/UpdatePlugin.php | 1 - includes/Mcp/Tools/Posts/ListPosts.php | 39 + .../Tools/Posts/UpdatePostImageAltText.php | 13 + includes/Mcp/Tools/Site/UpdateSiteOptions.php | 35 +- includes/Mcp/Tools/Themes/ActivateTheme.php | 22 +- package-lock.json | 1499 ++++++++++++++++- package.json | 23 +- phpcs.xml | 5 + readme.txt | 4 +- .../GlobalStyles/ListGlobalStylesTest.php | 24 +- tests/Unit/Mcp/Tools/Posts/ListPostsTest.php | 37 + .../Posts/UpdatePostImageAltTextTest.php | 20 + .../Mcp/Tools/Site/UpdateSiteOptionsTest.php | 43 + .../Mcp/Tools/Themes/ActivateThemeTest.php | 51 + tests/e2e/check-plugin/.plugin-check-excludes | 9 + .../check-plugin/pcp-ai-provider-setup.php | 276 +++ tests/e2e/check-plugin/run-plugin-check.mjs | 259 ++- tests/e2e/functional/environment/serve-wp.sh | 15 + tests/e2e/functional/playwright.config.ts | 10 +- .../specs/blocks-and-patterns.spec.ts | 28 - tests/e2e/functional/specs/mcp-server.spec.ts | 4 +- .../functional/specs/rest/draft-pages.spec.ts | 35 +- .../tools/mcp-list-global-styles-tool.spec.ts | 15 +- .../specs/tools/mcp-list-posts-tool.spec.ts | 33 +- tests/e2e/setup/build-zip.mjs | 107 ++ tests/scripts/e2e.mjs | 85 + tests/scripts/plugin-check.mjs | 119 ++ tests/scripts/wp-env-exec.mjs | 94 ++ 48 files changed, 2977 insertions(+), 657 deletions(-) delete mode 100644 .distignore delete mode 100644 .puballow create mode 100644 .wp-env.json create mode 100644 tests/e2e/check-plugin/.plugin-check-excludes create mode 100644 tests/e2e/check-plugin/pcp-ai-provider-setup.php create mode 100644 tests/e2e/setup/build-zip.mjs create mode 100644 tests/scripts/e2e.mjs create mode 100644 tests/scripts/plugin-check.mjs create mode 100644 tests/scripts/wp-env-exec.mjs diff --git a/.distignore b/.distignore deleted file mode 100644 index 19df53a..0000000 --- a/.distignore +++ /dev/null @@ -1,50 +0,0 @@ -# Files and directories excluded from the distributable plugin archive. -# This file documents what does NOT ship to end users. - -# Version control -.git -.gitignore -.gitkeep - -# CI / development infrastructure -.github -.dev -.claude -.idea -.vscode -.worktrees -.dockerignore -.phpunit.result.cache -.DS_Store - -# Documentation (readme.txt is the WP standard) -docs -CONTRIBUTORS.md -AGENTS.md -CLAUDE.md -README.md - -# Build tooling -bin -Makefile -composer.lock -phpcs.xml -phpunit.xml.dist - -# Node.js / Playwright (e2e only) -node_modules -package.json -package-lock.json -tsconfig.json -playwright.config.ts -test-results -playwright-report - -# Build config (source and compiled blocks both ship) -webpack.config.js - -# Build output -builds - -# Tests -tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ddda31..2ac47b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI -# PR gate: all four jobs run natively on ubuntu-24.04 — no Docker, no make. -# PHP 7.4 / WP 7.0 on PRs guards the plugin's hard PHP 7.4 compatibility requirement. +# PR gate: PHPCS, PHPUnit, and Plugin Check run on ubuntu-24.04 via wp-env. +# E2E tests run only on release/* branches — see pre-release.yml. on: pull_request: @@ -19,65 +19,51 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - - name: Set up toolchain - run: sh scripts/setup/unit.sh - env: - PHP_VERSION: '7.4' + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - name: Start WordPress environment + run: npm run wp-env:start - name: Run PHPCS - run: sh scripts/tests/lint.sh + run: npm run lint + - name: Stop WordPress environment + if: always() + run: npm run wp-env:stop unit: name: PHPUnit runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - - name: Set up toolchain - run: sh scripts/setup/unit.sh - env: - PHP_VERSION: '7.4' - - name: Run PHPUnit - run: sh scripts/tests/unit.sh - - test-e2e: - name: Functional E2E - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - name: Set up e2e environment - run: sh scripts/setup/e2e.sh - env: - PHP_VERSION: '7.4' - WP_VERSION: '7.0' - - name: Run functional e2e suite - run: sh scripts/tests/e2e.sh - env: - PHP_VERSION: '7.4' - WP_VERSION: '7.0' - - name: Upload Playwright report on failure - if: failure() - uses: actions/upload-artifact@v4 + - uses: actions/setup-node@v4 with: - name: playwright-report - path: | - playwright-report/ - test-results/ - retention-days: 7 + node-version: '20' + cache: npm + - run: npm ci + - name: Start WordPress environment + run: npm run wp-env:start + - name: Run PHPUnit + run: npm run test:unit + - name: Stop WordPress environment + if: always() + run: npm run wp-env:stop check-plugin: name: Plugin Check (PCP) runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - - name: Set up e2e environment - run: sh scripts/setup/e2e.sh - env: - PHP_VERSION: '7.4' - WP_VERSION: '7.0' + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - name: Build distributable zip + run: npm run build:zip - name: Run Plugin Check suite - run: sh scripts/tests/plugin-check.sh - env: - PHP_VERSION: '7.4' - WP_VERSION: '7.0' + run: npm run plugin-check - name: Post Plugin Check results as PR comment if: always() && github.event_name == 'pull_request' uses: marocchino/sticky-pull-request-comment@v2 diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 78fc2cb..c1606b8 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -1,14 +1,11 @@ name: Pre-release -# Full PHP × WP matrix sweep. +# Full WP version matrix sweep. PHP fixed at 8.3 (set in .wp-env.json). # Triggers automatically on release/* PRs to main; also runnable manually. on: workflow_dispatch: inputs: - php_version: - description: 'PHP version to test, or "all"' - default: 'all' wp_version: description: 'WP version to test, or "all"' default: 'all' @@ -21,16 +18,10 @@ jobs: runs-on: ubuntu-24.04 if: startsWith(github.head_ref, 'release/') || github.event_name == 'workflow_dispatch' outputs: - php: ${{ steps.set.outputs.php }} wp: ${{ steps.set.outputs.wp }} steps: - id: set run: | - if [ -z "${{ inputs.php_version }}" ] || [ "${{ inputs.php_version }}" = "all" ]; then - echo 'php=["7.4","8.0","8.1","8.2","8.3"]' >> $GITHUB_OUTPUT - else - echo 'php=["${{ inputs.php_version }}"]' >> $GITHUB_OUTPUT - fi if [ -z "${{ inputs.wp_version }}" ] || [ "${{ inputs.wp_version }}" = "all" ]; then echo 'wp=["6.8","6.9","7.0"]' >> $GITHUB_OUTPUT else @@ -38,81 +29,109 @@ jobs: fi lint: - name: PHPCS (PHP 8.3) + name: PHPCS runs-on: ubuntu-24.04 if: startsWith(github.head_ref, 'release/') || github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 - - run: sh scripts/setup/unit.sh - env: - PHP_VERSION: '8.3' - - run: sh scripts/tests/lint.sh + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm run wp-env:start + - run: npx wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer install --no-interaction --no-progress + - run: npm run lint + - if: always() + run: npm run wp-env:stop unit: - name: PHPUnit (PHP ${{ matrix.php }}) - needs: matrix-setup + name: PHPUnit runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - php: ${{ fromJson(needs.matrix-setup.outputs.php) }} + if: startsWith(github.head_ref, 'release/') || github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 - - run: sh scripts/setup/unit.sh - env: - PHP_VERSION: ${{ matrix.php }} - - run: sh scripts/tests/unit.sh + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm run wp-env:start + - run: npx wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer install --no-interaction --no-progress + - run: npm run test:unit + - if: always() + run: npm run wp-env:stop test-e2e: - name: E2E (PHP ${{ matrix.php }} × WP ${{ matrix.wp }}) + name: E2E (WP ${{ matrix.wp }}) needs: matrix-setup runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - php: ${{ fromJson(needs.matrix-setup.outputs.php) }} wp: ${{ fromJson(needs.matrix-setup.outputs.wp) }} steps: - uses: actions/checkout@v4 - - run: sh scripts/setup/e2e.sh - env: - PHP_VERSION: ${{ matrix.php }} - WP_VERSION: ${{ matrix.wp }} - - run: sh scripts/tests/e2e.sh - env: - PHP_VERSION: ${{ matrix.php }} - WP_VERSION: ${{ matrix.wp }} + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - name: Pin WP version in .wp-env.json + run: | + node -e " + const fs = require('fs'); + const cfg = JSON.parse(fs.readFileSync('.wp-env.json','utf8')); + cfg.core = 'https://wordpress.org/wordpress-${{ matrix.wp }}.zip'; + fs.writeFileSync('.wp-env.json', JSON.stringify(cfg, null, 2)); + " + - run: npm run build + - run: npm run wp-env:start + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + - if: always() + run: npm run wp-env:stop - if: failure() uses: actions/upload-artifact@v4 with: - name: playwright-report-php${{ matrix.php }}-wp${{ matrix.wp }} + name: playwright-report-wp${{ matrix.wp }} path: | playwright-report/ test-results/ retention-days: 7 check-plugin: - name: Plugin Check (PHP ${{ matrix.php }} × WP ${{ matrix.wp }}) + name: Plugin Check (WP ${{ matrix.wp }}) needs: matrix-setup runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - php: ${{ fromJson(needs.matrix-setup.outputs.php) }} wp: ${{ fromJson(needs.matrix-setup.outputs.wp) }} steps: - uses: actions/checkout@v4 - - run: sh scripts/setup/e2e.sh - env: - PHP_VERSION: ${{ matrix.php }} - WP_VERSION: ${{ matrix.wp }} - - run: sh scripts/tests/plugin-check.sh - env: - PHP_VERSION: ${{ matrix.php }} - WP_VERSION: ${{ matrix.wp }} + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - name: Pin WP version in .wp-env.json + run: | + node -e " + const fs = require('fs'); + const cfg = JSON.parse(fs.readFileSync('.wp-env.json','utf8')); + cfg.core = 'https://wordpress.org/wordpress-${{ matrix.wp }}.zip'; + fs.writeFileSync('.wp-env.json', JSON.stringify(cfg, null, 2)); + " + - run: npm run wp-env:start + - run: npx wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer install --no-dev --no-interaction --no-progress + - run: npm run build:zip + - run: npm run plugin-check + - if: always() + run: npm run wp-env:stop - if: failure() uses: actions/upload-artifact@v4 with: - name: plugin-check-php${{ matrix.php }}-wp${{ matrix.wp }} + name: plugin-check-wp${{ matrix.wp }} path: builds/plugin-check-results.txt retention-days: 7 diff --git a/.gitignore b/.gitignore index f28b491..1598317 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ # DSG-owned — generated by make dsg-sync, never edited directly and not committed. # Regenerated by make dsg-sync; shipped to the zip via the package.json "files" # allowlist (build-zip.sh runs sync + build), so they don't need to be tracked. +/.env.local /artifacts/storage-states/*.json diff --git a/.puballow b/.puballow deleted file mode 100644 index 38f92f3..0000000 --- a/.puballow +++ /dev/null @@ -1,66 +0,0 @@ -# .puballow — rsync filter rules for the public mirror workflow -# -# ALLOWLIST: everything is private by default. -# Add a + rule to make a file or directory public. -# The trailing "- *" excludes anything not explicitly listed. -# -# Format (rsync filter rules): -# + pattern include — sync to public repo -# - pattern exclude — keep private -# First matching rule wins. -# -# For directories, two rules are required: -# + /dir/ lets rsync descend into the directory -# + /dir/** includes all contents recursively -# -# Public GitHub files live in .github/public/ in this repo. -# The sync workflow remaps them to .github/ in the public repo. -# Add workflows, issue templates, etc. under .github/public/ — no changes here needed. - -# ── Top-level files ───────────────────────────────────────────────────────── -+ /airo-wp.php -+ /readme.txt -+ /CHANGELOG.md -+ /README.md -+ /CONTRIBUTORS.md -+ /.gitignore -+ /.distignore -+ /.puballow -+ /composer.json -+ /composer.lock -+ /package.json -+ /package-lock.json -+ /phpcs.xml -+ /phpunit.xml.dist -+ /playwright.config.ts -+ /tsconfig.json -+ /webpack.config.js - -# ── PHP source ─────────────────────────────────────────────────────────────── -+ /includes/ -+ /includes/** -+ /functions/ -+ /functions/** - -# ── Tests ───────────────────────────────────────────────────────────────────── -+ /tests/ -+ /tests/** - -# ── Public GitHub files (.github/public/ → remapped to .github/ on sync) ──── -# Add workflows, issue templates, etc. under .github/public/ in this repo. -+ /.github/ -+ /.github/public/ -+ /.github/public/** - -# ── DSG-generated source (gitignored; populated by dsg-sync before rsync) ──── -+ /src/ -+ /src/** -+ /patterns/ -+ /patterns/** -+ /data/ -+ /data/** -+ /dist/ -+ /dist/** - -# ── Catch-all: exclude everything not listed above ──────────────────────────── -- * diff --git a/.wp-env.json b/.wp-env.json new file mode 100644 index 0000000..27f5836 --- /dev/null +++ b/.wp-env.json @@ -0,0 +1,12 @@ +{ + "phpVersion": "8.3", + "port": 9173, + "testsPort": 9190, + "plugins": [ + ".", + "https://downloads.wordpress.org/plugin/plugin-check.2.0.0.zip" + ], + "lifecycleScripts": { + "afterStart": "npx wp-env run tests-cli -- sh -c 'command -v composer || curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer' && npx wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer install && npx wp-env run tests-cli -- wp rewrite structure '/%postname%/' && npx wp-env run tests-cli -- wp rewrite flush --hard && npx wp-env run tests-cli -- wp plugin install hello-dolly --version=1.7.2 --force" + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index f49b09c..33ab984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.2.5 + +- Hardened permission checks on four MCP tools + ## 0.2.4 - Bundled block dynamic tags so shipped blocks resolve dynamic content correctly diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index b61c46c..e530514 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -8,164 +8,115 @@ Airo WP is a **plugin scaffold**: bootstrap, DI container, package loader, Strau ## Prerequisites -Before you begin, install: - -| Tool | Version | -|------|---------| -| PHP | 7.4+ (use **8.3** locally when possible) | -| Composer | 2.x | -| Git | 2.x | -| WordPress | 6.8+ (local site or container) | - -Optional but recommended: - -- A local WordPress environment (e.g. site-designer-local or your team's standard stack) -- `phpcs` / `phpcbf` via Composer (included in dev dependencies) +| Tool | Version | Notes | +|------|---------|-------| +| Node.js | 20+ | Required | +| npm | 10+ | Required | +| Docker | 24+ | Required by the WordPress test environment | +| PHP | 7.4+ | Optional — only needed if running Composer on the host | +| Composer | 2.x | Optional — runs inside the WordPress environment | ## Getting started -### 1. Clone and install dependencies +### 1. Clone and install Node dependencies ```bash git clone git@github.com:godaddy-wordpress/airo-wp.git cd airo-wp -composer install +npm ci ``` -This installs dev tools (PHPUnit, Brain Monkey, WPCS, VIPWPCS), downloads the Strauss phar, and wires autoloaders. - -### 2. Link the plugin into WordPress - -Point your local site's plugins directory at this repo: +### 2. Start the WordPress environment ```bash -# Example: symlink (adjust paths to your environment) -ln -s "$(pwd)" /path/to/wordpress/wp-content/plugins/airo-wp +npm run wp-env:start ``` -Or copy the built plugin tree into `wp-content/plugins/airo-wp/`. +This starts a Docker-based WordPress instance with the plugin loaded. On first run it also installs Composer inside the environment (takes ~2–3 minutes while images are pulled). -### 3. Prefix runtime dependencies (when changing deps or autoload) +### 3. Install PHP dependencies ```bash -composer build +npx wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer install --no-interaction --no-progress ``` -Run this after adding or updating Composer `require` packages, or after changing PSR-4 paths. It runs Strauss to refresh prefixed packages under `dependencies/`, then regenerates `vendor/autoload.php` via Composer. The `dependencies/` directory is gitignored — do not commit it. - ### 4. Verify everything passes -With Docker (recommended; caches `vendor` in named volumes): - ```bash -make check # test + lint (recommended before opening a PR) -make test PHP=8.0 # test on a specific version only -make lint # lint only -make docker-build-all # build all matrix images locally (optional) +npm run test:unit +npm run lint ``` -CI runs the same commands automatically: -- `.github/workflows/phpunit.yml` — PHPUnit matrix on PHP 7.4–8.3 (PR + push to `main`) -- `.github/workflows/phpcs.yml` — PHPCS on PHP 8.3 (PR + push to `main`) -- `.github/workflows/plugin-check.yml` — WordPress Plugin Check on PHP 8.3, WP latest (PR + push to `main`) - -Or locally with Composer (single host PHP only): +Both must pass before you open a pull request. CI runs the same commands automatically. -```bash -composer test -composer lint -``` +### 5. Activate in WordPress -Both must pass before you open a pull request. Build images once with `make docker-build` if `airo-wp-test` / `airo-wp-linter` tags are missing. +The environment boots at `http://localhost:8881` with the plugin active. Admin at `http://localhost:8881/wp-admin/` (user: `admin`, password: `password`). -### 5. Activate in WordPress +### 6. Stop the environment when done -In wp-admin → **Plugins**, activate **Airo WP**. The scaffold has no settings screen; activation only boots the container and placeholder `scaffold` package. +```bash +npm run wp-env:stop +``` ## Development commands | Command | Description | |---------|-------------| -| `make test` | PHPUnit in Docker (default PHP 8.3) | -| `make test PHP=8.0` | PHPUnit in Docker on a specific version (7.4–8.3) | -| `make lint` | PHPCS in Docker | -| `make check` | test + lint | -| `make docker-build` | Build `airo-wp-test` and `airo-wp-linter` images | -| `make docker-build-all` | Build test images for every PHP version | -| `make plugin-check` | Run WordPress Plugin Check in Docker (PHP 8.3, WP latest) | -| `make plugin-check PHP=8.3 WP=6.8` | Plugin Check against a specific WP version | -| `make docker-build-e2e` | Build `airo-wp-e2e` image (PHP + Node.js + WP-CLI) | -| `make e2e` | Run e2e tests in Docker (PHP 8.3, WP 6.9.4) | -| `make e2e PHP=8.0 WP=6.8` | Run e2e tests against a specific PHP × WP version | -| `make playground` | Run WP Playground in Docker (PHP 8.3, latest WP) | -| `make playground PHP=8.2 WP=6.8` | Playground with specific PHP × WP version | -| `make playground-stop` | Stop the running playground container | -| `make playground-reset` | Stop and remove all playground data (fresh start) | -| `make version-bump TYPE=patch` | Bump plugin version in all files (patch/minor/major) | -| `make package` | Build distributable zip (`builds/airo-wp.zip`) | -| `make build TYPE=patch` | Version bump + full build (patch/minor/major) | -| `composer test` | PHPUnit on host PHP (no version matrix) | -| `composer lint` | PHPCS — WPCS + VIP-Go + PHPCompatibility | -| `composer format` | PHPCBF auto-fix | -| `composer build` | Strauss-prefix runtime deps into `dependencies/` | +| `npm run test:unit` | PHPUnit — PHP unit tests | +| `npm run lint` | PHPCS — WordPress coding standards | +| `npm run format` | PHPCBF — auto-fix coding standard issues | +| `npm run build` | Build JS/CSS assets (wp-scripts) | +| `npm run build:zip` | Build distributable plugin zip (`builds/airo-wp-test.zip`) | +| `npm run test:e2e` | Playwright functional end-to-end tests | +| `npm run test:e2e:debug` | Playwright with step debugger | +| `npm run plugin-check` | WordPress Plugin Check (PCP) | +| `npm run wp-env:start` | Start the WordPress environment | +| `npm run wp-env:stop` | Stop the WordPress environment | +| `npm run start` | JS/CSS file watcher | ## Plugin Check -The [WordPress Plugin Check](https://wordpress.org/plugins/plugin-check/) (PCP) verifies the plugin satisfies WordPress.org requirements (security, performance, accessibility, plugin-review standards). +The [WordPress Plugin Check](https://wordpress.org/plugins/plugin-check/) (PCP) verifies the plugin satisfies WordPress.org requirements. ### Running locally ```bash -# Build the e2e image once (shared with e2e tests) -make docker-build-e2e - -# Run Plugin Check (default: PHP 8.3, WP latest) -make plugin-check +# Build the distributable zip first +npx wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer install --no-dev --no-interaction --no-progress +npm run build:zip -# Run against a specific WP version -make plugin-check PHP=8.3 WP=6.8 +# Run Plugin Check +npm run plugin-check ``` -### How it works +Full results are written to `builds/plugin-check-results.txt`. CI posts them as a sticky PR comment. -1. `tests/e2e/setup/plugin-check-entrypoint.sh` runs inside the container: - - Builds a distributable zip via `.dev/release/build-zip.sh` (Composer, DSG sync, wp-scripts build, plugin-zip) - - Downloads WordPress via WP-CLI and sets up SQLite database integration (no MySQL needed) - - Installs the plugin from the built zip (`wp plugin install builds/airo-wp-test.zip`) - - Installs the Plugin Check plugin and runs `wp plugin check` with `--require=cli.php` for runtime checks -2. Full results are saved as markdown to `plugin-check-results/full-results.txt` (posted as a sticky PR comment in CI) -3. A filtered run (excluding baseline ignore codes) determines pass/fail -4. The job hard-fails if any ERROR-type finding is detected in the filtered output +After running plugin-check locally, restore dev dependencies: -### The `.distignore` file - -`.distignore` documents which files and directories are excluded from the distributable plugin archive. The plugin-check build step mirrors these exclusions. When adding new dev-only files or directories, add them to `.distignore` to keep them out of the build. +```bash +npx wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer install --no-interaction --no-progress +``` ## End-to-end tests -E2e tests run WordPress via [`@wp-playground/cli`](https://github.com/WordPress/playground-tools/tree/trunk/packages/playground-cli) (WASM PHP + bundled SQLite, no separate database) and drive the REST API with [Playwright](https://playwright.dev). - -### Prerequisites - -- Docker (same requirement as unit tests) -- No local Node.js or PHP needed — everything runs inside the container +E2e tests run WordPress via the wp-env environment and drive the REST API and browser with [Playwright](https://playwright.dev). ### Running locally ```bash -# Build the e2e image once (or after changing .dev/e2e/Dockerfile) -make docker-build-e2e +# Build JS/CSS assets (required — tests interact with compiled blocks) +npm run build -# Run against the default matrix cell (PHP 8.3, WP 6.9.4) -make e2e +# Install Playwright browser (once) +npx playwright install --with-deps chromium -# Run against a specific PHP × WP version -make e2e PHP=8.0 WP=6.8 +# Start environment and run tests +npm run wp-env:start +npm run test:e2e ``` -Supported WP versions: `6.8`, `6.9`, `7` (must be `>= 6.8` — the plugin's declared minimum). - -On failure, Playwright's HTML report is written to `tests/e2e/playwright-report/` inside the container. Pass `-e PWDEBUG=1` in the `docker run` command to enable step-by-step debugging. +Playwright's HTML report is written to `playwright-report/` on failure. ### What the tests cover @@ -174,53 +125,16 @@ On failure, Playwright's HTML report is written to `tests/e2e/playwright-report/ | `plugin-activation.spec.ts` | Plugin is present and `active` via `GET /wp/v2/plugins` | | `mcp-tools.spec.ts` | MCP `tools/list` returns `gd-mcp-get-site-info`; `tools/call` returns site data | -### How it works - -1. `entrypoint.sh` runs inside the container: - - Builds a distributable zip via `.dev/release/build-zip.sh` (Composer, DSG sync, wp-scripts build, plugin-zip) - - Extracts `builds/airo-wp-test.zip` to a temp directory - - Copies `tests/e2e/setup/e2e-setup.php` into the @wp-playground/cli server's MU-plugins directory (loaded on WP boot) - - Starts `@wp-playground/cli` serving the extracted plugin on port 8080 - - Runs `playwright test` with static test credentials -2. All REST calls use `index.php?rest_route=` instead of `/wp-json/` because @wp-playground/cli does not rewrite URLs at the Node level. - -### CI +## CI -The workflow at `.github/workflows/e2e.yml` is **manual only** (`workflow_dispatch`). It accepts optional `php_version` and `wp_version` inputs (defaults to `all`) and runs the full PHP × WP matrix with `fail-fast: false`. Trigger it from the GitHub Actions tab when you need to verify backward compatibility. +CI runs automatically on pull requests and pushes to `main`: -## WP Playground +- **PHPUnit** — PHP unit tests +- **PHPCS** — WordPress coding standards +- **Functional E2E** — Playwright test suite +- **Plugin Check (PCP)** — WordPress.org plugin requirements check -A local WordPress instance running the plugin with live source binding — useful for quick fixes, prototyping, and manual testing. - -### Running - -```bash -# Start (first run builds the image and installs deps) -make playground - -# Specific PHP and WP versions -make playground PHP=8.2 WP=6.8 - -# Stop -make playground-stop - -# Reset all state (fresh WordPress on next start) -make playground-reset -``` - -### How it works - -- Uses [`@wp-playground/cli`](https://github.com/WordPress/playground-tools/tree/trunk/packages/playground-cli) (WordPress Playground for Node.js) inside Docker -- Plugin source is bind-mounted — edits reflect immediately without restart -- WordPress state (SQLite database, uploads, settings) persists in the `airo-wp-playground-data` Docker volume across container restarts -- Browser auto-opens at `http://localhost:9400`; admin at `http://localhost:9400/wp-admin/` -- The Docker image is built from the `base` stage of `.dev/e2e/Dockerfile` (PHP + Node 24 + Composer, shared with the e2e image) - -### Known limitations - -- @wp-playground/cli uses PHP-WASM (WebAssembly), not native PHP — step-debugging (Xdebug) is not available -- WordPress loopback requests (Site Health REST API check) time out — this is a PHP-WASM limitation and does not affect normal plugin development -- Plugin source changes are live, but `vendor/` changes require stopping and restarting (`make playground-stop && make playground`) +All four must pass before merge. ## Architecture @@ -231,38 +145,11 @@ make playground-reset | `includes/Packages.php` | Domain package registry | | `includes/Container.php` | DI facade | | `functions/` | Global helpers (`functions/index.php` → per-domain files) | -| `dependencies/` | Strauss-prefixed runtime vendors (gitignored; generated at build time) | -| `tests/` | PHPUnit unit tests | - -**Autoloading:** Composer's `vendor/autoload.php` loads plugin code (`includes/`, `functions/`) and Strauss-prefixed runtime packages under `dependencies/`. Run `composer install` (or `composer install --no-dev` for releases) before activating the plugin. - -## Project structure - -``` -airo-wp.php # Plugin header + bootstrap (keep thin) -readme.txt # WordPress.org-style readme (screens, changelog) -includes/ # All PHP classes (PSR-4) - Container.php - Packages.php - Plugin.php - Internal/ # Infrastructure (container, etc.) - {YourDomain}/ # Your feature package - Package.php -functions/ - index.php # Composer files autoload entry - container.php # AiroWP() - {your-domain}.php # Optional global helpers -dependencies/ # Strauss-prefixed vendors (gitignored; generated by composer build) -tests/ # PHPUnit + Brain Monkey -``` - -Namespace root: `GoDaddy\WordPress\Plugins\AiroWp\` +| `tests/` | PHPUnit unit tests + Playwright e2e tests | ## Adding a feature (domain package) -1. **Create the package class** - - `includes/MyFeature/Package.php`: +1. **Create the package class** — `includes/MyFeature/Package.php`: ```php MyFeature\Package::class, ``` -3. **Prefer DI over `new`** — resolve services via `$container->get( SomeService::class )` with constructor or `init()` type hints. - -4. **Optional helpers** — add `functions/my-feature.php` and `require_once` it from `functions/index.php`. - -5. **Tests** — add `tests/Unit/MyFeature/` with Brain Monkey for WordPress APIs. +3. **Tests** — add `tests/Unit/MyFeature/` with Brain Monkey for WordPress APIs. -6. **Run** `composer test`, `composer lint`, and `composer build` if you changed Composer dependencies (`dependencies/` is regenerated automatically and should not be committed). +4. **Run** `npm run test:unit` and `npm run lint` before opening a PR. ## Coding standards - **PHPCS** with WordPress-Core, WordPress-Extra, WordPress-Docs, **WordPress-VIP-Go**, and PHPCompatibilityWP (`testVersion` 7.4–8.3). -- Run `make lint` (Docker, matches CI) before pushing; `composer lint` works too if Docker is unavailable. `composer format` (or `phpcbf`) fixes many issues automatically. - Use `declare(strict_types=1);` in new PHP files. -- Add `defined( 'ABSPATH' ) || exit;` to every PHP file for direct file access protection. Place it **after** the `namespace` declaration in namespaced files, or after `declare(strict_types=1)` in non-namespaced files (PHP requires `namespace` to directly follow `declare`). -- Keep `airo-wp.php` free of business logic. -- Write PHP **7.4-compatible** code in `includes/` (no enums/readonly in shared code unless the team agrees to raise the minimum). -- **Brain Monkey** for unit tests; use `Proxies\LegacyProxy` only when a WordPress function is impractical to mock. - -## Dependency and autoload rules - -- **Runtime** packages go in `composer.json` `require` → Strauss prefixes them into `dependencies/`. -- **Dev-only** tools stay in `require-dev` → `vendor/` only, not shipped. -- Do not commit `vendor/`, `dependencies/`, or `bin/strauss.phar`. -- `dependencies/` is gitignored and generated automatically by `composer build` (or `make test` on first run). +- Add `defined( 'ABSPATH' ) || exit;` to every PHP file for direct file access protection. Place it **after** the `namespace` declaration in namespaced files, or after `declare(strict_types=1)` in non-namespaced files. +- Write PHP **7.4-compatible** code in `includes/`. ## Pull requests -1. Branch from `main` (or your team's default branch). -2. Keep changes focused; one domain or one infrastructure concern per PR when possible. -3. Ensure CI passes (PHPUnit matrix 7.4–8.3, PHPCS on 8.3). +1. Branch from `main`. +2. Keep changes focused — one domain or one infrastructure concern per PR. +3. Ensure CI passes (all four jobs). 4. Describe **what** changed and **why** in the PR body. 5. No secrets, `.env` files, or local paths in commits. ## Local docs -Brainstorming specs and implementation plans may live under `docs/superpowers/` on your machine. That directory is **gitignored** and is not part of the plugin distribution. +Brainstorming specs and implementation plans may live under `docs/superpowers/` on your machine. That directory is not part of the plugin distribution. ## Questions -For team-specific process (code review owners, release cadence, Jira tickets), use your GoDaddy / Site Designer team channels. For plugin architecture questions, refer to `README.md` and the inline structure in `includes/Packages.php` and `includes/Container.php`. +For team-specific process, use your team's standard channels. For plugin architecture questions, refer to `README.md` and the inline structure in `includes/Packages.php` and `includes/Container.php`. diff --git a/README.md b/README.md index 2e477df..f15e021 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ |---|---| | **Plugin name** | Airo WP AI Builder | | **Text domain** | `airo-wp` | -| **Version** | 0.2.4 | +| **Version** | 0.2.5 | | **Requires WordPress** | 6.8+ | | **Requires PHP** | 7.4+ | | **License** | [GPLv2 or later](https://www.gnu.org/licenses/gpl-2.0.html) | diff --git a/airo-wp.php b/airo-wp.php index b6013a5..fbd656c 100644 --- a/airo-wp.php +++ b/airo-wp.php @@ -3,7 +3,7 @@ * Plugin Name: Airo WP AI Builder * Plugin URI: https://github.com/godaddy-wordpress/airo-wp * Description: MCP server and block pattern library for AI-powered site building. - * Version: 0.2.4 + * Version: 0.2.5 * Requires at least: 6.9 * Requires PHP: 7.4 * Author: GoDaddy @@ -20,7 +20,7 @@ defined( 'ABSPATH' ) || exit; if ( ! defined( 'AIRO_WP_VERSION' ) ) { - define( 'AIRO_WP_VERSION', '0.2.4' ); + define( 'AIRO_WP_VERSION', '0.2.5' ); } if ( ! defined( 'AIRO_WP_PLUGIN_FILE' ) ) { define( 'AIRO_WP_PLUGIN_FILE', __FILE__ ); diff --git a/composer.lock b/composer.lock index 99275d8..9649390 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "80d70621e97d640336dfa6e324027f73", + "content-hash": "23d8279a68202aa0b462da83eab25aff", "packages": [ { "name": "psr/container", diff --git a/data/block-animation-attributes.php b/data/block-animation-attributes.php index 124f325..15e00a9 100644 --- a/data/block-animation-attributes.php +++ b/data/block-animation-attributes.php @@ -5,7 +5,7 @@ * Provides utility functions to add animation data attributes * to dynamic blocks during server-side rendering. * - * @package DesignSetGo + * @package airo-wp * @since 1.0.0 */ @@ -22,7 +22,7 @@ * @param array $attributes Block attributes array. * @return array Array of data attributes for animations. */ -function designsetgo_get_animation_attributes( $attributes ) { +function airowp_get_animation_attributes( $attributes ) { $animation_attrs = array(); $animation_classes = array(); @@ -37,61 +37,61 @@ function designsetgo_get_animation_attributes( $attributes ) { } // Add animation classes. - $animation_classes[] = 'has-dsgo-animation'; + $animation_classes[] = 'has-airo-wp-animation'; // Add entrance animation class. $entrance_animation = isset( $attributes['dsgoEntranceAnimation'] ) ? $attributes['dsgoEntranceAnimation'] : ''; if ( $entrance_animation ) { - $animation_classes[] = 'dsgo-animation-' . esc_attr( $entrance_animation ); + $animation_classes[] = 'airo-wp-animation-' . esc_attr( $entrance_animation ); } // Add exit animation class. $exit_animation = isset( $attributes['dsgoExitAnimation'] ) ? $attributes['dsgoExitAnimation'] : ''; if ( $exit_animation ) { - $animation_classes[] = 'dsgo-animation-exit-' . esc_attr( $exit_animation ); + $animation_classes[] = 'airo-wp-animation-exit-' . esc_attr( $exit_animation ); } // Always include the enabled flag and animation type(s) — required by frontend JS. - $animation_attrs['data-dsgo-animation-enabled'] = 'true'; + $animation_attrs['data-airo-wp-animation-enabled'] = 'true'; if ( $entrance_animation ) { - $animation_attrs['data-dsgo-entrance-animation'] = esc_attr( $entrance_animation ); + $animation_attrs['data-airo-wp-entrance-animation'] = esc_attr( $entrance_animation ); } if ( $exit_animation ) { - $animation_attrs['data-dsgo-exit-animation'] = esc_attr( $exit_animation ); + $animation_attrs['data-airo-wp-exit-animation'] = esc_attr( $exit_animation ); } // Only output settings that differ from defaults to keep markup lean. // Defaults: trigger=scroll, duration=600, delay=0, easing=ease-out, offset=100, once=true. $trigger = isset( $attributes['dsgoAnimationTrigger'] ) ? $attributes['dsgoAnimationTrigger'] : 'scroll'; if ( 'scroll' !== $trigger ) { - $animation_attrs['data-dsgo-animation-trigger'] = esc_attr( $trigger ); + $animation_attrs['data-airo-wp-animation-trigger'] = esc_attr( $trigger ); } $duration = isset( $attributes['dsgoAnimationDuration'] ) ? (int) $attributes['dsgoAnimationDuration'] : 600; if ( 600 !== $duration ) { - $animation_attrs['data-dsgo-animation-duration'] = esc_attr( (string) $duration ); + $animation_attrs['data-airo-wp-animation-duration'] = esc_attr( (string) $duration ); } $delay = isset( $attributes['dsgoAnimationDelay'] ) ? (int) $attributes['dsgoAnimationDelay'] : 0; if ( 0 !== $delay ) { - $animation_attrs['data-dsgo-animation-delay'] = esc_attr( (string) $delay ); + $animation_attrs['data-airo-wp-animation-delay'] = esc_attr( (string) $delay ); } $easing = isset( $attributes['dsgoAnimationEasing'] ) ? $attributes['dsgoAnimationEasing'] : 'ease-out'; if ( 'ease-out' !== $easing ) { - $animation_attrs['data-dsgo-animation-easing'] = esc_attr( $easing ); + $animation_attrs['data-airo-wp-animation-easing'] = esc_attr( $easing ); } $offset = isset( $attributes['dsgoAnimationOffset'] ) ? (int) $attributes['dsgoAnimationOffset'] : 100; if ( 100 !== $offset ) { - $animation_attrs['data-dsgo-animation-offset'] = esc_attr( (string) $offset ); + $animation_attrs['data-airo-wp-animation-offset'] = esc_attr( (string) $offset ); } $once = isset( $attributes['dsgoAnimationOnce'] ) ? (bool) $attributes['dsgoAnimationOnce'] : true; if ( ! $once ) { - $animation_attrs['data-dsgo-animation-once'] = 'false'; + $animation_attrs['data-airo-wp-animation-once'] = 'false'; } // Convert classes array to string. @@ -117,7 +117,7 @@ function designsetgo_get_animation_attributes( $attributes ) { * @param array $attributes Block attributes array. * @return array Array of data attributes for links. */ -function designsetgo_get_clickable_attributes( $attributes ) { +function airowp_get_clickable_attributes( $attributes ) { $link_attrs = array(); $link_classes = array(); @@ -131,7 +131,7 @@ function designsetgo_get_clickable_attributes( $attributes ) { } // Add clickable class. - $link_classes[] = 'dsgo-clickable'; + $link_classes[] = 'airo-wp-clickable'; // Add link data attributes. $link_attrs['data-link-url'] = esc_attr( $link_url ); @@ -171,12 +171,12 @@ function designsetgo_get_clickable_attributes( $attributes ) { * @param array $attributes Block attributes array. * @return string Modified wrapper attributes string. */ -function designsetgo_add_animation_to_wrapper( $wrapper_attributes, $attributes ) { +function airowp_add_animation_to_wrapper( $wrapper_attributes, $attributes ) { // Get animation data. - $animation_data = designsetgo_get_animation_attributes( $attributes ); + $animation_data = airowp_get_animation_attributes( $attributes ); // Get clickable link data. - $clickable_data = designsetgo_get_clickable_attributes( $attributes ); + $clickable_data = airowp_get_clickable_attributes( $attributes ); // Combine all classes. $all_classes = trim( $animation_data['classes'] . ' ' . $clickable_data['classes'] ); diff --git a/data/icon-svg-library.php b/data/icon-svg-library.php index 6e8ce5e..ed54f7f 100644 --- a/data/icon-svg-library.php +++ b/data/icon-svg-library.php @@ -2,11 +2,11 @@ /** * SVG Icon Library - PHP Version * - * Shared icon library for all DesignSetGo blocks. + * Shared icon library for all airo-wp blocks. * Inline SVG icons to avoid font loading issues. * Auto-generated from src/blocks/icon/utils/svg-icons.js * - * @package DesignSetGo + * @package airo-wp * @since 1.0.0 */ @@ -21,15 +21,15 @@ * @param string $icon_name Icon name (e.g., 'star', 'heart', 'check'). * @return string SVG markup or empty string if not found. */ -function designsetgo_get_icon_svg( $icon_name ) { - $icons = designsetgo_get_all_icons(); +function airowp_get_icon_svg( $icon_name ) { + $icons = airowp_get_all_icons(); if ( isset( $icons[ $icon_name ] ) ) { return $icons[ $icon_name ]; } // Resolve alias to canonical name. - $aliases = designsetgo_get_icon_aliases(); + $aliases = airowp_get_icon_aliases(); if ( isset( $aliases[ $icon_name ] ) && isset( $icons[ $aliases[ $icon_name ] ] ) ) { return $icons[ $aliases[ $icon_name ] ]; } @@ -42,7 +42,7 @@ function designsetgo_get_icon_svg( $icon_name ) { * * Mirrors the JS getIcon() helper (src/blocks/icon/utils/svg-icons.js): the * stored markup is the filled variant, and "outlined" is produced purely in CSS - * via the shared `.dsgo-icon-outlined` rule (src/styles/_icon-outlined.scss), + * via the shared `.airo-wp-icon-outlined` rule (src/styles/_icon-outlined.scss), * which strips the fills and redraws paths as strokes sized by * `--icon-stroke-width`. Falls back to the 'star' icon for unknown names, the * same as the JS helper. @@ -55,12 +55,12 @@ function designsetgo_get_icon_svg( $icon_name ) { * @param float|int $stroke_width Stroke width for the outlined style. * @return string SVG markup (optionally wrapped for the outlined style). */ -function designsetgo_render_icon_svg( $icon_name, $style = 'filled', $stroke_width = 1.5 ) { - $svg = designsetgo_get_icon_svg( $icon_name ); +function airowp_render_icon_svg( $icon_name, $style = 'filled', $stroke_width = 1.5 ) { + $svg = airowp_get_icon_svg( $icon_name ); // Mirror the JS fallback to the 'star' icon for unknown names. if ( '' === $svg ) { - $svg = designsetgo_get_icon_svg( 'star' ); + $svg = airowp_get_icon_svg( 'star' ); } if ( '' === $svg ) { @@ -73,7 +73,7 @@ function designsetgo_render_icon_svg( $icon_name, $style = 'filled', $stroke_wid $stroke = (float) $stroke_width; return sprintf( - '%s', + '%s', esc_attr( (string) $stroke ), $svg ); @@ -87,7 +87,7 @@ function designsetgo_render_icon_svg( $icon_name, $style = 'filled', $stroke_wid * * @return array Associative array of icon_name => svg_markup. */ -function designsetgo_get_all_icons() { +function airowp_get_all_icons() { static $icons = null; // Cache icons in static variable for performance. @@ -268,18 +268,18 @@ function designsetgo_get_all_icons() { * Get icon alias map. * * Maps alternate icon names to their canonical names in the icon library. - * Single source of truth: includes/data/icon-aliases.json (shared with JS). + * Single source of truth: data/icon-aliases.json (shared with JS). * * @return array Associative array of alias => canonical_name. */ -function designsetgo_get_icon_aliases() { +function airowp_get_icon_aliases() { static $aliases = null; if ( null !== $aliases ) { return $aliases; } - $file = __DIR__ . '/data/icon-aliases.json'; + $file = dirname( dirname( __DIR__ ) ) . '/data/icon-aliases.json'; if ( ! file_exists( $file ) ) { $aliases = array(); @@ -300,7 +300,7 @@ function designsetgo_get_icon_aliases() { * @param string $icon Icon slug to sanitize. * @return string Sanitized icon slug. */ -function designsetgo_sanitize_icon_slug( $icon ) { +function airowp_sanitize_icon_slug( $icon ) { if ( empty( $icon ) || ! is_string( $icon ) ) { return ''; } @@ -317,7 +317,7 @@ function designsetgo_sanitize_icon_slug( $icon ) { * @param bool $is_open Whether accordion item is open. * @return string Icon HTML markup. */ -function designsetgo_accordion_render_icon( $icon_style, $is_open ) { +function airowp_accordion_render_icon( $icon_style, $is_open ) { if ( 'none' === $icon_style ) { return ''; } @@ -340,5 +340,5 @@ function designsetgo_accordion_render_icon( $icon_style, $is_open ) { $icon_svg = ''; } - return ''; + return ''; } diff --git a/data/svg-pattern-data.php b/data/svg-pattern-data.php index 1fe8ec0..fd2e646 100644 --- a/data/svg-pattern-data.php +++ b/data/svg-pattern-data.php @@ -11,7 +11,7 @@ * * SECURITY: Path `d` values are static data and must never accept user input. * - * @package DesignSetGo + * @package airo-wp * @since 2.1.0 */ @@ -23,7 +23,7 @@ * * @return array Pattern definitions keyed by pattern ID. */ -function designsetgo_get_svg_pattern_data() { +function airowp_get_svg_pattern_data() { return array( // ── MINIMAL ────────────────────────────────────── diff --git a/functions/index.php b/functions/index.php index 8df7e9d..5821f34 100644 --- a/functions/index.php +++ b/functions/index.php @@ -13,6 +13,10 @@ return; } +if ( file_exists( dirname( __DIR__ ) . '/data/icon-svg-library.php' ) ) { + require_once dirname( __DIR__ ) . '/data/icon-svg-library.php'; +} + if ( file_exists( __DIR__ . '/blocks/index.php' ) ) { require_once __DIR__ . '/blocks/index.php'; } diff --git a/includes/Blocks/Common/DynamicTags/ImageResolver.php b/includes/Blocks/Common/DynamicTags/ImageResolver.php index 6b551d0..e1ff56d 100644 --- a/includes/Blocks/Common/DynamicTags/ImageResolver.php +++ b/includes/Blocks/Common/DynamicTags/ImageResolver.php @@ -185,8 +185,8 @@ private static function resolve_url_via_bindings( $slug, array $args, $post_id ) return ''; } $args['__airowp_post_id'] = $post_id; - $args['subkey'] = $args['subkey'] ?? 'url'; - $value = call_user_func( $binding->get_value_callback, $args, null, 'url' ); + $args['subkey'] = $args['subkey'] ?? 'url'; + $value = call_user_func( $binding->get_value_callback, $args, null, 'url' ); if ( is_string( $value ) && '' !== $value && filter_var( $value, FILTER_VALIDATE_URL ) ) { return $value; } diff --git a/includes/Blocks/Common/Forms/FormHandler.php b/includes/Blocks/Common/Forms/FormHandler.php index 520134c..f5b6674 100644 --- a/includes/Blocks/Common/Forms/FormHandler.php +++ b/includes/Blocks/Common/Forms/FormHandler.php @@ -202,17 +202,30 @@ public function register_rest_endpoint() { 'default' => '', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function ( $value ) { + if ( ! is_string( $value ) ) { + return new \WP_Error( + 'invalid_turnstile_token', + __( 'Invalid Turnstile token format.', 'airo-wp' ) + ); + } + // Empty is valid (graceful degradation). - if ( empty( $value ) ) { + if ( '' === $value ) { return true; } - // Turnstile tokens are alphanumeric with hyphens/underscores. - if ( ! preg_match( '/^[a-zA-Z0-9_-]+$/', $value ) ) { + + // The token is opaque: Cloudflare only guarantees it is at most + // 2048 characters, and its alphabet is not contractual. Real tokens + // are dot-delimited, so do not try to parse their structure — + // bound the length and reject anything that is not printable ASCII + // (whitespace, newlines, NUL) before forwarding it to siteverify. + if ( strlen( $value ) > 2048 || ! preg_match( '/^[!-~]+$/', $value ) ) { return new \WP_Error( 'invalid_turnstile_token', __( 'Invalid Turnstile token format.', 'airo-wp' ) ); } + return true; }, ), diff --git a/includes/Blocks/Common/Forms/FormSubmissions.php b/includes/Blocks/Common/Forms/FormSubmissions.php index 212f360..bce87cd 100644 --- a/includes/Blocks/Common/Forms/FormSubmissions.php +++ b/includes/Blocks/Common/Forms/FormSubmissions.php @@ -279,7 +279,7 @@ public function custom_column_content( $column, $post_id ) { * * @param string $hook Current admin page hook suffix. */ - public function enqueue_submission_styles( string $hook ) : void { + public function enqueue_submission_styles( string $hook ): void { if ( 'post.php' !== $hook && 'post-new.php' !== $hook ) { return; } diff --git a/includes/Blocks/Common/Query/FilterIndex.php b/includes/Blocks/Common/Query/FilterIndex.php index d8c315b..f7f88a9 100644 --- a/includes/Blocks/Common/Query/FilterIndex.php +++ b/includes/Blocks/Common/Query/FilterIndex.php @@ -473,7 +473,7 @@ public static function install(): void { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - $table = self::table_name(); + $table = self::table_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $charset = $wpdb->get_charset_collate(); $stored_schema = (string) get_option( self::OPTION_SCHEMA, '0' ); diff --git a/includes/Mcp/Tools/GlobalStyles/ListGlobalStyles.php b/includes/Mcp/Tools/GlobalStyles/ListGlobalStyles.php index 3c77faf..dfc9331 100644 --- a/includes/Mcp/Tools/GlobalStyles/ListGlobalStyles.php +++ b/includes/Mcp/Tools/GlobalStyles/ListGlobalStyles.php @@ -70,16 +70,6 @@ public function execute( array $input ): array { 'order' => 'DESC', ); - if ( ! empty( $theme ) ) { - $query_args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - array( - 'key' => 'theme', - 'value' => $theme, - 'compare' => '=', - ), - ); - } - // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.get_posts_get_posts $global_styles = get_posts( $query_args ); @@ -97,15 +87,20 @@ public function execute( array $input ): array { ); } + // WordPress stores the theme association in post_name + // (e.g. "wp-global-styles-twentytwentyfive"), not in post meta. $styles = array(); foreach ( $global_styles as $style ) { - $theme_slug = get_post_meta( $style->ID, 'theme', true ); + $theme_slug = ''; + if ( preg_match( '/^wp-global-styles-(.+)$/', $style->post_name, $matches ) ) { + $theme_slug = $matches[1]; + } if ( empty( $theme ) || $theme_slug === $theme ) { $styles[] = array( 'id' => (int) $style->ID, 'title' => $style->post_title, - 'theme' => $theme_slug ? $theme_slug : '', + 'theme' => $theme_slug, 'status' => $style->post_status, 'date' => $style->post_date, 'modified' => $style->post_modified, diff --git a/includes/Mcp/Tools/Plugins/UpdatePlugin.php b/includes/Mcp/Tools/Plugins/UpdatePlugin.php index 8204f70..bbc1717 100644 --- a/includes/Mcp/Tools/Plugins/UpdatePlugin.php +++ b/includes/Mcp/Tools/Plugins/UpdatePlugin.php @@ -85,7 +85,6 @@ private function get_input_schema(): array { 'plugin_slug' => array( 'type' => 'string', 'description' => __( 'The plugin slug (e.g., "hello-dolly", "akismet")', 'airo-wp' ), - 'minLength' => 1, ), 'version' => array( 'type' => 'string', diff --git a/includes/Mcp/Tools/Posts/ListPosts.php b/includes/Mcp/Tools/Posts/ListPosts.php index f8c78b5..4a3317d 100644 --- a/includes/Mcp/Tools/Posts/ListPosts.php +++ b/includes/Mcp/Tools/Posts/ListPosts.php @@ -55,6 +55,45 @@ public function register(): void { */ public function execute( array $input ): array { $query_args = $this->build_query_args( $input ); + $post_type = $query_args['post_type']; + $status = $query_args['post_status']; + + // check_permissions() only validates edit_posts for built-in types; custom post + // types may register different edit caps, so verify against the actual type cap. + $builtin_types = array( 'post', 'page', 'attachment', 'revision', 'nav_menu_item' ); + if ( ! in_array( $post_type, $builtin_types, true ) ) { + $post_type_obj = get_post_type_object( $post_type ); + if ( ! $post_type_obj ) { + return array( + 'success' => false, + 'message' => sprintf( + /* translators: %s: post type slug */ + __( 'Invalid post type: %s', 'airo-wp' ), + $post_type + ), + ); + } + if ( ! current_user_can( $post_type_obj->cap->edit_posts ) ) { + return array( + 'success' => false, + 'message' => __( 'You do not have permission to query this post type.', 'airo-wp' ), + ); + } + } + + // Non-public statuses expose unpublished content across all authors, which + // requires edit_others_posts (or the type-specific equivalent). + $non_public = array( 'private', 'draft', 'pending', 'future', 'trash', 'any' ); + if ( in_array( $status, $non_public, true ) ) { + $post_type_obj = get_post_type_object( $post_type ); + $edit_others_cap = $post_type_obj ? $post_type_obj->cap->edit_others_posts : 'edit_others_posts'; + if ( ! current_user_can( $edit_others_cap ) ) { + return array( + 'success' => false, + 'message' => __( 'You do not have permission to query posts with this status.', 'airo-wp' ), + ); + } + } $query = new \WP_Query( $query_args ); diff --git a/includes/Mcp/Tools/Posts/UpdatePostImageAltText.php b/includes/Mcp/Tools/Posts/UpdatePostImageAltText.php index c8378b0..d92b498 100644 --- a/includes/Mcp/Tools/Posts/UpdatePostImageAltText.php +++ b/includes/Mcp/Tools/Posts/UpdatePostImageAltText.php @@ -97,6 +97,19 @@ public function execute( array $input ): array { ); } + // check_permissions() only validates edit_posts globally; authors cannot edit + // other authors' posts, so we need the per-post meta cap after resolving the post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return array( + 'success' => false, + 'message' => sprintf( + /* translators: %d: post ID */ + __( 'You do not have permission to edit post %d.', 'airo-wp' ), + $post_id + ), + ); + } + // Parse the post content into blocks. $blocks = parse_blocks( $post->post_content ); $normalized_image_src = $this->normalize_url( $image_src ); diff --git a/includes/Mcp/Tools/Site/UpdateSiteOptions.php b/includes/Mcp/Tools/Site/UpdateSiteOptions.php index d10929e..04e946b 100644 --- a/includes/Mcp/Tools/Site/UpdateSiteOptions.php +++ b/includes/Mcp/Tools/Site/UpdateSiteOptions.php @@ -31,6 +31,10 @@ class UpdateSiteOptions extends BaseTool { /** * Protected option names that cannot be modified. * + * All values must be lowercase; is_protected_option() compares against strtolower($name). + * AI service API keys are included because they grant external service access and must + * not be writable via the MCP surface. + * * @var array */ private const PROTECTED_OPTIONS = array( @@ -45,6 +49,22 @@ class UpdateSiteOptions extends BaseTool { 'secure_auth_salt', 'logged_in_salt', 'nonce_salt', + // AI service credentials. + 'openai_api_key', + 'anthropic_api_key', + ); + + /** + * Protected option name prefixes that cannot be modified. + * + * Any option whose lowercased name starts with one of these prefixes is treated as + * protected. Add vendor-specific credential prefixes here rather than enumerating + * every individual key. + * + * @var array + */ + private const PROTECTED_PREFIXES = array( + 'gd_mwcs_', // GoDaddy Managed WooCommerce Connect service credentials. ); /** @@ -316,7 +336,20 @@ private function canonical_option_name( string $name ): string { * @return bool True if the option is protected. */ private function is_protected_option( string $name ): bool { - return in_array( strtolower( $name ), self::PROTECTED_OPTIONS, true ); + $lower = strtolower( $name ); + + if ( in_array( $lower, self::PROTECTED_OPTIONS, true ) ) { + return true; + } + + // str_starts_with() requires PHP 8.0+; use strpos() for 7.4 compat. + foreach ( self::PROTECTED_PREFIXES as $prefix ) { + if ( 0 === strpos( $lower, $prefix ) ) { + return true; + } + } + + return false; } /** diff --git a/includes/Mcp/Tools/Themes/ActivateTheme.php b/includes/Mcp/Tools/Themes/ActivateTheme.php index 31f45db..3ea4dd6 100644 --- a/includes/Mcp/Tools/Themes/ActivateTheme.php +++ b/includes/Mcp/Tools/Themes/ActivateTheme.php @@ -30,10 +30,26 @@ class ActivateTheme extends BaseTool { /** * Permission callback for wp_register_ability. * + * The abilities API passes input to this callback when an input_schema is defined, + * so we can check whether the requested theme is already installed and require + * install_themes only when an installation will actually be attempted. + * + * @param array $input Input parameters (theme_slug). * @return bool Whether the current user has the required capability. */ - public function check_permissions(): bool { - return current_user_can( 'switch_themes' ); + public function check_permissions( array $input = array() ): bool { + if ( ! current_user_can( 'switch_themes' ) ) { + return false; + } + + $slug = isset( $input['theme_slug'] ) ? sanitize_text_field( $input['theme_slug'] ) : ''; + + // If the theme is not yet installed, the install path requires install_themes too. + if ( ! empty( $slug ) && ! wp_get_theme( $slug )->exists() ) { + return current_user_can( 'install_themes' ); + } + + return true; } /** @@ -160,7 +176,7 @@ public function execute( array $input ): array { add_filter( 'filesystem_method', array( $this, 'filter_filesystem_method' ) ); WP_Filesystem(); - $upgrader = new \Theme_Upgrader( new \Automatic_Upgrader_Skin() ); + $upgrader = new \Theme_Upgrader( new \WP_Ajax_Upgrader_Skin() ); $result = $upgrader->install( $api->download_link ); remove_filter( 'filesystem_method', array( $this, 'filter_filesystem_method' ) ); diff --git a/package-lock.json b/package-lock.json index b13fccd..52e2bf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "airo-wp", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "airo-wp", - "version": "0.2.4", + "version": "0.2.5", "devDependencies": { "@playwright/test": "^1.52.0", "@wordpress/e2e-test-utils-playwright": "^1.50.0", + "@wordpress/env": "^10.0.0", "@wordpress/icons": "^10.0.0", "@wordpress/scripts": "^30.0.0", - "@wp-playground/cli": "3.1.43", + "adm-zip": "^0.5.18", "classnames": "^2.3.2", "countup.js": "^2.9.0", "glob": "^11.0.0", @@ -2558,6 +2559,388 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -3030,6 +3413,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "dev": true, + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -4914,6 +5314,17 @@ "npm": ">=10.2.3" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgr/core": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", @@ -5156,6 +5567,23 @@ "@opentelemetry/semantic-conventions": "^1.34.0" } }, + "node_modules/@simple-git/args-pathspec": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", + "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@simple-git/argv-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", + "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-git/args-pathspec": "^1.0.3" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -5163,6 +5591,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -5484,6 +5925,19 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@tannin/compile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@tannin/compile/-/compile-1.1.0.tgz", @@ -5634,6 +6088,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -5711,6 +6178,13 @@ "@types/node": "*" } }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -5792,6 +6266,16 @@ "@types/node": "*" } }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -5928,6 +6412,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -7009,55 +7503,308 @@ "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.50.0.tgz", "integrity": "sha512-2Mognc1rwe1+tlIfbSeZPqbuAh6vJagELzw9cLasObiVayWhaG+LCIp0sJz2pPG9ppz8L31gGUgna7zrSRu85Q==", "dev": true, - "license": "GPL-2.0-or-later", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/deprecated": "^4.50.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/e2e-test-utils-playwright": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.50.0.tgz", + "integrity": "sha512-P55NxY86M8lNnAbEPmpEswbxBoB7BWVhg67KOLRsGwLMeJ4qHbBngmgBIUt72IAH3H2UWVq7jZ1Xm/J1ENOMoQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "change-case": "^4.1.2", + "get-port": "^5.1.1", + "lighthouse": "^12.2.2", + "mime": "^3.0.0", + "web-vitals": "^4.2.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "@playwright/test": ">=1", + "@types/node": "^20.17.10" + } + }, + "node_modules/@wordpress/element": { + "version": "6.46.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-6.46.0.tgz", + "integrity": "sha512-hjnrqZi0cZVdkmN0xQavKfSQJYAkb9pVSnDPpuX65OLxeD9/EWkIXvFzBb+nH8c4NzKKSqQU96XCTQrH37OCIA==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@types/react": "^18.3.27", + "@types/react-dom": "^18.3.1", + "@wordpress/escape-html": "^3.46.0", + "change-case": "^4.1.2", + "is-plain-object": "^5.0.0", + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/env": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-10.39.0.tgz", + "integrity": "sha512-Hgl2RQAAzXFMqkpegGWT1/KkX88OVikRroPidWkij1WtU8p+AZniTcncWmlWqbdLdfGbPqQS5ZkqDZCzrQjgnA==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@inquirer/prompts": "^7.2.0", + "@wp-playground/cli": "^3.0.0", + "chalk": "^4.0.0", + "copy-dir": "^1.3.0", + "cross-spawn": "^7.0.6", + "docker-compose": "^0.24.3", + "extract-zip": "^1.6.7", + "got": "^11.8.5", + "js-yaml": "^3.13.1", + "ora": "^4.0.2", + "rimraf": "^5.0.10", + "simple-git": "^3.5.0", + "terminal-link": "^2.0.0", + "yargs": "^17.3.0" + }, + "bin": { + "wp-env": "bin/wp-env" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/env/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@wordpress/env/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@wordpress/env/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@wordpress/env/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/@wordpress/env/node_modules/extract-zip": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", + "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "concat-stream": "^1.6.2", + "debug": "^2.6.9", + "mkdirp": "^0.5.4", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + } + }, + "node_modules/@wordpress/env/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@wordpress/env/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/@wordpress/env/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@wordpress/env/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@wordpress/env/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/@wordpress/env/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@wordpress/env/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@wordpress/env/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", "dependencies": { - "@wordpress/deprecated": "^4.50.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@wordpress/e2e-test-utils-playwright": { - "version": "1.50.0", - "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.50.0.tgz", - "integrity": "sha512-P55NxY86M8lNnAbEPmpEswbxBoB7BWVhg67KOLRsGwLMeJ4qHbBngmgBIUt72IAH3H2UWVq7jZ1Xm/J1ENOMoQ==", + "node_modules/@wordpress/env/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, - "license": "GPL-2.0-or-later", + "license": "MIT", "dependencies": { - "change-case": "^4.1.2", - "get-port": "^5.1.1", - "lighthouse": "^12.2.2", - "mime": "^3.0.0", - "web-vitals": "^4.2.1" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=12" }, - "peerDependencies": { - "@playwright/test": ">=1", - "@types/node": "^20.17.10" + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@wordpress/element": { - "version": "6.46.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-6.46.0.tgz", - "integrity": "sha512-hjnrqZi0cZVdkmN0xQavKfSQJYAkb9pVSnDPpuX65OLxeD9/EWkIXvFzBb+nH8c4NzKKSqQU96XCTQrH37OCIA==", + "node_modules/@wordpress/env/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, - "license": "GPL-2.0-or-later", + "license": "MIT", "dependencies": { - "@types/react": "^18.3.27", - "@types/react-dom": "^18.3.1", - "@wordpress/escape-html": "^3.46.0", - "change-case": "^4.1.2", - "is-plain-object": "^5.0.0", - "react": "^18.3.0", - "react-dom": "^18.3.0" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/@wordpress/escape-html": { @@ -9011,6 +9758,51 @@ "qified": "^0.10.1" } }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cacheable/node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -9227,6 +10019,13 @@ "node": ">=10" } }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, "node_modules/check-node-version": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/check-node-version/-/check-node-version-4.2.1.tgz", @@ -9368,6 +10167,42 @@ "node": ">=6" } }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -9383,6 +10218,16 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clone-deep": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", @@ -9413,6 +10258,29 @@ "node": ">=0.10.0" } }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone-response/node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -9592,6 +10460,62 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/configstore": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", @@ -9680,6 +10604,13 @@ "dev": true, "license": "MIT" }, + "node_modules/copy-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/copy-dir/-/copy-dir-1.3.0.tgz", + "integrity": "sha512-Q4+qBFnN4bwGwvtXXzbp4P/4iNk0MaiGAzvQ8OiMtlLjkIKjmNN689uVzShSM0908q7GoFHXIPx4zi75ocoaHw==", + "dev": true, + "license": "MIT" + }, "node_modules/copy-webpack-plugin": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", @@ -10436,6 +11367,29 @@ "node": ">= 10" } }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -10613,6 +11567,35 @@ "node": ">=6" } }, + "node_modules/docker-compose": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.8.tgz", + "integrity": "sha512-plizRs/Vf15H+GCVxq2EUvyPK7ei9b/cVesHvjnX4xaXjM9spHe2Ytq0BitndFgvTJ3E3NljPNUEl7BAN43iZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/docker-compose/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -10761,6 +11744,13 @@ "dev": true, "license": "MIT" }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -13406,6 +14396,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -13727,6 +14743,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -13827,6 +14850,33 @@ } } }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/http2-wrapper/node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -14471,6 +15521,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -16478,6 +17538,16 @@ "tslib": "^2.0.3" } }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -17238,6 +18308,19 @@ "node": ">=0.10.0" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -17283,6 +18366,16 @@ "multicast-dns": "cli.js" } }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nan": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", @@ -17463,6 +18556,19 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npm-bundled": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz", @@ -17915,6 +19021,141 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-4.1.1.tgz", + "integrity": "sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^3.0.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.2.0", + "is-interactive": "^1.0.0", + "log-symbols": "^3.0.0", + "mute-stream": "0.0.8", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, "node_modules/os-homedir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", @@ -17943,6 +19184,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -20127,6 +21378,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-bin": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/resolve-bin/-/resolve-bin-0.4.3.tgz", @@ -20194,6 +21452,40 @@ "node": ">=10" } }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -21061,6 +22353,24 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-git": { + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz", + "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "@simple-git/args-pathspec": "^1.0.3", + "@simple-git/argv-parser": "^1.1.0", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/sirv": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", @@ -21506,6 +22816,29 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -21640,6 +22973,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -22386,6 +23733,37 @@ "streamx": "^2.12.5" } }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link/node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/terser": { "version": "5.48.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", @@ -23075,6 +24453,13 @@ "dev": true, "license": "MIT" }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -23599,6 +24984,16 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/web-vitals": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", @@ -24238,6 +25633,25 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -24448,6 +25862,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", diff --git a/package.json b/package.json index 95b21d5..3f2a56d 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,11 @@ { "name": "airo-wp", - "version": "0.2.4", + "version": "0.2.5", "private": true, "files": [ + "airo-wp.php", + "CHANGELOG.md", + "readme.txt", "data/", "dependencies/", "dist/", @@ -11,22 +14,28 @@ "patterns/", "src/", "vendor/", - "airo-wp.php", - "composer.json", - "readme.txt" + "composer.json" ], "scripts": { "build": "wp-scripts build", + "build:zip": "npm run build && node tests/e2e/setup/build-zip.mjs", "start": "wp-scripts start", - "test": "playwright test", - "test:debug": "playwright test --debug" + "test:unit": "node tests/scripts/wp-env-exec.mjs test", + "lint": "node tests/scripts/wp-env-exec.mjs lint", + "format": "wp-env run tests-cli --env-cwd=wp-content/plugins/airo-wp -- composer format || true", + "test:e2e": "node tests/scripts/e2e.mjs", + "test:e2e:debug": "node tests/scripts/e2e.mjs --debug", + "plugin-check": "node tests/scripts/plugin-check.mjs", + "wp-env:start": "wp-env start", + "wp-env:stop": "wp-env stop" }, "devDependencies": { "@playwright/test": "^1.52.0", + "adm-zip": "^0.5.18", "@wordpress/e2e-test-utils-playwright": "^1.50.0", + "@wordpress/env": "^10.0.0", "@wordpress/icons": "^10.0.0", "@wordpress/scripts": "^30.0.0", - "@wp-playground/cli": "3.1.43", "classnames": "^2.3.2", "countup.js": "^2.9.0", "glob": "^11.0.0", diff --git a/phpcs.xml b/phpcs.xml index f375c41..5b281e6 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -49,6 +49,11 @@ + + + + + diff --git a/readme.txt b/readme.txt index 0664daf..6c1982f 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: airo, godaddy, mcp, ai, block-patterns Requires at least: 6.9 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 0.2.4 +Stable tag: 0.2.5 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -72,7 +72,7 @@ Runtime Composer packages are namespace-prefixed with Strauss into `dependencies == Changelog == = 0.2.5 = -* Renamed plugin to "Airo AI Builder" +* Hardened permission checks on four MCP tools = 0.2.4 = * Bundled block dynamic tags so shipped blocks resolve dynamic content correctly diff --git a/tests/Unit/Mcp/Tools/GlobalStyles/ListGlobalStylesTest.php b/tests/Unit/Mcp/Tools/GlobalStyles/ListGlobalStylesTest.php index dc5d39f..3ad7e97 100644 --- a/tests/Unit/Mcp/Tools/GlobalStyles/ListGlobalStylesTest.php +++ b/tests/Unit/Mcp/Tools/GlobalStyles/ListGlobalStylesTest.php @@ -42,6 +42,7 @@ public function test_returns_styles_list(): void { $post1 = new \stdClass(); $post1->ID = 10; $post1->post_title = 'Theme Styles'; + $post1->post_name = 'wp-global-styles-twentytwentyfive'; $post1->post_status = 'publish'; $post1->post_date = '2024-01-01 00:00:00'; $post1->post_modified = '2024-01-02 00:00:00'; @@ -49,12 +50,12 @@ public function test_returns_styles_list(): void { $post2 = new \stdClass(); $post2->ID = 20; $post2->post_title = 'Custom Styles'; + $post2->post_name = 'wp-global-styles-custom-theme'; $post2->post_status = 'draft'; $post2->post_date = '2024-02-01 00:00:00'; $post2->post_modified = '2024-02-02 00:00:00'; Functions\when( 'get_posts' )->justReturn( array( $post1, $post2 ) ); - Functions\when( 'get_post_meta' )->justReturn( 'twentytwentyfive' ); $result = $this->tool->execute( array() ); @@ -67,27 +68,30 @@ public function test_returns_styles_list(): void { } public function test_filters_by_theme(): void { + // Both posts are returned by get_posts (DB filtering removed); PHP + // applies the theme filter by matching post_name prefix. $post1 = new \stdClass(); $post1->ID = 10; $post1->post_title = 'Theme Styles'; + $post1->post_name = 'wp-global-styles-twentytwentyfive'; $post1->post_status = 'publish'; $post1->post_date = '2024-01-01 00:00:00'; $post1->post_modified = '2024-01-02 00:00:00'; - Functions\expect( 'get_posts' ) - ->once() - ->with( \Mockery::on( function ( $args ) { - return isset( $args['meta_query'] ) - && 'theme' === $args['meta_query'][0]['key'] - && 'twentytwentyfive' === $args['meta_query'][0]['value']; - } ) ) - ->andReturn( array( $post1 ) ); + $post2 = new \stdClass(); + $post2->ID = 20; + $post2->post_title = 'Other Theme Styles'; + $post2->post_name = 'wp-global-styles-anotherTheme'; + $post2->post_status = 'publish'; + $post2->post_date = '2024-02-01 00:00:00'; + $post2->post_modified = '2024-02-02 00:00:00'; - Functions\when( 'get_post_meta' )->justReturn( 'twentytwentyfive' ); + Functions\when( 'get_posts' )->justReturn( array( $post1, $post2 ) ); $result = $this->tool->execute( array( 'theme' => 'twentytwentyfive' ) ); $this->assertTrue( $result['success'] ); $this->assertSame( 1, $result['total'] ); + $this->assertSame( 'twentytwentyfive', $result['styles'][0]['theme'] ); } } diff --git a/tests/Unit/Mcp/Tools/Posts/ListPostsTest.php b/tests/Unit/Mcp/Tools/Posts/ListPostsTest.php index f58b3ef..9450a0f 100644 --- a/tests/Unit/Mcp/Tools/Posts/ListPostsTest.php +++ b/tests/Unit/Mcp/Tools/Posts/ListPostsTest.php @@ -148,4 +148,41 @@ public function test_featured_media_id_filter_with_nonexistent_attachment_return $this->assertSame( 0, $result['total'] ); $this->assertSame( 999, \WP_Query::$last_args['meta_query'][0]['value'] ); } + + public function test_non_public_status_denied_without_edit_others_posts(): void { + $type_obj = new \stdClass(); + $type_obj->cap = new \stdClass(); + $type_obj->cap->edit_others_posts = 'edit_others_posts'; + + Functions\expect( 'get_post_type_object' )->with( 'page' )->andReturn( $type_obj ); + Functions\expect( 'current_user_can' )->with( 'edit_others_posts' )->andReturn( false ); + + $result = $this->tool->execute( array( 'status' => 'private' ) ); + + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'permission', $result['message'] ); + } + + public function test_invalid_custom_post_type_returns_error(): void { + Functions\expect( 'get_post_type_object' )->with( 'nonexistent_type' )->andReturn( null ); + + $result = $this->tool->execute( array( 'post_type' => 'nonexistent_type' ) ); + + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'Invalid post type', $result['message'] ); + } + + public function test_custom_post_type_without_permission_returns_error(): void { + $type_obj = new \stdClass(); + $type_obj->cap = new \stdClass(); + $type_obj->cap->edit_posts = 'edit_products'; + + Functions\expect( 'get_post_type_object' )->with( 'product' )->andReturn( $type_obj ); + Functions\expect( 'current_user_can' )->with( 'edit_products' )->andReturn( false ); + + $result = $this->tool->execute( array( 'post_type' => 'product' ) ); + + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'permission', $result['message'] ); + } } diff --git a/tests/Unit/Mcp/Tools/Posts/UpdatePostImageAltTextTest.php b/tests/Unit/Mcp/Tools/Posts/UpdatePostImageAltTextTest.php index a4738a6..9304bc6 100644 --- a/tests/Unit/Mcp/Tools/Posts/UpdatePostImageAltTextTest.php +++ b/tests/Unit/Mcp/Tools/Posts/UpdatePostImageAltTextTest.php @@ -74,6 +74,23 @@ public function test_nonexistent_post_returns_error(): void { $this->assertStringContainsString( '999', $result['message'] ); } + public function test_user_without_edit_post_permission_returns_error(): void { + $post = new \stdClass(); + $post->post_content = ''; + + Functions\expect( 'get_post' )->once()->with( 1 )->andReturn( $post ); + Functions\expect( 'current_user_can' )->with( 'edit_post', 1 )->andReturn( false ); + + $result = $this->tool->execute( array( + 'post_id' => 1, + 'image_src' => 'https://example.com/img.jpg', + 'alt' => 'New alt text', + ) ); + + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'permission', $result['message'] ); + } + public function test_image_not_found_in_post_returns_error(): void { $post = new \stdClass(); $post->post_content = '

Hello

'; @@ -82,6 +99,7 @@ public function test_image_not_found_in_post_returns_error(): void { ->once() ->with( 1 ) ->andReturn( $post ); + Functions\when( 'current_user_can' )->justReturn( true ); Functions\expect( 'parse_blocks' ) ->once() @@ -113,6 +131,7 @@ public function test_successful_update_via_block_attrs_url(): void { ->once() ->with( 1 ) ->andReturn( $post ); + Functions\when( 'current_user_can' )->justReturn( true ); Functions\expect( 'parse_blocks' ) ->once() @@ -158,6 +177,7 @@ public function test_wp_update_post_error_returns_error(): void { ->once() ->with( 1 ) ->andReturn( $post ); + Functions\when( 'current_user_can' )->justReturn( true ); Functions\expect( 'parse_blocks' ) ->once() diff --git a/tests/Unit/Mcp/Tools/Site/UpdateSiteOptionsTest.php b/tests/Unit/Mcp/Tools/Site/UpdateSiteOptionsTest.php index ad995b8..b4b75df 100644 --- a/tests/Unit/Mcp/Tools/Site/UpdateSiteOptionsTest.php +++ b/tests/Unit/Mcp/Tools/Site/UpdateSiteOptionsTest.php @@ -215,4 +215,47 @@ public function test_protected_option_case_insensitive(): void { $this->assertFalse( $result['success'] ); $this->assertStringContainsString( 'protected', $result['message'] ); } + + /** + * AI service API keys are protected. + * + * @dataProvider provider_ai_api_keys + */ + public function test_ai_api_keys_are_protected( string $key ): void { + Functions\expect( 'current_user_can' )->with( 'manage_options' )->andReturn( true ); + + $result = $this->tool->execute( array( + 'option_name' => $key, + 'option_value' => 'sk-leaked', + ) ); + + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'protected', $result['message'] ); + } + + /** + * @return array + */ + public static function provider_ai_api_keys(): array { + return array( + 'openai' => array( 'openai_api_key' ), + 'anthropic' => array( 'anthropic_api_key' ), + 'openai_uc' => array( 'OPENAI_API_KEY' ), + ); + } + + /** + * Options with the gd_mwcs_ prefix are protected. + */ + public function test_gd_mwcs_prefix_is_protected(): void { + Functions\expect( 'current_user_can' )->with( 'manage_options' )->andReturn( true ); + + $result = $this->tool->execute( array( + 'option_name' => 'gd_mwcs_api_token', + 'option_value' => 'leaked', + ) ); + + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'protected', $result['message'] ); + } } diff --git a/tests/Unit/Mcp/Tools/Themes/ActivateThemeTest.php b/tests/Unit/Mcp/Tools/Themes/ActivateThemeTest.php index 4bb23be..18bb028 100644 --- a/tests/Unit/Mcp/Tools/Themes/ActivateThemeTest.php +++ b/tests/Unit/Mcp/Tools/Themes/ActivateThemeTest.php @@ -97,6 +97,57 @@ public function test_check_permissions_returns_false_without_switch_themes(): vo $this->assertFalse( $this->tool->check_permissions() ); } + /** + * check_permissions returns true for an already-installed theme with only switch_themes. + */ + public function test_check_permissions_true_for_installed_theme_with_switch_themes_only(): void { + $theme = \Mockery::mock( 'WP_Theme' ); + $theme->shouldReceive( 'exists' )->andReturn( true ); + + Functions\expect( 'current_user_can' )->with( 'switch_themes' )->andReturn( true ); + Functions\expect( 'wp_get_theme' )->with( 'twentytwentyfour' )->andReturn( $theme ); + + $this->assertTrue( $this->tool->check_permissions( array( 'theme_slug' => 'twentytwentyfour' ) ) ); + } + + /** + * check_permissions returns false for uninstalled theme when install_themes is missing. + */ + public function test_check_permissions_false_for_uninstalled_theme_without_install_themes(): void { + $theme = \Mockery::mock( 'WP_Theme' ); + $theme->shouldReceive( 'exists' )->andReturn( false ); + + // andReturnUsing disambiguates two calls to current_user_can with different args. + Functions\expect( 'current_user_can' )->andReturnUsing( + fn( string $cap ) => 'switch_themes' === $cap + ); + Functions\expect( 'wp_get_theme' )->with( 'new-theme' )->andReturn( $theme ); + + $this->assertFalse( $this->tool->check_permissions( array( 'theme_slug' => 'new-theme' ) ) ); + } + + /** + * check_permissions returns true for uninstalled theme when both caps are present. + */ + public function test_check_permissions_true_for_uninstalled_theme_with_install_themes(): void { + $theme = \Mockery::mock( 'WP_Theme' ); + $theme->shouldReceive( 'exists' )->andReturn( false ); + + Functions\when( 'current_user_can' )->justReturn( true ); + Functions\expect( 'wp_get_theme' )->with( 'new-theme' )->andReturn( $theme ); + + $this->assertTrue( $this->tool->check_permissions( array( 'theme_slug' => 'new-theme' ) ) ); + } + + /** + * check_permissions returns true with no slug — only switch_themes needed. + */ + public function test_check_permissions_true_with_no_slug(): void { + Functions\expect( 'current_user_can' )->with( 'switch_themes' )->andReturn( true ); + + $this->assertTrue( $this->tool->check_permissions( array() ) ); + } + /** * Successful activation of existing theme. */ diff --git a/tests/e2e/check-plugin/.plugin-check-excludes b/tests/e2e/check-plugin/.plugin-check-excludes new file mode 100644 index 0000000..aae0dfe --- /dev/null +++ b/tests/e2e/check-plugin/.plugin-check-excludes @@ -0,0 +1,9 @@ +.claude +.dev +.git +.github +bin +builds +docs +scripts +tests diff --git a/tests/e2e/check-plugin/pcp-ai-provider-setup.php b/tests/e2e/check-plugin/pcp-ai-provider-setup.php new file mode 100644 index 0000000..74430f3 --- /dev/null +++ b/tests/e2e/check-plugin/pcp-ai-provider-setup.php @@ -0,0 +1,276 @@ + + * on the wp plugin check command to target a specific model. + * + * @package AiroWp + */ + +if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { + return; +} + +$_pcp_ai_key = (string) ( getenv( 'PLUGIN_CHECK_AI_KEY' ) ?: '' ); +if ( '' === $_pcp_ai_key ) { + return; +} + +$GLOBALS['_pcp_ai_base_url'] = rtrim( (string) ( getenv( 'PLUGIN_CHECK_AI_URL' ) ?: 'https://api.openai.com/v1' ), '/' ); +$GLOBALS['_pcp_ai_model_id'] = (string) ( getenv( 'PLUGIN_CHECK_AI_MODEL' ) ?: 'gpt-4o' ); + +WP_CLI::add_hook( + 'after_wp_load', + static function () use ( $_pcp_ai_key ) { + + if ( ! class_exists( 'WordPress\AiClient\AiClient' ) ) { + WP_CLI::warning( 'PLUGIN_CHECK_AI: WordPress AI Client not available (requires WP 7.0+). AI analysis skipped.' ); + return; + } + + // Availability: key-presence check without making a real API call. + if ( ! class_exists( 'PcpAiProviderAvailability' ) ) { + class PcpAiProviderAvailability implements \WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface { + public function isConfigured() : bool { + return '' !== (string) ( getenv( 'PLUGIN_CHECK_AI_KEY' ) ?: '' ); + } + } + } + + // Static model metadata directory — one model, no API listing call. + if ( ! class_exists( 'PcpAiStaticModelDirectory' ) ) { + class PcpAiStaticModelDirectory implements \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface { + /** @var string */ + private $modelId; + + public function __construct( string $modelId ) { + $this->modelId = $modelId; + } + + private function buildMeta() : \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + return new \WordPress\AiClient\Providers\Models\DTO\ModelMetadata( + $this->modelId, + $this->modelId, + [ \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::textGeneration() ], + [] + ); + } + + public function listModelMetadata() : array { + return [ $this->buildMeta() ]; + } + + public function hasModelMetadata( string $modelId ) : bool { + return $modelId === $this->modelId; + } + + public function getModelMetadata( string $modelId ) : \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + if ( $modelId !== $this->modelId ) { + throw new \WordPress\AiClient\Common\Exception\InvalidArgumentException( + "Model {$modelId} not registered in Plugin Check AI provider" + ); + } + return $this->buildMeta(); + } + } + } + + // Concrete text-generation model using wp_remote_post() for the HTTP call. + // Avoids extending AbstractOpenAiCompatibleTextGenerationModel which is not + // included in the WP.org release package (only in the develop repo). + if ( ! class_exists( 'PcpAiTextModel' ) ) { + // Implements ModelInterface only; TextGenerationModelInterface is absent from the WP.org + // release package. generateTextResult() is still callable via duck typing / capability dispatch. + class PcpAiTextModel implements \WordPress\AiClient\Providers\Models\Contracts\ModelInterface { + + /** @var \WordPress\AiClient\Providers\Models\DTO\ModelMetadata */ + private $modelMetadata; + + /** @var \WordPress\AiClient\Providers\DTO\ProviderMetadata */ + private $providerMetadata; + + /** @var \WordPress\AiClient\Providers\Models\DTO\ModelConfig|null */ + private $config = null; + + public function __construct( + \WordPress\AiClient\Providers\Models\DTO\ModelMetadata $modelMetadata, + \WordPress\AiClient\Providers\DTO\ProviderMetadata $providerMetadata + ) { + $this->modelMetadata = $modelMetadata; + $this->providerMetadata = $providerMetadata; + } + + public function metadata() : \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + return $this->modelMetadata; + } + + public function providerMetadata() : \WordPress\AiClient\Providers\DTO\ProviderMetadata { + return $this->providerMetadata; + } + + public function setConfig( \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config ) : void { + $this->config = $config; + } + + public function getConfig() : \WordPress\AiClient\Providers\Models\DTO\ModelConfig { + if ( null === $this->config ) { + $this->config = \WordPress\AiClient\Providers\Models\DTO\ModelConfig::fromArray( [] ); + } + return $this->config; + } + + public function generateTextResult( array $prompt ) : \WordPress\AiClient\Results\DTO\GenerativeAiResult { + $base_url = rtrim( (string) ( $GLOBALS['_pcp_ai_base_url'] ?? 'https://api.openai.com/v1' ), '/' ); + $api_key = (string) ( getenv( 'PLUGIN_CHECK_AI_KEY' ) ?: '' ); + + $messages = []; + foreach ( $prompt as $message ) { + $role = $message->getRole()->isModel() ? 'assistant' : 'user'; + $text = ''; + foreach ( $message->getParts() as $part ) { + if ( $part->getType()->isText() && ! $part->getChannel()->isThought() ) { + $text .= $part->getText(); + } + } + if ( '' !== $text ) { + $messages[] = [ + 'role' => $role, + 'content' => $text, + ]; + } + } + + $response = wp_remote_post( + $base_url . '/chat/completions', + [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $api_key, + ], + 'body' => wp_json_encode( [ + 'model' => $this->modelMetadata->getId(), + 'messages' => $messages, + ] ), + 'timeout' => 60, + ] + ); + + if ( is_wp_error( $response ) ) { + throw new \WordPress\AiClient\Common\Exception\RuntimeException( + 'PLUGIN_CHECK_AI: HTTP request failed — ' . $response->get_error_message() + ); + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + if ( $status < 200 || $status >= 300 ) { + throw new \WordPress\AiClient\Common\Exception\RuntimeException( + 'PLUGIN_CHECK_AI: API returned HTTP ' . $status + ); + } + + $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); + if ( ! is_array( $body ) ) { + throw new \WordPress\AiClient\Common\Exception\RuntimeException( + 'PLUGIN_CHECK_AI: Failed to parse API response' + ); + } + + $candidates = []; + foreach ( ( $body['choices'] ?? [] ) as $choice ) { + $message_text = (string) ( $choice['message']['content'] ?? '' ); + $ai_message = new \WordPress\AiClient\Messages\DTO\Message( + \WordPress\AiClient\Messages\Enums\MessageRoleEnum::model(), + [ new \WordPress\AiClient\Messages\DTO\MessagePart( $message_text ) ] + ); + + switch ( $choice['finish_reason'] ?? 'stop' ) { + case 'length': + $finish_reason = \WordPress\AiClient\Results\Enums\FinishReasonEnum::length(); + break; + case 'content_filter': + $finish_reason = \WordPress\AiClient\Results\Enums\FinishReasonEnum::contentFilter(); + break; + default: + $finish_reason = \WordPress\AiClient\Results\Enums\FinishReasonEnum::stop(); + } + + $candidates[] = new \WordPress\AiClient\Results\DTO\Candidate( $ai_message, $finish_reason ); + } + + $usage = $body['usage'] ?? []; + $token_usage = new \WordPress\AiClient\Results\DTO\TokenUsage( + (int) ( $usage['prompt_tokens'] ?? 0 ), + (int) ( $usage['completion_tokens'] ?? 0 ), + (int) ( $usage['total_tokens'] ?? 0 ) + ); + + $additional_data = $body; + unset( $additional_data['id'], $additional_data['choices'], $additional_data['usage'] ); + + return new \WordPress\AiClient\Results\DTO\GenerativeAiResult( + (string) ( $body['id'] ?? '' ), + $candidates, + $token_usage, + $this->providerMetadata, + $this->modelMetadata, + $additional_data + ); + } + } + } + + // Provider: wraps the model, directory, and availability. + if ( ! class_exists( 'PcpAiProvider' ) ) { + class PcpAiProvider extends \WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider { + protected static function baseUrl() : string { + return (string) ( $GLOBALS['_pcp_ai_base_url'] ?? 'https://api.openai.com/v1' ); + } + + protected static function createProviderMetadata() : \WordPress\AiClient\Providers\DTO\ProviderMetadata { + return new \WordPress\AiClient\Providers\DTO\ProviderMetadata( + 'plugin-check-ai', + 'Plugin Check AI', + \WordPress\AiClient\Providers\Enums\ProviderTypeEnum::cloud(), + null, + \WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod::apiKey() + ); + } + + protected static function createProviderAvailability() : \WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface { + return new PcpAiProviderAvailability(); + } + + protected static function createModelMetadataDirectory() : \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface { + return new PcpAiStaticModelDirectory( + (string) ( $GLOBALS['_pcp_ai_model_id'] ?? 'gpt-4o' ) + ); + } + + protected static function createModel( + \WordPress\AiClient\Providers\Models\DTO\ModelMetadata $modelMetadata, + \WordPress\AiClient\Providers\DTO\ProviderMetadata $providerMetadata + ) : \WordPress\AiClient\Providers\Models\Contracts\ModelInterface { + return new PcpAiTextModel( $modelMetadata, $providerMetadata ); + } + } + } + + try { + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + $registry->registerProvider( PcpAiProvider::class ); + $registry->setProviderRequestAuthentication( + PcpAiProvider::class, + new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $_pcp_ai_key ) + ); + } catch ( \Exception $e ) { + WP_CLI::warning( 'PLUGIN_CHECK_AI: provider registration failed — ' . $e->getMessage() ); + } + } +); diff --git a/tests/e2e/check-plugin/run-plugin-check.mjs b/tests/e2e/check-plugin/run-plugin-check.mjs index 75b2db0..f359449 100644 --- a/tests/e2e/check-plugin/run-plugin-check.mjs +++ b/tests/e2e/check-plugin/run-plugin-check.mjs @@ -5,6 +5,13 @@ * flow, which silently skips the 5 runtime checks due to unauthenticated table * set swap). * + * Two lanes (selected by WP_ENV=1): + * Private (default): provision-pcp-wp.sh sets up ephemeral WordPress; native + * wp-cli runs against the provisioned WP_DIR. Used by make plugin-check. + * Public (WP_ENV=1): plugin-check.sh extracts the built zip to a timestamped + * directory and starts wp-env pointing at it; all WP-CLI calls go through + * `wp-env run cli --`. Used by npm run plugin-check. + * * Two runs: full default check set + an explicit runtime-checks canary. * ERROR-type findings gate; WARNING-type are reported only. Structural failures * (missing runs, early_init=no, fatals, wp-cli Error: lines, unparseable lines) @@ -13,16 +20,34 @@ */ import { spawnSync } from 'node:child_process'; -import { existsSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname( fileURLToPath( import.meta.url ) ); const ROOT = path.resolve( HERE, '../../..' ); +// WP_ENV_DIR: directory containing the .wp-env.json for the plugin-check instance. +// Set by plugin-check.sh (WP_ENV=1 lane) so wp-env targets the zip-installed container. +const WP_ENV_CWD = ( process.env.WP_ENV && process.env.WP_ENV_DIR ) + ? path.resolve( process.env.WP_ENV_DIR ) + : ROOT; const PLUGIN_SLUG = 'airo-wp'; + +// ── AI analysis opt-in ──────────────────────────────────────────────────────── +// Set PLUGIN_CHECK_AI=1 to enable Plugin Check's --ai flag. +// PLUGIN_CHECK_AI_KEY API key forwarded to pcp-ai-provider-setup.php (required when AI=1). +// PLUGIN_CHECK_AI_MODEL Model name only, e.g. "gpt-4o" (default: gpt-4o). +// The provider ID "plugin-check-ai" is prepended automatically. +// PLUGIN_CHECK_AI_URL Base API URL, e.g. https://api.openai.com/v1 (default: OpenAI). +const USE_AI = process.env.PLUGIN_CHECK_AI === '1'; +const AI_MODEL = ( process.env.PLUGIN_CHECK_AI_MODEL || '' ).trim(); +const AI_KEY = ( process.env.PLUGIN_CHECK_AI_KEY || '' ).trim(); const ZIP_PATH = path.join( ROOT, 'builds', 'airo-wp-test.zip' ); const RESULTS_FILE = path.join( ROOT, 'builds', 'plugin-check-results.txt' ); -const MARKER_REQUIRE = path.join( HERE, 'pcp-early-init-marker.php' ); + +// Public lane (WP_ENV=1): wp-env container-absolute paths. +const CONTAINER_DROPIN_SRC = '/var/www/html/wp-content/plugins/plugin-check/drop-ins/object-cache.copy.php'; +const CONTAINER_DROPIN_DST = '/var/www/html/wp-content/object-cache.php'; /** * Findings with these codes are displayed in the report but do NOT gate the @@ -50,88 +75,191 @@ function fail( message ) { console.error( `✗ ${ message }` ); } +/** + * Build the --exclude-directories value for `wp plugin check`. + * + * Both lanes: the plugin directory contains exactly the zip-packaged files, so + * only `dependencies` (Strauss-prefixed vendor code) needs excluding. + */ +function excludeDirectories( forBindMount ) { + const always = [ 'dependencies' ]; + if ( ! forBindMount ) { + return always.join( ',' ); + } + const excludesFile = path.join( HERE, '.plugin-check-excludes' ); + if ( ! existsSync( excludesFile ) ) { + throw new Error( + `${ excludesFile } not found — run via scripts/tests/plugin-check.sh (or npm run plugin-check), which generates it.` + ); + } + const dirs = readFileSync( excludesFile, 'utf8' ) + .split( '\n' ) + .map( ( l ) => l.trim() ) + .filter( Boolean ); + return [ ...new Set( [ ...always, ...dirs ] ) ].join( ',' ); +} + +// ── Lane selection ──────────────────────────────────────────────────────────── + +const USE_WP_ENV = !! process.env.WP_ENV; + if ( ! existsSync( ZIP_PATH ) ) { - console.error( - `✗ Missing ${ path.relative( ROOT, ZIP_PATH ) } — run via scripts/tests/plugin-check.sh (or make check-plugin), which builds it.` - ); + const hint = USE_WP_ENV + ? 'run npm run build:zip first' + : 'run via scripts/tests/plugin-check.sh (or make plugin-check), which builds it'; + console.error( `✗ Missing ${ path.relative( ROOT, ZIP_PATH ) } — ${ hint }.` ); process.exit( 1 ); } rmSync( RESULTS_FILE, { force: true } ); -console.log( 'Provisioning ephemeral WordPress (native PHP + SQLite drop-in)…' ); -const prov = spawnSync( 'sh', [ path.join( HERE, 'provision-pcp-wp.sh' ) ], { - cwd: ROOT, - encoding: 'utf8', - timeout: 5 * 60_000, - maxBuffer: 64 * 1024 * 1024, -} ); -const provLog = `${ prov.stdout ?? '' }${ prov.stderr ?? '' }`; -if ( prov.error || prov.status !== 0 ) { - console.error( provLog ); - console.error( - `✗ Provisioning failed${ prov.error ? `: ${ prov.error.message }` : ` (exit ${ prov.status })` }` - ); - process.exit( 1 ); -} -const WP_DIR = ( prov.stdout ?? '' ).match( /^WP_DIR=(.+)$/m )?.[ 1 ]; -if ( ! WP_DIR ) { - console.error( provLog ); - console.error( '✗ provision-pcp-wp.sh did not report WP_DIR.' ); - process.exit( 1 ); -} +let markerRequire; +let aiProviderRequire; +let dropinSrc; +let dropinDst; +let runCheck; -const DROPIN_SRC = path.join( - WP_DIR, - 'wp-content/plugins/plugin-check/drop-ins/object-cache.copy.php' -); -const DROPIN_DST = path.join( WP_DIR, 'wp-content/object-cache.php' ); - -function runCheck( extraArgs ) { - // PCP's per-run cleanup deletes the drop-in — re-place before each run. - const cp = spawnSync( 'cp', [ DROPIN_SRC, DROPIN_DST ], { encoding: 'utf8' } ); - if ( cp.status !== 0 ) { - fail( `Could not place PCP's object-cache drop-in: ${ cp.stderr }` ); - return null; +if ( USE_WP_ENV ) { + // ── Public lane: wp-env running with extracted zip directory ── + // plugin-check.sh extracts airo-wp-test.zip to builds/tmp/airo-wp-{ts}/airo-wp and + // points wp-env at that directory. The marker is not in the zip; plugin-check.sh + // copies it to /tmp. + markerRequire = '/tmp/pcp-early-init-marker.php'; + aiProviderRequire = '/tmp/pcp-ai-provider-setup.php'; + dropinSrc = CONTAINER_DROPIN_SRC; + dropinDst = CONTAINER_DROPIN_DST; + + console.log( 'Verifying plugin is active via wp-env…' ); + const statusRun = spawnSync( + 'npx', [ 'wp-env', 'run', 'cli', '--', 'wp', 'plugin', 'get', PLUGIN_SLUG, '--field=status', '--allow-root' ], + { cwd: WP_ENV_CWD, encoding: 'utf8', timeout: 2 * 60_000 } + ); + if ( ( statusRun.stdout ?? '' ).trim() !== 'active' ) { + console.error( `✗ Plugin "${ PLUGIN_SLUG }" is not active. Ensure npm run wp-env:start has been run.` ); + process.exit( 1 ); } - const args = [ - 'plugin', 'check', PLUGIN_SLUG, - '--format=json', - `--require=${ MARKER_REQUIRE }`, - `--path=${ WP_DIR }`, - '--allow-root', - '--no-color', - // Strauss-prefixed vendor deps have their own text domains / WP compat declarations. - '--exclude-directories=dependencies', - ...extraArgs, - ]; - const cmd = `wp ${ args.join( ' ' ) }`; - console.log( `Running: ${ cmd }` ); - const run = spawnSync( 'wp', args, { + runCheck = function( extraArgs ) { + // PCP's per-run cleanup deletes the drop-in — re-place before each run. + const cp = spawnSync( 'npx', [ 'wp-env', 'run', 'cli', '--', 'cp', dropinSrc, dropinDst ], { + cwd: WP_ENV_CWD, encoding: 'utf8', + } ); + if ( cp.status !== 0 ) { + fail( `Could not place PCP's object-cache drop-in: ${ cp.stderr }` ); + return null; + } + + const args = [ + 'wp', 'plugin', 'check', PLUGIN_SLUG, + '--format=json', + `--require=${ markerRequire }`, + '--allow-root', + '--no-color', + `--exclude-directories=${ excludeDirectories( false ) }`, + ...extraArgs, + ]; + const cmd = `wp-env run cli -- ${ args.join( ' ' ) }`; + console.log( `Running: ${ cmd }` ); + const run = spawnSync( 'npx', [ 'wp-env', 'run', 'cli', '--', ...args ], { + cwd: WP_ENV_CWD, + encoding: 'utf8', + timeout: 10 * 60_000, + maxBuffer: 64 * 1024 * 1024, + } ); + if ( run.error ) { + fail( `Failed to run wp-cli: ${ run.error.message }` ); + return null; + } + return { cmd, stdout: run.stdout ?? '', stderr: run.stderr ?? '', earlyInit: /pcp_early_init=yes/.test( run.stdout ?? '' ) }; + }; + +} else { + // ── Private lane: provision ephemeral WordPress ─────────────────────────── + markerRequire = path.join( HERE, 'pcp-early-init-marker.php' ); + aiProviderRequire = path.join( HERE, 'pcp-ai-provider-setup.php' ); + + console.log( 'Provisioning ephemeral WordPress (native PHP + SQLite drop-in)…' ); + const prov = spawnSync( 'sh', [ path.join( HERE, 'provision-pcp-wp.sh' ) ], { cwd: ROOT, encoding: 'utf8', - timeout: 10 * 60_000, + timeout: 5 * 60_000, maxBuffer: 64 * 1024 * 1024, } ); - if ( run.error ) { - fail( `Failed to run wp-cli: ${ run.error.message }` ); - return null; + const provLog = `${ prov.stdout ?? '' }${ prov.stderr ?? '' }`; + if ( prov.error || prov.status !== 0 ) { + console.error( provLog ); + console.error( `✗ Provisioning failed${ prov.error ? `: ${ prov.error.message }` : ` (exit ${ prov.status })` }` ); + process.exit( 1 ); + } + const WP_DIR = ( prov.stdout ?? '' ).match( /^WP_DIR=(.+)$/m )?.[ 1 ]; + if ( ! WP_DIR ) { + console.error( provLog ); + console.error( '✗ provision-pcp-wp.sh did not report WP_DIR.' ); + process.exit( 1 ); } - return { - cmd, - stdout: run.stdout ?? '', - stderr: run.stderr ?? '', - earlyInit: /pcp_early_init=yes/.test( run.stdout ?? '' ), + + dropinSrc = path.join( WP_DIR, 'wp-content/plugins/plugin-check/drop-ins/object-cache.copy.php' ); + dropinDst = path.join( WP_DIR, 'wp-content/object-cache.php' ); + + runCheck = function( extraArgs ) { + // PCP's per-run cleanup deletes the drop-in — re-place before each run. + const cp = spawnSync( 'cp', [ dropinSrc, dropinDst ], { encoding: 'utf8' } ); + if ( cp.status !== 0 ) { + fail( `Could not place PCP's object-cache drop-in: ${ cp.stderr }` ); + return null; + } + + const args = [ + 'plugin', 'check', PLUGIN_SLUG, + '--format=json', + `--require=${ markerRequire }`, + `--path=${ WP_DIR }`, + '--allow-root', + '--no-color', + `--exclude-directories=${ excludeDirectories( false ) }`, + ...extraArgs, + ]; + const cmd = `wp ${ args.join( ' ' ) }`; + console.log( `Running: ${ cmd }` ); + const run = spawnSync( 'wp', args, { + cwd: ROOT, + encoding: 'utf8', + timeout: 10 * 60_000, + maxBuffer: 64 * 1024 * 1024, + } ); + if ( run.error ) { + fail( `Failed to run wp-cli: ${ run.error.message }` ); + return null; + } + return { cmd, stdout: run.stdout ?? '', stderr: run.stderr ?? '', earlyInit: /pcp_early_init=yes/.test( run.stdout ?? '' ) }; }; } +// ── Shared: run both checks ─────────────────────────────────────────────────── + +// ── AI args (empty unless PLUGIN_CHECK_AI=1) ────────────────────────────────── +if ( USE_AI && ! AI_KEY ) { + console.warn( '⚠ PLUGIN_CHECK_AI=1 but PLUGIN_CHECK_AI_KEY is not set — AI analysis will be skipped.' ); +} +const AI_ARGS = ( () => { + if ( ! USE_AI ) return []; + const args = [ '--ai' ]; + if ( AI_MODEL ) args.push( `--ai-model=plugin-check-ai::${ AI_MODEL }` ); + args.push( `--require=${ aiProviderRequire }` ); + return args; +} )(); + +if ( USE_AI ) { + console.log( `AI analysis enabled (model: ${ AI_MODEL || 'default' }, provider setup: ${ aiProviderRequire })` ); +} else { + console.log( 'AI analysis disabled (set PLUGIN_CHECK_AI=1 to enable)' ); +} + const runs = [ - runCheck( [] ), - runCheck( [ `--checks=${ RUNTIME_CHECKS.join( ',' ) }` ] ), + runCheck( AI_ARGS ), + runCheck( [ ...AI_ARGS, `--checks=${ RUNTIME_CHECKS.join( ',' ) }` ] ), ].filter( Boolean ); - if ( runs.length !== 2 ) { fail( `Expected 2 Plugin Check runs (full set + runtime canary), completed ${ runs.length }.` ); } @@ -158,6 +286,9 @@ for ( const run of runs ) { if ( /^Error:/.test( line.trim() ) ) { fail( `wp-cli error during "${ run.cmd }": ${ line.slice( 0, 300 ) }` ); } + if ( USE_AI && /PLUGIN_CHECK_AI/.test( line ) ) { + console.log( `[AI] ${ line.trim() }` ); + } } } diff --git a/tests/e2e/functional/environment/serve-wp.sh b/tests/e2e/functional/environment/serve-wp.sh index afee925..d7d4184 100755 --- a/tests/e2e/functional/environment/serve-wp.sh +++ b/tests/e2e/functional/environment/serve-wp.sh @@ -25,13 +25,28 @@ provision_wp mkdir -p "$WP_DIR/wp-content/mu-plugins" cat > "$WP_DIR/wp-content/mu-plugins/airo-wp-e2e-pin-canonical.php" < '$WP_SITE_URL', PHP_INT_MAX ); add_filter( 'pre_option_siteurl', static fn() => '$WP_SITE_URL', PHP_INT_MAX ); add_filter( 'option_home', static fn() => '$WP_SITE_URL', PHP_INT_MAX ); add_filter( 'option_siteurl', static fn() => '$WP_SITE_URL', PHP_INT_MAX ); PHP +# Disable WordPress auto-updates and pseudo-cron so Plugin_Upgrader never creates +# .maintenance mid-run (which causes 503 responses in concurrent PHP workers). +cat > "$WP_DIR/wp-content/mu-plugins/airo-wp-e2e-disable-updates.php" <<'PHP' + { test.describe( 'Custom block rendering', () => { - test( 'dynamic airo-wp blocks render without errors via block-renderer API', async ( { requestUtils } ) => { - const blockTypes = await requestUtils.rest( { - method: 'GET', - path: '/wp/v2/block-types?namespace=airo-wp&per_page=100', - } ); - - const dynamicBlocks = blockTypes.filter( ( b ) => b.is_dynamic ); - - expect( - dynamicBlocks.length, - 'expected at least one dynamic airo-wp block' - ).toBeGreaterThan( 0 ); - - for ( const block of dynamicBlocks ) { - const body = await requestUtils.rest< { rendered: string } >( { - method: 'POST', - path: `/wp/v2/block-renderer/${ block.name }`, - data: { context: 'edit', attributes: {} }, - } ); - - expect( - typeof body.rendered, - `block ${ block.name } renderer response missing 'rendered' field` - ).toBe( 'string' ); - } - } ); - test( 'static airo-wp block markup renders correctly in a page', async ( { requestUtils } ) => { const contentPatterns = await fetchPatternsByCategory( requestUtils, 'airo-wp-content' ); diff --git a/tests/e2e/functional/specs/mcp-server.spec.ts b/tests/e2e/functional/specs/mcp-server.spec.ts index 18e05e6..b336d55 100644 --- a/tests/e2e/functional/specs/mcp-server.spec.ts +++ b/tests/e2e/functional/specs/mcp-server.spec.ts @@ -3,9 +3,9 @@ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; const MCP_ENDPOINT = '/wp-json/airo-wp/v1/mcp/streamable'; test.describe( 'MCP server › auth enforcement', () => { - test( 'rejects requests with invalid credentials with 401', async ( { requestUtils } ) => { + test( 'rejects requests with invalid credentials with 401', async ( { playwright } ) => { // Create an unauthenticated context with bad Basic auth credentials. - const invalidCtx = await ( requestUtils.request as any )._playwright.request.newContext( { + const invalidCtx = await playwright.request.newContext( { baseURL: process.env.WP_BASE_URL, extraHTTPHeaders: { Authorization: 'Basic invalid-credentials' }, } ); diff --git a/tests/e2e/functional/specs/rest/draft-pages.spec.ts b/tests/e2e/functional/specs/rest/draft-pages.spec.ts index 15b6799..a4fb102 100644 --- a/tests/e2e/functional/specs/rest/draft-pages.spec.ts +++ b/tests/e2e/functional/specs/rest/draft-pages.spec.ts @@ -7,16 +7,17 @@ const REST_BASE = '/wp-json/airo-wp/v1/drafts'; * Helper: call a tool via the MCP JSON-RPC endpoint. */ async function callTool( - request: import('@playwright/test').APIRequestContext, + requestUtils: import('@wordpress/e2e-test-utils-playwright').RequestUtils, sessionId: string, toolName: string, args: Record, id = 2 ) { - return request.post( `${ process.env.WP_BASE_URL }${ MCP_ENDPOINT }`, { + return requestUtils.request.post( `${ process.env.WP_BASE_URL }${ MCP_ENDPOINT }`, { headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json', 'Mcp-Session-Id': sessionId, + 'X-WP-Nonce': requestUtils.storageState!.nonce, }, data: { jsonrpc: '2.0', @@ -31,12 +32,12 @@ async function callTool( * Helper: create a published page and a draft via MCP, return { pageId, draftId }. */ async function createPageWithDraft( - request: import('@playwright/test').APIRequestContext, + requestUtils: import('@wordpress/e2e-test-utils-playwright').RequestUtils, sessionId: string, title: string, idBase = 10 ): Promise<{ pageId: number; draftId: number }> { - const pageResp = await callTool( request, sessionId, 'airo-wp-create-post', { + const pageResp = await callTool( requestUtils, sessionId, 'airo-wp-create-post', { title, content: '

Content

', post_type: 'page', @@ -45,7 +46,7 @@ async function createPageWithDraft( const pageBody = await pageResp.json(); const pageId = JSON.parse( pageBody.result.content[ 0 ].text ).post_id as number; - const draftResp = await callTool( request, sessionId, 'airo-wp-create-page-draft', { + const draftResp = await callTool( requestUtils, sessionId, 'airo-wp-create-page-draft', { post_id: pageId, }, idBase + 1 ); const draftBody = await draftResp.json(); @@ -61,7 +62,7 @@ test.describe( 'REST draft-pages endpoints', () => { const initResponse = await requestUtils.request.post( `${ process.env.WP_BASE_URL }${ MCP_ENDPOINT }`, { - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': requestUtils.storageState!.nonce }, data: { jsonrpc: '2.0', id: 1, @@ -90,7 +91,7 @@ test.describe( 'REST draft-pages endpoints', () => { test( 'POST publish — returns 200 and success on happy path', async ( { requestUtils } ) => { const { pageId, draftId } = await createPageWithDraft( - requestUtils.request, sessionId, 'E2E REST Publish Happy Path', 10 + requestUtils, sessionId, 'E2E REST Publish Happy Path', 10 ); const body = await requestUtils.rest< { success: boolean; original_id: number; redirect_url: string } >( { @@ -103,14 +104,14 @@ test.describe( 'REST draft-pages endpoints', () => { expect( typeof body.redirect_url ).toBe( 'string' ); } ); - test( 'POST publish — returns 401 or 403 when unauthenticated', async ( { requestUtils } ) => { + test( 'POST publish — returns 401 or 403 when unauthenticated', async ( { requestUtils, playwright } ) => { const { draftId } = await createPageWithDraft( - requestUtils.request, sessionId, 'E2E REST Publish Unauth', 20 + requestUtils, sessionId, 'E2E REST Publish Unauth', 20 ); // A truly unauthenticated call needs a fresh context with neither the // storageState cookie nor the nonce header. - const anonCtx = await ( requestUtils.request as any )._playwright.request.newContext( { + const anonCtx = await playwright.request.newContext( { storageState: { cookies: [], origins: [] }, } ); const response = await anonCtx.post( @@ -133,7 +134,7 @@ test.describe( 'REST draft-pages endpoints', () => { test( 'POST publish — returns 403 when post has no draft link', async ( { requestUtils } ) => { // A published page has no META_DRAFT_OF — permissions check returns false (403). - const pageResp = await callTool( requestUtils.request, sessionId, 'airo-wp-create-post', { + const pageResp = await callTool( requestUtils, sessionId, 'airo-wp-create-post', { title: 'E2E REST Publish Non-Draft', content: '

Content

', post_type: 'page', @@ -156,7 +157,7 @@ test.describe( 'REST draft-pages endpoints', () => { test( 'DELETE discard — returns 200 and success on happy path', async ( { requestUtils } ) => { const { pageId, draftId } = await createPageWithDraft( - requestUtils.request, sessionId, 'E2E REST Discard Happy Path', 30 + requestUtils, sessionId, 'E2E REST Discard Happy Path', 30 ); // requestUtils.rest() does not support DELETE — use the underlying request context. @@ -173,14 +174,14 @@ test.describe( 'REST draft-pages endpoints', () => { expect( typeof body.redirect_url ).toBe( 'string' ); } ); - test( 'DELETE discard — returns 401 or 403 when unauthenticated', async ( { requestUtils } ) => { + test( 'DELETE discard — returns 401 or 403 when unauthenticated', async ( { requestUtils, playwright } ) => { const { draftId } = await createPageWithDraft( - requestUtils.request, sessionId, 'E2E REST Discard Unauth', 40 + requestUtils, sessionId, 'E2E REST Discard Unauth', 40 ); // A truly unauthenticated call needs a fresh context with neither the // storageState cookie nor the nonce header. - const anonCtx = await ( requestUtils.request as any )._playwright.request.newContext( { + const anonCtx = await playwright.request.newContext( { storageState: { cookies: [], origins: [] }, } ); const response = await anonCtx.delete( @@ -203,7 +204,7 @@ test.describe( 'REST draft-pages endpoints', () => { test( 'DELETE discard — returns 400 when post is not a draft', async ( { requestUtils } ) => { // A published page is not a draft — service returns not_a_draft (400). - const pageResp = await callTool( requestUtils.request, sessionId, 'airo-wp-create-post', { + const pageResp = await callTool( requestUtils, sessionId, 'airo-wp-create-post', { title: 'E2E REST Discard Non-Draft', content: '

Content

', post_type: 'page', diff --git a/tests/e2e/functional/specs/tools/mcp-list-global-styles-tool.spec.ts b/tests/e2e/functional/specs/tools/mcp-list-global-styles-tool.spec.ts index e032c1b..1da9b13 100644 --- a/tests/e2e/functional/specs/tools/mcp-list-global-styles-tool.spec.ts +++ b/tests/e2e/functional/specs/tools/mcp-list-global-styles-tool.spec.ts @@ -102,8 +102,17 @@ test.describe('airo-wp/list-global-styles tool', () => { }); test('filters by theme using active theme stylesheet', async ({ requestUtils }) => { + // Detect the active theme slug so the filter uses a value that actually + // exists in this environment instead of a hardcoded name. + const themes = await requestUtils.rest>({ + method: 'GET', + path: '/wp/v2/themes?status=active', + }); + const activeTheme = themes[0]?.stylesheet; + expect(activeTheme, 'could not determine active theme').toBeTruthy(); + const response = await callTool(requestUtils, sessionId, 'airo-wp-list-global-styles', { - theme: 'twentytwentyfive', + theme: activeTheme, }, 4); expect(response.status(), `tools/call failed: ${await response.text()}`).toBe(200); @@ -118,11 +127,11 @@ test.describe('airo-wp/list-global-styles tool', () => { expect(data.success, `expected success: ${JSON.stringify(data)}`).toBe(true); expect(Array.isArray(data.styles), 'styles should be an array').toBe(true); - expect(data.styles.length, 'should find styles for twentytwentyfive').toBeGreaterThan(0); + expect(data.styles.length, `should find styles for ${activeTheme}`).toBeGreaterThan(0); // Every returned style should belong to the filtered theme. for (const style of data.styles) { - expect(style.theme, `style theme should be twentytwentyfive`).toBe('twentytwentyfive'); + expect(style.theme, `style theme should be ${activeTheme}`).toBe(activeTheme); } }); }); diff --git a/tests/e2e/functional/specs/tools/mcp-list-posts-tool.spec.ts b/tests/e2e/functional/specs/tools/mcp-list-posts-tool.spec.ts index dadd31d..65b2a1d 100644 --- a/tests/e2e/functional/specs/tools/mcp-list-posts-tool.spec.ts +++ b/tests/e2e/functional/specs/tools/mcp-list-posts-tool.spec.ts @@ -164,21 +164,28 @@ test.describe('airo-wp/list-posts tool', () => { }); test('filters posts by featured_media_id and returns only matching posts', async ({ requestUtils }) => { - // Upload an attachment to use as the featured image. - const uploadResponse = await callTool(requestUtils, sessionId, 'airo-wp-upload-media', { - filename: 'featured-filter-test.png', - mime_type: 'image/png', - // 1x1 transparent PNG (base64). - data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', - }, 8); - - expect(uploadResponse.status(), `upload-media failed: ${await uploadResponse.text()}`).toBe(200); + // Upload a 1×1 transparent PNG via the WP REST Media API. + // The MCP server has no base64-upload tool; using the REST API is the + // correct way to create a test attachment from raw bytes. + const imageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ); + const uploadResponse = await requestUtils.request.post( + `${process.env.WP_BASE_URL}/wp-json/wp/v2/media`, + { + headers: { + 'Content-Disposition': 'attachment; filename="featured-filter-test.png"', + 'Content-Type': 'image/png', + 'X-WP-Nonce': requestUtils.storageState!.nonce, + }, + data: imageBuffer, + } + ); - const uploadBody = await uploadResponse.json(); - expect(uploadBody.error, `JSON-RPC error: ${JSON.stringify(uploadBody.error)}`).toBeUndefined(); + expect(uploadResponse.status(), `media upload failed: ${await uploadResponse.text()}`).toBe(201); - const uploadContent: Array<{ type: string; text: string }> = uploadBody.result?.content ?? []; - const uploadData = JSON.parse(uploadContent[0].text); + const uploadData = await uploadResponse.json(); const attachmentId: number = uploadData.id; expect(attachmentId, 'attachment ID should be a positive integer').toBeGreaterThan(0); diff --git a/tests/e2e/setup/build-zip.mjs b/tests/e2e/setup/build-zip.mjs new file mode 100644 index 0000000..0986d41 --- /dev/null +++ b/tests/e2e/setup/build-zip.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Assembles builds/airo-wp.zip and builds/airo-wp-{version}.zip from the repo root. + * Uses wp-scripts plugin-zip (reads package.json "files") — same mechanism as the private lane. + * Called by: npm run build:zip (after npm run build populates dist/). + */ + +import { readFileSync, mkdirSync, readdirSync, renameSync, copyFileSync, rmSync, existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve( path.dirname( fileURLToPath( import.meta.url ) ), '../../..' ); +const OUTPUT_DIR = path.join( ROOT, 'builds' ); + +const { version } = JSON.parse( readFileSync( path.join( ROOT, 'package.json' ), 'utf8' ) ); +const OUTPUT_ZIP = path.join( OUTPUT_DIR, 'airo-wp.zip' ); +const OUTPUT_ZIP_VERSIONED = path.join( OUTPUT_DIR, `airo-wp-${ version }.zip` ); +const TEMP_ZIP = path.join( ROOT, 'airo-wp.zip' ); + +mkdirSync( OUTPUT_DIR, { recursive: true } ); +rmSync( OUTPUT_ZIP, { force: true } ); +rmSync( OUTPUT_ZIP_VERSIONED, { force: true } ); +rmSync( TEMP_ZIP, { force: true } ); + +console.log( `build-zip.mjs: assembling ${ OUTPUT_ZIP }` ); + +// Install production Composer dependencies (generates vendor/ and dependencies/ via Strauss) +for ( const cmd of [ + [ 'composer', [ 'install', '--no-dev', '--no-interaction', '--prefer-dist' ] ], + [ 'composer', [ 'dump-autoload', '--no-dev' ] ], +] ) { + const r = spawnSync( cmd[ 0 ], cmd[ 1 ], { cwd: ROOT, stdio: 'inherit' } ); + if ( r.error || r.status !== 0 ) { + console.error( `build-zip.mjs: ${ cmd[ 0 ] } ${ cmd[ 1 ].join( ' ' ) } failed` ); + process.exit( r.status ?? 1 ); + } +} + +function removeTestDirs( dir ) { + for ( const entry of readdirSync( dir, { withFileTypes: true } ) ) { + if ( ! entry.isDirectory() ) continue; + const entryPath = path.join( dir, entry.name ); + if ( entry.name === 'test' ) { + rmSync( entryPath, { recursive: true, force: true } ); + } else { + removeTestDirs( entryPath ); + } + } +} + +// Strip dev artifacts from Strauss-prefixed dependencies (mirrors build-zip.sh cleanup) +const DEV_DIR_NAMES = new Set( [ '.github', 'tests', 'docs' ] ); +function cleanDevArtifacts( dir ) { + for ( const entry of readdirSync( dir, { withFileTypes: true } ) ) { + const entryPath = path.join( dir, entry.name ); + if ( entry.isDirectory() ) { + if ( DEV_DIR_NAMES.has( entry.name ) ) { + rmSync( entryPath, { recursive: true, force: true } ); + } else { + cleanDevArtifacts( entryPath ); + } + } else if ( entry.isFile() && entry.name.endsWith( '.md' ) ) { + rmSync( entryPath, { force: true } ); + } + } +} +const depsDir = path.join( ROOT, 'dependencies' ); +if ( existsSync( depsDir ) ) { + cleanDevArtifacts( depsDir ); +} +const srcDir = path.join( ROOT, 'src' ); +if ( existsSync( srcDir ) ) { + removeTestDirs( srcDir ); +} + +const result = spawnSync( 'npx', [ 'wp-scripts', 'plugin-zip' ], { + cwd: ROOT, + stdio: 'inherit', +} ); + +if ( result.error ) { + console.error( `build-zip.mjs: ${ result.error.message }` ); + process.exit( 1 ); +} + +if ( result.status !== 0 ) { + process.exit( result.status ?? 1 ); +} + +// Remove package.json from zip (npm-packlist always includes it regardless of files allowlist) +console.log( 'build-zip.mjs: removing package.json from zip...' ); +{ + const { createRequire } = await import( 'node:module' ); + const req = createRequire( import.meta.url ); + const AdmZip = req( 'adm-zip' ); + const zip = new AdmZip( TEMP_ZIP ); + zip.deleteFile( 'airo-wp/package.json' ); + zip.writeZip( TEMP_ZIP ); +} + +renameSync( TEMP_ZIP, OUTPUT_ZIP ); + +console.log( `build-zip.mjs: copying to ${ OUTPUT_ZIP_VERSIONED }` ); +copyFileSync( OUTPUT_ZIP, OUTPUT_ZIP_VERSIONED ); + +process.exit( 0 ); diff --git a/tests/scripts/e2e.mjs b/tests/scripts/e2e.mjs new file mode 100644 index 0000000..adc2a83 --- /dev/null +++ b/tests/scripts/e2e.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * Runs e2e tests against the wp-env tests instance. + * Starts wp-env if not running; stops it only if this script started it. + * + * Called by: npm run test:e2e + */ + +import { createHash } from 'node:crypto'; +import { execSync, spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve( path.dirname( fileURLToPath( import.meta.url ) ), '../..' ); +const extraArgs = process.argv.slice( 2 ); + +// Detect whether wp-env containers are already running. +const hash = createHash( 'md5' ).update( `${ ROOT }/.wp-env.json` ).digest( 'hex' ); +let alreadyRunning = false; +try { + const out = execSync( + `docker ps --filter "name=${ hash }" --format "{{.Names}}"`, + { encoding: 'utf8', stdio: [ 'pipe', 'pipe', 'pipe' ] } + ); + alreadyRunning = out.trim().length > 0; +} catch { + // docker unavailable — wp-env start will surface the error below +} + +if ( ! alreadyRunning ) { + spawnSync( 'npx', [ 'wp-env', 'start' ], { cwd: ROOT, stdio: 'inherit' } ); + + // MySQL startup timing race: wp-env start sometimes skips the WordPress + // installation when MySQL isn't fully initialised. A second start always + // succeeds because MySQL is already up. + const wpCheck = spawnSync( + 'npx', + [ 'wp-env', 'run', 'tests-cli', '--', 'wp', 'core', 'is-installed' ], + { cwd: ROOT, stdio: 'pipe' } + ); + if ( wpCheck.status !== 0 ) { + console.log( 'WordPress not installed after wp-env start (MySQL timing race) — retrying...' ); + spawnSync( 'npx', [ 'wp-env', 'start' ], { cwd: ROOT, stdio: 'inherit' } ); + } +} + +// Resolve the tests port from .wp-env.json. +const wpEnvConfig = JSON.parse( readFileSync( path.join( ROOT, '.wp-env.json' ), 'utf8' ) ); +const port = wpEnvConfig.testsPort || ( wpEnvConfig.port + 1 ); + +// Build compiled block assets if not already built (public mirror commits +// dist/, but local dev does not). +if ( ! existsSync( path.join( ROOT, 'dist/blocks' ) ) && existsSync( path.join( ROOT, 'src/blocks' ) ) ) { + console.log( 'Building block assets...' ); + spawnSync( 'npm', [ 'run', 'build' ], { cwd: ROOT, stdio: 'inherit' } ); +} + +// Remove dx-lite so the install-from-.org test always exercises the install +// path, even when wp-env persists state across runs. +spawnSync( + 'npx', + [ 'wp-env', 'run', 'tests-cli', '--', 'wp', 'theme', 'delete', 'dx-lite', '--force' ], + { cwd: ROOT, stdio: 'pipe' } +); + +// Install Playwright browsers. +spawnSync( 'npx', [ 'playwright', 'install', 'chromium', 'ffmpeg' ], { cwd: ROOT, stdio: 'inherit' } ); + +// Run Playwright. +const result = spawnSync( + 'npx', + [ 'playwright', 'test', '--config', 'tests/e2e/functional/playwright.config.ts', ...extraArgs ], + { + cwd: ROOT, + stdio: 'inherit', + env: { ...process.env, WP_ENV: '1', WP_E2E_PORT: String( port ) }, + } +); + +if ( ! alreadyRunning ) { + spawnSync( 'npx', [ 'wp-env', 'stop' ], { cwd: ROOT, stdio: 'inherit' } ); +} + +process.exit( result.status ?? 1 ); diff --git a/tests/scripts/plugin-check.mjs b/tests/scripts/plugin-check.mjs new file mode 100644 index 0000000..2837440 --- /dev/null +++ b/tests/scripts/plugin-check.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * Plugin Check suite orchestrator — public (wp-env) lane. + * Called by: npm run plugin-check + * + * Expects builds/airo-wp.zip to already exist (npm run build:zip). + * Spins up a dedicated wp-env instance on ports 9175/9192, runs PCP, + * always tears down even on failure. + */ + +import { createHash } from 'node:crypto'; +import { execSync, spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import AdmZip from 'adm-zip'; + +const ROOT = path.resolve( path.dirname( fileURLToPath( import.meta.url ) ), '../..' ); +const ZIP_SRC = path.join( ROOT, 'builds', 'airo-wp.zip' ); +const ZIP_TEST = path.join( ROOT, 'builds', 'airo-wp-test.zip' ); +const EXTRACT_DIR = path.join( ROOT, 'builds', 'tmp', 'airo-wp-pcp' ); +const WP_ENV_DIR = path.join( ROOT, 'builds', 'pcp-wp-env' ); +const WP_ENV_JSON = path.join( WP_ENV_DIR, '.wp-env.json' ); +const WP_ENV_HOME = path.join( ROOT, 'builds', 'pcp-wp-env-home' ); + +if ( ! existsSync( ZIP_SRC ) ) { + console.error( '✗ builds/airo-wp.zip not found — run npm run build:zip first.' ); + process.exit( 1 ); +} + +// run-plugin-check.mjs guards on builds/airo-wp-test.zip (hardcoded name, existence check only). +copyFileSync( ZIP_SRC, ZIP_TEST ); + +// Extract zip to a clean staging directory. +rmSync( EXTRACT_DIR, { recursive: true, force: true } ); +mkdirSync( EXTRACT_DIR, { recursive: true } ); +new AdmZip( ZIP_SRC ).extractAllTo( EXTRACT_DIR, true ); +const pluginDir = path.join( EXTRACT_DIR, 'airo-wp' ); + +// Write isolated wp-env config (separate ports avoid clash with dev instance 9173/9190). +mkdirSync( WP_ENV_DIR, { recursive: true } ); +writeFileSync( WP_ENV_JSON, JSON.stringify( { + phpVersion: '8.3', + port: 9175, + testsPort: 9192, + plugins: [ + pluginDir, + 'https://downloads.wordpress.org/plugin/plugin-check.2.0.0.zip', + ], +}, null, 2 ) ); + +// Start wp-env with retry (MySQL startup race — mirrors private lane behaviour). +for ( let attempt = 1; attempt <= 3; attempt++ ) { + const r = spawnSync( 'npx', [ 'wp-env', 'start' ], { + cwd: WP_ENV_DIR, + stdio: 'inherit', + shell: true, + env: { ...process.env, WP_ENV_HOME }, + } ); + if ( r.status === 0 ) { break; } + if ( attempt >= 3 ) { + console.error( `✗ wp-env start failed after ${ attempt } attempts.` ); + cleanup( false ); + process.exit( 1 ); + } + console.log( `wp-env start failed (attempt ${ attempt }), retrying in 20s…` ); + sleep( 20_000 ); +} + +try { + // Copy PCP early-init marker into the CLI container. + // wp-env stores compose files at ~/.wp-env// + const hash = createHash( 'md5' ).update( WP_ENV_JSON ).digest( 'hex' ); + const composeDir = path.join( WP_ENV_HOME, hash ); + const containerId = execSync( + 'docker compose ps -q cli', + { cwd: composeDir, encoding: 'utf8' } + ).trim().split( '\n' )[ 0 ]; + + const marker = path.join( ROOT, 'tests', 'e2e', 'check-plugin', 'pcp-early-init-marker.php' ); + execSync( `docker cp "${ marker }" "${ containerId }:/tmp/pcp-early-init-marker.php"` ); + + // Run PCP — exit code from run-plugin-check.mjs is the suite result. + const pcp = spawnSync( + 'node', + [ path.join( ROOT, 'tests', 'e2e', 'check-plugin', 'run-plugin-check.mjs' ) ], + { + cwd: ROOT, + stdio: 'inherit', + env: { ...process.env, WP_ENV: '1', WP_ENV_DIR: WP_ENV_DIR }, + } + ); + + cleanup( true ); + process.exit( pcp.status ?? 1 ); +} catch ( error ) { + console.error( `✗ Post-start command failed: ${ error.message }` ); + cleanup( true ); + process.exit( 1 ); +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function cleanup( stopEnv ) { + if ( stopEnv ) { + spawnSync( 'npx', [ 'wp-env', 'stop' ], { + cwd: WP_ENV_DIR, + stdio: 'inherit', + shell: true, + env: { ...process.env, WP_ENV_HOME }, + } ); + } + rmSync( EXTRACT_DIR, { recursive: true, force: true } ); + rmSync( ZIP_TEST, { force: true } ); +} + +function sleep( ms ) { + Atomics.wait( new Int32Array( new SharedArrayBuffer( 4 ) ), 0, 0, ms ); +} diff --git a/tests/scripts/wp-env-exec.mjs b/tests/scripts/wp-env-exec.mjs new file mode 100644 index 0000000..97db96f --- /dev/null +++ b/tests/scripts/wp-env-exec.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * Runs a wp-env tests-cli composer subcommand with smart lifecycle management. + * Starts wp-env if not running; stops it only if this script started it. + * + * Usage: node tests/scripts/wp-env-exec.mjs + * Called by: npm run test:unit, npm run lint + */ + +import { createHash } from 'node:crypto'; +import { execSync, spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve( path.dirname( fileURLToPath( import.meta.url ) ), '../..' ); +const subcommand = process.argv[ 2 ]; + +if ( ! subcommand ) { + console.error( 'Usage: node wp-env-exec.mjs ' ); + process.exit( 1 ); +} + +// Detect whether wp-env containers are already running by checking docker ps +// for any container whose name contains the env hash. +const hash = createHash( 'md5' ).update( `${ ROOT }/.wp-env.json` ).digest( 'hex' ); +let alreadyRunning = false; +try { + const out = execSync( + `docker ps --filter "name=${ hash }" --format "{{.Names}}"`, + { encoding: 'utf8', stdio: [ 'pipe', 'pipe', 'pipe' ] } + ); + alreadyRunning = out.trim().length > 0; +} catch { + // docker unavailable — wp-env start will surface the error below +} + +if ( ! alreadyRunning ) { + spawnSync( 'npx', [ 'wp-env', 'start' ], { cwd: ROOT, stdio: 'inherit' } ); + + // afterStart may fail (e.g. transient composer install error on macOS bind mounts) + // even when the containers came up successfully. Verify containers are actually + // running before proceeding rather than trusting wp-env start's exit code. + let containersUp = false; + try { + const out = execSync( + `docker ps --filter "name=${ hash }" --format "{{.Names}}"`, + { encoding: 'utf8', stdio: [ 'pipe', 'pipe', 'pipe' ] } + ); + containersUp = out.trim().length > 0; + } catch { /* ignore */ } + + if ( ! containersUp ) { + console.error( '✗ wp-env start failed and no containers are running.' ); + process.exit( 1 ); + } + + // MySQL startup timing race: wp-env start sometimes skips the WordPress + // installation when MySQL isn't fully initialised. A second start always + // succeeds because MySQL is already up. Skip the retry if afterStart also + // failed (non-zero exit from the composer install step is tolerated). + const wpCheck = spawnSync( + 'npx', + [ 'wp-env', 'run', 'tests-cli', '--', 'wp', 'core', 'is-installed' ], + { cwd: ROOT, stdio: 'pipe' } + ); + if ( wpCheck.status !== 0 ) { + console.log( 'WordPress not installed after wp-env start (MySQL timing race) — retrying wp-env start...' ); + spawnSync( 'npx', [ 'wp-env', 'start' ], { cwd: ROOT, stdio: 'inherit' } ); + } +} + +// Ensure vendor/ is populated regardless of whether afterStart's composer install +// succeeded — mirrors the old CI's explicit "Install PHP dependencies" step. +const install = spawnSync( + 'npx', + [ 'wp-env', 'run', 'tests-cli', '--env-cwd=wp-content/plugins/airo-wp', '--', 'composer', 'install', '--no-interaction', '--no-progress' ], + { cwd: ROOT, stdio: 'inherit' } +); +if ( install.status !== 0 ) { + if ( ! alreadyRunning ) spawnSync( 'npx', [ 'wp-env', 'stop' ], { cwd: ROOT, stdio: 'inherit' } ); + process.exit( install.status ?? 1 ); +} + +const result = spawnSync( + 'npx', + [ 'wp-env', 'run', 'tests-cli', '--env-cwd=wp-content/plugins/airo-wp', '--', 'composer', subcommand ], + { cwd: ROOT, stdio: 'inherit' } +); + +if ( ! alreadyRunning ) { + spawnSync( 'npx', [ 'wp-env', 'stop' ], { cwd: ROOT, stdio: 'inherit' } ); +} + +process.exit( result.status ?? 1 );