diff --git a/.github/workflows/phpunit-test.yml b/.github/workflows/phpunit-test.yml index f6be916ba..6f7c59dfe 100644 --- a/.github/workflows/phpunit-test.yml +++ b/.github/workflows/phpunit-test.yml @@ -13,7 +13,10 @@ jobs: phpunit-test: name: PHPUnit tests (PHP ${{ inputs.php-version }}) runs-on: ubuntu-22.04 - + env: + WP_CORE_DIR: /tmp/wordpress + WP_TESTS_DIR: /tmp/wordpress-tests-lib + services: mysql: image: mysql:8.0 @@ -59,9 +62,7 @@ jobs: - name: Install Composer dependencies if: steps.composer-cache.outputs.cache-hit != 'true' - run: | - cd src - composer install --no-progress --prefer-dist --optimize-autoloader + run: composer install -d src --no-progress --prefer-dist --optimize-autoloader - name: Save Composer cache if: steps.composer-cache.outputs.cache-hit != 'true' @@ -70,21 +71,52 @@ jobs: path: src/vendor key: ${{ runner.os }}-php-${{ inputs.php-version }}-composer-${{ steps.deps-hash.outputs.deps_hash }} + - name: Resolve WordPress version metadata + id: wp-meta + run: | + set -euo pipefail + wp_version=$(curl -s http://api.wordpress.org/core/version-check/1.7/ | grep -o '"version":"[^"]*' | head -1 | sed 's/"version":"//') + if [ -z "$wp_version" ]; then + wp_version="latest-unknown" + fi + echo "wp_version=$wp_version" >> "$GITHUB_OUTPUT" + + - name: Get WordPress test suite cache + id: wp-tests-cache + uses: actions/cache/restore@v4 + with: + path: | + ${{ env.WP_CORE_DIR }} + ${{ env.WP_TESTS_DIR }} + key: ${{ runner.os }}-php-${{ inputs.php-version }}-wp-tests-${{ hashFiles('tests/install-wp-tests.sh') }}-wp-${{ steps.wp-meta.outputs.wp_version }} + - name: Install WordPress test suite run: | bash tests/install-wp-tests.sh wordpress_test root root 127.0.0.1:3306 latest true + - name: Save WordPress test suite cache + if: steps.wp-tests-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ${{ env.WP_CORE_DIR }} + ${{ env.WP_TESTS_DIR }} + key: ${{ steps.wp-tests-cache.outputs.cache-primary-key }} + - name: Run PHPUnit tests run: | + set -euo pipefail + mkdir -p test-results/phpunit cd src - vendor/bin/phpunit -c ../phpunit.xml --testdox + vendor/bin/phpunit -c ../phpunit.xml --testdox --log-junit ../test-results/phpunit/phpunit-${{ inputs.php-version }}.xml 2>&1 | tee ../test-results/phpunit/phpunit-${{ inputs.php-version }}.log - uses: actions/upload-artifact@v4 if: always() with: name: phpunit-test-results-php-${{ inputs.php-version }} path: | - .phpunit.result.cache + src/.phpunit.result.cache + test-results/phpunit/ if-no-files-found: ignore retention-days: 2 diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 45123d4bf..a9fc67f70 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -24,64 +24,250 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - phpunit-php-7-4: + phpunit: if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') + strategy: + fail-fast: false + matrix: + php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] uses: ./.github/workflows/phpunit-test.yml with: - php-version: '7.4' - - phpunit-php-8-0: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') - uses: ./.github/workflows/phpunit-test.yml - with: - php-version: '8.0' - - phpunit-php-8-1: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') - uses: ./.github/workflows/phpunit-test.yml - with: - php-version: '8.1' - - phpunit-php-8-2: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') - uses: ./.github/workflows/phpunit-test.yml - with: - php-version: '8.2' - - phpunit-php-8-3: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') - uses: ./.github/workflows/phpunit-test.yml - with: - php-version: '8.3' - - phpunit-php-8-4: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') - uses: ./.github/workflows/phpunit-test.yml - with: - php-version: '8.4' + php-version: ${{ matrix.php-version }} test-result: - needs: [phpunit-php-7-4, phpunit-php-8-0, phpunit-php-8-1, phpunit-php-8-2, phpunit-php-8-3, phpunit-php-8-4] - if: always() && (needs.phpunit-php-7-4.result != 'skipped' || needs.phpunit-php-8-0.result != 'skipped' || needs.phpunit-php-8-1.result != 'skipped' || needs.phpunit-php-8-2.result != 'skipped' || needs.phpunit-php-8-3.result != 'skipped' || needs.phpunit-php-8-4.result != 'skipped') + needs: [phpunit] + if: always() && needs.phpunit.result != 'skipped' runs-on: ubuntu-22.04 name: PHPUnit - Test Results Summary + permissions: + pull-requests: write + issues: write steps: - name: Test status summary run: | - echo "PHP 7.4: ${{ needs.phpunit-php-7-4.result }}" - echo "PHP 8.0: ${{ needs.phpunit-php-8-0.result }}" - echo "PHP 8.1: ${{ needs.phpunit-php-8-1.result }}" - echo "PHP 8.2: ${{ needs.phpunit-php-8-2.result }}" - echo "PHP 8.3: ${{ needs.phpunit-php-8-3.result }}" - echo "PHP 8.4: ${{ needs.phpunit-php-8-4.result }}" + echo "PHPUnit matrix result: ${{ needs.phpunit.result }}" + + - name: Delete previous PR failure comment on success + if: github.event_name == 'pull_request' && needs.phpunit.result == 'success' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker)); + + for (const comment of existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + + - name: Download PHPUnit artifacts + if: github.event_name == 'pull_request' && needs.phpunit.result == 'failure' + uses: actions/download-artifact@v5 + with: + pattern: phpunit-test-results-php-* + path: phpunit-artifacts + merge-multiple: true + + - name: Build distinct error summary + if: github.event_name == 'pull_request' && needs.phpunit.result == 'failure' + run: | + set -euo pipefail + python3 - <<'PY' + import glob + import os + import re + import xml.etree.ElementTree as ET + from collections import defaultdict + + artifacts_dir = 'phpunit-artifacts' + + def version_from_path(path: str) -> str: + base = os.path.basename(path) + m = re.search(r'phpunit-([0-9]+\.[0-9]+)\.xml$', base) + if m: + return m.group(1) + m = re.search(r'phpunit-([0-9]+\.[0-9]+)\.log$', base) + if m: + return m.group(1) + return 'unknown' + + def add_entry(grouped, key, version, message): + if not message.strip(): + return + grouped[key]['versions'].add(version) + # Keep the first representative message we see for this key. + if not grouped[key]['message']: + grouped[key]['message'] = message.strip() + + grouped = defaultdict(lambda: {'versions': set(), 'message': ''}) + versions_seen = set() + + # Prefer JUnit XML when present. + for xml_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.xml'), recursive=True)): + version = version_from_path(xml_path) + versions_seen.add(version) + try: + root = ET.parse(xml_path).getroot() + except Exception: + continue + + for testcase in root.iter('testcase'): + for tag in ('error', 'failure'): + for node in testcase.findall(tag): + etype = (node.attrib.get('type') or tag).strip() + msg = (node.attrib.get('message') or '').strip() + details = (node.text or '').strip() + combined = f"{etype}: {msg}".strip(': ') + if details: + combined = combined + "\n" + details + + testcase_id = ( + (testcase.attrib.get('classname') or '').strip() + + '::' + + (testcase.attrib.get('name') or '').strip() + ).strip(':') + + extracted = '' + if not msg and details: + for line in details.splitlines(): + line = line.strip() + if line.startswith('CI demo:'): + extracted = line + break + if not extracted: + extracted = details.splitlines()[0].strip() + + # Dedupe key: prefer explicit message; otherwise use extracted details + testcase id. + key_parts = [etype] + if msg: + key_parts.append(msg) + elif extracted: + key_parts.append(extracted) + if testcase_id: + key_parts.append(testcase_id) + key = "\n".join([p for p in key_parts if p]).strip() or (combined.splitlines()[0] if combined else 'unknown') + add_entry(grouped, key, version, combined) + + # Fallback: scan logs for fatals if XML missing. + fatal_re = re.compile(r'^(PHP\s+Fatal\s+error:.*|Fatal\s+error:.*)$', re.MULTILINE) + for log_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.log'), recursive=True)): + version = version_from_path(log_path) + versions_seen.add(version) + try: + log = open(log_path, 'r', encoding='utf-8', errors='replace').read() + except Exception: + continue + m = fatal_re.search(log) + if m: + msg = m.group(1).strip() + key = 'Fatal error\n' + msg + add_entry(grouped, key, version, msg) + + versions = sorted(v for v in versions_seen if v != 'unknown') + + def versions_label(affected): + affected = sorted(v for v in affected if v != 'unknown') + if versions and affected == versions: + return 'all' + return ', '.join(affected) if affected else 'unknown' + + items = sorted(grouped.items(), key=lambda kv: (-len(kv[1]['versions']), kv[0])) + blocks = [] + for idx, (key, info) in enumerate(items): + affected = versions_label(info['versions']) + message = info['message'] + block = ( + "-----\n" + f"Affected PHP version: `{affected}`\n" + "```php\n" + f"{message}\n" + "```" + ) + if idx == len(items) - 1: + block += "\n-----" + blocks.append(block) + + if blocks: + details = "\n\n".join(blocks) + else: + details = "No PHPUnit error details could be parsed from artifacts." + + md = "\n".join([ + "
", + "See all PHPUnit errors (click to expand)", + "", + details, + "", + "
", + "", + ]) + + with open('phpunit-errors.md', 'w', encoding='utf-8') as f: + f.write(md) + PY + + - name: Post PR comment on failure + if: github.event_name == 'pull_request' && needs.phpunit.result == 'failure' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + + const fs = require('fs'); + let details = ''; + try { + details = fs.readFileSync('phpunit-errors.md', 'utf8').trim(); + } catch (e) { + details = ''; + } + + const body = [ + marker, + '## PHPUnit Test Failure', + '', + `One or more PHP version targets failed in [this workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`, + '', + details || '_No parsed error details found._', + '', + 'Please review the failing jobs and fix the issues before merging.', + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker)); + + for (const comment of existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); - name: Check overall status - if: | - (needs.phpunit-php-7-4.result != 'success' && needs.phpunit-php-7-4.result != 'skipped') || - (needs.phpunit-php-8-0.result != 'success' && needs.phpunit-php-8-0.result != 'skipped') || - (needs.phpunit-php-8-1.result != 'success' && needs.phpunit-php-8-1.result != 'skipped') || - (needs.phpunit-php-8-2.result != 'success' && needs.phpunit-php-8-2.result != 'skipped') || - (needs.phpunit-php-8-3.result != 'success' && needs.phpunit-php-8-3.result != 'skipped') || - (needs.phpunit-php-8-4.result != 'success' && needs.phpunit-php-8-4.result != 'skipped') + if: needs.phpunit.result != 'success' run: exit 1 diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml index 59f5b4a91..c3180bde4 100644 --- a/.github/workflows/playwright-test.yml +++ b/.github/workflows/playwright-test.yml @@ -45,10 +45,16 @@ jobs: WORDPRESS_DEBUG: 1 WORDPRESS_CONFIG_EXTRA: | define( 'FS_METHOD', 'direct' ); + define( 'WP_DEBUG', true ); define( 'WP_DEBUG_LOG', true ); - define( 'WP_DEBUG_DISPLAY', false ); + define( 'WP_DEBUG_DISPLAY', true ); + define( 'WP_DISABLE_FATAL_ERROR_HANDLER', true ); define( 'SCRIPT_DEBUG', true ); define( 'WP_ENVIRONMENT_TYPE', 'local' ); + @ini_set( 'display_errors', '1' ); + @ini_set( 'display_startup_errors', '1' ); + @ini_set( 'log_errors', '1' ); + @ini_set( 'error_reporting', (string) E_ALL ); ports: - 8888:80 steps: @@ -88,24 +94,28 @@ jobs: uses: actions/cache/restore@v4 with: path: | - node_modules src/vendor - key: ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }}-${{ github.run_id }}-${{ github.job }} + node_modules + key: ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }} restore-keys: | ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }}- + ${{ runner.os }}-${{ inputs.test-mode }}-deps- - name: Install workflow dependencies - if: steps.deps-cache.outputs.cache-matched-key == '' - run: npm run prepare-environment:ci && npm run bundle + if: steps.deps-cache.outputs.cache-hit != 'true' + run: npm run prepare-environment:ci + + - name: Build plugin assets + run: npm run bundle - name: Save vendor and node_modules cache - if: steps.deps-cache.outputs.cache-matched-key == '' + if: steps.deps-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | src/vendor node_modules - key: ${{ steps.deps-cache.outputs.cache-primary-key }} + key: ${{ steps.deps-cache.outputs.cache-primary-key }}-${{ github.run_id }}-${{ github.job }} - name: Wait for WordPress to be reachable run: | @@ -229,7 +239,31 @@ jobs: WP_E2E_WPCLI_URL: http://localhost:8888 WP_E2E_WP_CONTAINER: ${{ job.services.wordpress.id }} WP_E2E_MULTISITE_MODE: ${{ inputs.multisite }} - run: npm run test:playwright -- --project=${{ inputs.project-name }} + run: | + set -euo pipefail + mkdir -p test-results/ci + suffix="${{ inputs.project-name }}${{ inputs.multisite && '-multisite' || '' }}" + : > "test-results/ci/playwright-${suffix}.log" + npm run test:playwright -- --project=${{ inputs.project-name }} 2>&1 | tee -a "test-results/ci/playwright-${suffix}.log" + + - name: Normalize Playwright report filenames + if: always() + run: | + set -euo pipefail + mkdir -p test-results/ci + suffix="${{ inputs.project-name }}${{ inputs.multisite && '-multisite' || '' }}" + + if [ -f test-results/results.xml ]; then + mv test-results/results.xml "test-results/ci/results-${suffix}.xml" + elif [ -f tests/playwright/test-results/results.xml ]; then + mv tests/playwright/test-results/results.xml "test-results/ci/results-${suffix}.xml" + fi + + if [ -f test-results/results.json ]; then + mv test-results/results.json "test-results/ci/results-${suffix}.json" + elif [ -f tests/playwright/test-results/results.json ]; then + mv tests/playwright/test-results/results.json "test-results/ci/results-${suffix}.json" + fi - name: Print WordPress logs on failure if: failure() @@ -241,7 +275,7 @@ jobs: - uses: actions/upload-artifact@v4 if: always() with: - name: playwright-test-results-${{ inputs.test-mode }} + name: playwright-test-results-${{ inputs.test-mode }}-${{ inputs.project-name }}${{ inputs.multisite && '-multisite' || '' }} path: test-results/ if-no-files-found: ignore retention-days: 2 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 51f39bd4d..acd18c071 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -15,8 +15,8 @@ on: workflow_dispatch: permissions: - contents: write - pull-requests: write + contents: read + pull-requests: read actions: read concurrency: @@ -45,12 +45,289 @@ jobs: if: always() && (needs.playwright-default.result != 'skipped' || needs.playwright-file-based-execution.result != 'skipped') runs-on: ubuntu-22.04 name: Playwright - Test Results Summary + permissions: + pull-requests: write + issues: write steps: - name: Test status summary run: | echo "Default Mode: ${{ needs.playwright-default.result }}" echo "File-based Execution: ${{ needs.playwright-file-based-execution.result }}" + - name: Delete previous PR failure comment on success + if: github.event_name == 'pull_request' && needs.playwright-default.result == 'success' && needs.playwright-file-based-execution.result == 'success' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker)); + + for (const comment of existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + + - name: Download Playwright artifacts + if: github.event_name == 'pull_request' && (needs.playwright-default.result == 'failure' || needs.playwright-file-based-execution.result == 'failure') + uses: actions/download-artifact@v5 + with: + pattern: playwright-test-results-* + path: playwright-artifacts + merge-multiple: true + + - name: Build distinct error summary + if: github.event_name == 'pull_request' && (needs.playwright-default.result == 'failure' || needs.playwright-file-based-execution.result == 'failure') + run: | + set -euo pipefail + python3 - <<'PY' + import glob + import json + import os + import re + import xml.etree.ElementTree as ET + from collections import defaultdict + + artifacts_dir = 'playwright-artifacts' + + def target_from_path(path: str) -> str: + base = os.path.basename(path) + m = re.search(r'(?:results|playwright)-(.+)\.(?:xml|json|log)$', base) + if m: + return m.group(1) + return 'unknown' + + def add_entry(grouped, key, target, message): + if not message.strip(): + return + grouped[key]['targets'].add(target) + if not grouped[key]['message']: + grouped[key]['message'] = message.strip() + + grouped = defaultdict(lambda: {'targets': set(), 'message': ''}) + targets_seen = set() + + def safe_str(val) -> str: + if val is None: + return '' + if isinstance(val, str): + return val + try: + return str(val) + except Exception: + return '' + + def walk_suite(suite, title_path): + # Playwright JSON reporter structure: suites -> suites/specs. + for child in suite.get('suites', []) or []: + walk_suite(child, title_path + [safe_str(child.get('title'))]) + + for spec in suite.get('specs', []) or []: + spec_title = safe_str(spec.get('title')) + for test in spec.get('tests', []) or []: + test_title = safe_str(test.get('title')) + for result in test.get('results', []) or []: + status = safe_str(result.get('status')) + err = result.get('error') or {} + err_msg = safe_str(err.get('message')) + err_stack = safe_str(err.get('stack')) + + if status != 'failed' and not err_msg and not err_stack: + continue + + name = ' > '.join([p for p in (title_path + [spec_title, test_title]) if p]) + combined = name + if err_msg: + combined += "\n" + err_msg + if err_stack: + combined += "\n" + err_stack + yield combined.strip() + + # 1) Parse JSON reporter output (most reliable for Playwright failures). + for json_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.json'), recursive=True)): + target = target_from_path(json_path) + targets_seen.add(target) + try: + data = json.loads(open(json_path, 'r', encoding='utf-8', errors='replace').read()) + except Exception: + continue + + suites = data.get('suites', []) if isinstance(data, dict) else [] + if not suites: + continue + + for suite in suites: + for combined in walk_suite(suite, [safe_str(suite.get('title'))]): + # Dedupe key: first line of error message + test name line. + first = combined.splitlines()[0].strip() if combined else 'unknown' + key = first + add_entry(grouped, key, target, combined) + + # 2) Parse JUnit XML (secondary; may be missing on some runner failures). + for xml_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.xml'), recursive=True)): + target = target_from_path(xml_path) + targets_seen.add(target) + try: + root = ET.parse(xml_path).getroot() + except Exception: + continue + + for testcase in root.iter('testcase'): + for tag in ('error', 'failure'): + for node in testcase.findall(tag): + etype = (node.attrib.get('type') or tag).strip() + msg = (node.attrib.get('message') or '').strip() + details = (node.text or '').strip() + + combined = f"{etype}: {msg}".strip(': ') + if details: + combined = combined + "\n" + details + + testcase_id = ( + (testcase.attrib.get('classname') or '').strip() + + '::' + + (testcase.attrib.get('name') or '').strip() + ).strip(':') + + extracted = '' + if not msg and details: + extracted = details.splitlines()[0].strip() + + key_parts = [etype] + if msg: + key_parts.append(msg) + elif extracted: + key_parts.append(extracted) + if testcase_id: + key_parts.append(testcase_id) + key = "\n".join([p for p in key_parts if p]).strip() or (combined.splitlines()[0] if combined else 'unknown') + + add_entry(grouped, key, target, combined) + + # 3) Fallback: scan logs if JSON/XML were not usable. + if not grouped: + # Try to extract something meaningful from Playwright logs. + error_line_re = re.compile(r'^(Error:.*|\s+at\s+.*|expect\(.*\).*)$', re.MULTILINE) + for log_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.log'), recursive=True)): + target = target_from_path(log_path) + targets_seen.add(target) + try: + text = open(log_path, 'r', encoding='utf-8', errors='replace').read() + except Exception: + continue + + # Prefer a compact tail if we can't find explicit error lines. + lines = [ln.rstrip() for ln in text.splitlines()] + tail = "\n".join(lines[-80:]) + + matches = error_line_re.findall(text) + extracted = "\n".join(matches[:80]).strip() if matches else tail.strip() + if extracted: + key = extracted.splitlines()[0][:200] + add_entry(grouped, key, target, extracted) + + targets = sorted(t for t in targets_seen if t != 'unknown') + + def targets_label(affected): + affected = sorted(t for t in affected if t != 'unknown') + if targets and affected == targets: + return 'all' + return ', '.join(affected) if affected else 'unknown' + + items = sorted(grouped.items(), key=lambda kv: (-len(kv[1]['targets']), kv[0])) + blocks = [] + for idx, (key, info) in enumerate(items): + affected = targets_label(info['targets']) + message = info['message'] + block = ( + "-----\n" + f"Affected Playwright test: `{affected}`\n" + "```text\n" + f"{message}\n" + "```" + ) + if idx == len(items) - 1: + block += "\n-----" + blocks.append(block) + + if blocks: + details_md = "\n\n".join(blocks) + else: + details_md = "No Playwright error details could be parsed from artifacts." + + md = "\n".join([ + "
", + "See all Playwright errors (click to expand)", + "", + details_md, + "", + "
", + "", + ]) + + with open('playwright-errors.md', 'w', encoding='utf-8') as f: + f.write(md) + PY + + - name: Post PR comment on failure + if: github.event_name == 'pull_request' && (needs.playwright-default.result == 'failure' || needs.playwright-file-based-execution.result == 'failure') + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + + const fs = require('fs'); + let details = ''; + try { + details = fs.readFileSync('playwright-errors.md', 'utf8').trim(); + } catch (e) { + details = ''; + } + + const body = [ + marker, + '## Playwright Test Failure', + '', + `One or more Playwright targets failed in [this workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`, + '', + details || '_No parsed error details found._', + '', + 'Please review the failing jobs and fix the issues before merging.', + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker)); + for (const comment of existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + - name: Check overall status if: ${{ (needs.playwright-default.result != 'success' && needs.playwright-default.result != 'skipped') || (needs.playwright-file-based-execution.result != 'success' && needs.playwright-file-based-execution.result != 'skipped') }} run: exit 1 diff --git a/.gitignore b/.gitignore index bf7f94184..b07c6dfbf 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ npm-debug.log # PHPUnit .phpunit.result.cache +.wp-core +.wp-tests-lib /coverage/ # Playwright @@ -29,3 +31,7 @@ Thumbs.db # storyman .story +.tmp +tmp +.continue +.codex \ No newline at end of file diff --git a/config/webpack-js.ts b/config/webpack-js.ts index adcd5cb47..c13fd47a2 100644 --- a/config/webpack-js.ts +++ b/config/webpack-js.ts @@ -24,14 +24,15 @@ const babelConfig = { export const jsWebpackConfig: Configuration = { entry: { - edit: { import: `${SOURCE_DIR}/edit.ts`, dependOn: 'editor' }, - editor: `${SOURCE_DIR}/editor.ts`, - import: `${SOURCE_DIR}/import.ts`, - manage: `${SOURCE_DIR}/manage.ts`, - mce: `${SOURCE_DIR}/mce.ts`, - prism: `${SOURCE_DIR}/prism.ts`, - settings: { import: `${SOURCE_DIR}/settings.ts`, dependOn: 'editor' }, - welcome: `${SOURCE_DIR}/welcome.ts`, + 'admin-bar': `${SOURCE_DIR}/admin-bar.ts`, + 'edit': { import: `${SOURCE_DIR}/edit.ts`, dependOn: 'editor' }, + 'editor': `${SOURCE_DIR}/editor.ts`, + 'import': `${SOURCE_DIR}/import.ts`, + 'manage': `${SOURCE_DIR}/manage.ts`, + 'mce': `${SOURCE_DIR}/mce.ts`, + 'prism': `${SOURCE_DIR}/prism.ts`, + 'settings': { import: `${SOURCE_DIR}/settings.ts`, dependOn: 'editor' }, + 'welcome': `${SOURCE_DIR}/welcome.ts` }, output: { path: join(resolve(__dirname), '..', DEST_DIR), diff --git a/eslint.config.mjs b/eslint.config.mjs index aca1db961..adebe1ba7 100755 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,7 +26,7 @@ export default eslintTs.config( rules: reactHooks.configs.recommended.rules, }, { - ignores: ['bundle/*', 'src/dist/*', 'src/vendor/*', 'svn/*', '*.config.mjs', '*.config.js'] + ignores: ['bundle/*', 'src/dist/*', 'src/vendor/*', 'svn/*', '*.config.mjs', '*.config.js', '.*/*', 'tmp/*'] }, { languageOptions: { @@ -136,8 +136,9 @@ export default eslintTs.config( } }, { - files: ['test/**', '**/*.test.*', '**/*.spec.*'], + files: ['tests/**', '**/*.test.*', '**/*.spec.*'], rules: { + '@typescript-eslint/no-magic-numbers': 'off', 'max-lines-per-function': 'off' } } diff --git a/package.json b/package.json index 1741bc21d..1c2a98928 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "test": "tests" }, "scripts": { - "test:php": "cd src && vendor/bin/phpunit -c ../phpunit.xml", + "test:php": "WP_TESTS_DIR=./.wp-tests-lib WP_DEVELOP_DIR=./.wp-core src/vendor/bin/phpunit -c phpunit.xml", "test:php:watch": "npm run test:php -- --testdox", "test:playwright": "playwright test -c tests/playwright/playwright.config.ts", "test:playwright:debug": "npm run test:playwright -- --debug", @@ -17,7 +17,8 @@ "wp-env:start": "wp-env start", "wp-env:stop": "wp-env stop", "wp-env:clean": "wp-env clean all", - "test:setup:playwright": "wp-env run cli wp plugin activate code-snippets", + "test:setup:php": "ts-node tests/test-setup-phpunit.ts", + "test:setup:playwright": "ts-node tests/test-setup-playwright.ts", "build": "webpack", "watch": "webpack --watch", "bundle": "ts-node scripts/bundle.ts", @@ -26,8 +27,8 @@ "lint:styles:fix": "stylelint --fix 'src/css/**/*.scss'", "lint:js": "eslint", "lint:js:fix": "eslint --fix", - "lint:php": "src/vendor/bin/phpcs -s --colors ./src/phpcs.xml", - "lint:php:fix": "src/vendor/bin/phpcbf ./src/phpcs.xml", + "lint:php": "src/vendor/bin/phpcs -s --colors --standard=phpcs.xml src/php tests", + "lint:php:fix": "src/vendor/bin/phpcbf --standard=phpcs.xml src/php tests", "version": "ts-node scripts/version.ts", "version-dev": "npm version --git-tag-version=false --preid=dev", "version-alpha": "npm version --git-tag-version=false --preid=alpha", diff --git a/phpunit.xml b/phpunit.xml index 79b627333..2d5ad9f89 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,7 +7,7 @@ convertWarningsToExceptions="true"> - ./tests/ + ./tests/phpunit/ diff --git a/src/css/admin-bar.scss b/src/css/admin-bar.scss new file mode 100644 index 000000000..de7b2a8e6 --- /dev/null +++ b/src/css/admin-bar.scss @@ -0,0 +1,119 @@ +#wpadminbar { + #wp-admin-bar-code-snippets > .ab-item { + .code-snippets-admin-bar-icon { + width: 16px; + height: 16px; + top: 5px; + + &::before { + content: ''; + display: block; + width: 16px; + height: 16px; + mask-image: url('../assets/menu-icon.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + background-color: rgba(240, 245, 250, 0.6); + } + } + } + + .code-snippets-safe-mode-active { + > .ab-item, + &.ab-item { + background: #b32d2e; + color: #fff; + } + + &:hover > .ab-item, + &.hover > .ab-item { + background: #d63638; + color: #fff; + } + } + + .code-snippets-safe-mode.code-snippets-safe-mode-active > .ab-item, + .code-snippets-safe-mode.code-snippets-safe-mode-active.ab-item { + font-weight: 600; + } + + .code-snippets-safe-mode .code-snippets-external-icon { + font-family: dashicons; + display: inline-block; + position: relative; + line-height: 1; + opacity: .8; + top: 6px; + } + + .code-snippets-safe-mode:hover .code-snippets-external-icon, + .code-snippets-safe-mode.hover .code-snippets-external-icon { + opacity: 1; + } + + #wp-admin-bar-code-snippets-active-snippets > .ab-sub-wrapper > .ab-submenu, + #wp-admin-bar-code-snippets-inactive-snippets > .ab-sub-wrapper > .ab-submenu { + padding-top: 0; + } + + .code-snippets-disabled > .ab-item { + opacity: 0.6; + } + + .code-snippets-disabled:hover > .ab-item, + .code-snippets-disabled.hover > .ab-item { + opacity: 1; + } + + .code-snippets-pagination-node > .ab-item { + padding: 0; + margin-bottom: 6px; + } + + .code-snippets-pagination-controls { + display: flex; + align-items: stretch; + width: 100%; + white-space: nowrap; + + .code-snippets-pagination-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1 1 0; + min-height: 32px; + padding: 0 10px; + background: rgba(240, 245, 250, 0.08); + color: inherit; + text-decoration: none; + line-height: 1.2; + box-sizing: border-box; + + & + .code-snippets-pagination-button { + border-left: 1px solid rgba(240, 245, 250, 0.15); + } + + &[aria-disabled='true'] { + opacity: 0.4; + pointer-events: none; + } + } + + a.code-snippets-pagination-button:hover { + background: rgba(240, 245, 250, 0.16); + } + + a[data-action='first'], + a[data-action='last'] { + flex: 0 0 36px; + padding: 0; + } + + .code-snippets-pagination-page { + flex: 0 0 auto; + cursor: default; + opacity: 0.85; + } + } +} diff --git a/src/js/entries/admin-bar.ts b/src/js/entries/admin-bar.ts new file mode 100644 index 000000000..444efec02 --- /dev/null +++ b/src/js/entries/admin-bar.ts @@ -0,0 +1,328 @@ +import { getSnippetType } from '../utils/snippets/snippets' +import type { SnippetScope } from '../types/Snippet' + +export type PaginationStatus = 'active' | 'inactive' +export type PaginationAction = 'first' | 'prev' | 'next' | 'last' + +export interface SnippetResponseItem { + id: number + scope: SnippetScope + name?: string +} + +export interface AdminBarConfig { + restUrl: string + nonce: string + perPage: number + isNetwork: boolean + excludeTypes: string[] + snippetPlaceholder: string + editUrlBase: string + activeNodeId: string + inactiveNodeId: string +} + +declare const CODE_SNIPPETS_ADMIN_BAR: AdminBarConfig | undefined + +const config = 'undefined' === typeof CODE_SNIPPETS_ADMIN_BAR ? undefined : CODE_SNIPPETS_ADMIN_BAR + +const getMenuNode = (status: PaginationStatus): HTMLElement | null => { + if (!config) { + return null + } + + const nodeId = 'active' === status ? config.activeNodeId : config.inactiveNodeId + return document.getElementById(nodeId) +} + +const getPaginationControls = (status: PaginationStatus): HTMLElement | null => { + const menuNode = getMenuNode(status) + if (!menuNode) { + return null + } + + return menuNode.querySelector(`.code-snippets-pagination-controls[data-status="${status}"]`) +} + +const getPaginationState = (controls: HTMLElement): { page: number; totalPages: number } => { + const page = Number.parseInt(controls.dataset.page ?? '1', 10) + const totalPages = Number.parseInt(controls.dataset.totalPages ?? '1', 10) + + return { + page: Number.isFinite(page) && 0 < page ? page : 1, + totalPages: Number.isFinite(totalPages) && 0 < totalPages ? totalPages : 1 + } +} + +const setLoading = (controls: HTMLElement, loading: boolean) => { + controls.dataset.loading = loading ? 'true' : 'false' +} + +const buildRequestUrl = (status: PaginationStatus, page: number): string => { + if (!config) { + return '' + } + + const url = new URL(config.restUrl) + url.searchParams.set('status', status) + url.searchParams.set('page', String(page)) + url.searchParams.set('per_page', String(config.perPage)) + url.searchParams.set('orderby', 'display_name') + url.searchParams.set('order', 'asc') + + if (config.isNetwork) { + url.searchParams.set('network', '1') + } + + for (const excluded of config.excludeTypes) { + url.searchParams.append('exclude_types[]', excluded) + } + + return url.toString() +} + +const fetchSnippetsPage = async (status: PaginationStatus, page: number) => { + if (!config) { + throw new Error('Missing CODE_SNIPPETS_ADMIN_BAR config') + } + + const response = await fetch(buildRequestUrl(status, page), { + credentials: 'same-origin', + headers: { + 'X-WP-Nonce': config.nonce + } + }) + + if (!response.ok) { + throw new Error(`Failed to fetch snippets (${response.status})`) + } + + const totalPagesHeader = response.headers.get('X-WP-TotalPages') ?? '1' + const totalPages = Number.parseInt(totalPagesHeader, 10) || 1 + + const snippets = await response.json() + + return { snippets, totalPages } +} + +const buildEditUrl = (snippetId: number): string => { + if (!config) { + return '#' + } + + try { + const url = new URL(config.editUrlBase, window.location.href) + url.searchParams.set('id', String(snippetId)) + return url.toString() + } catch { + return '#' + } +} + +const buildSnippetPlaceholder = (snippetId: number): string => + config?.snippetPlaceholder.replace(/%(?:\d+\$)?d/, String(snippetId)) ?? + `Snippet #${snippetId}` + +const formatSnippetTitle = (snippet: SnippetResponseItem): string => { + const typeLabel = getSnippetType(snippet).toUpperCase() + const name = snippet.name?.trim() + const title = ('' === name ? undefined : name) ?? buildSnippetPlaceholder(snippet.id) + return `(${typeLabel}) ${title}` +} + +const setControlsLinkDisabled = (controls: HTMLElement, action: PaginationAction, disabled: boolean): void => { + const link = controls.querySelector(`a[data-action="${action}"]`) + if (link) { + link.setAttribute('aria-disabled', disabled ? 'true' : 'false') + } +} + +const updatePaginationHrefs = (controls: HTMLElement, page: number, totalPages: number, queryArg?: string): void => { + if (!queryArg) { + return + } + + const firstLink = controls.querySelector('a[data-action="first"]') + const baseHref = firstLink?.href + if (!baseHref) { + return + } + + const buildHref = (targetPage: number) => { + const url = new URL(baseHref) + + if (1 >= targetPage) { + url.searchParams.delete(queryArg) + } else { + url.searchParams.set(queryArg, String(targetPage)) + } + + return url.toString() + } + + const getLink = (action: PaginationAction) => + controls.querySelector(`a[data-action="${action}"]`) + + const first = getLink('first') + if (first) { + first.href = buildHref(1) + } + + const prev = getLink('prev') + if (prev) { + prev.href = buildHref(Math.max(1, page - 1)) + } + + const next = getLink('next') + if (next) { + next.href = buildHref(Math.min(totalPages, page + 1)) + } + + const last = getLink('last') + if (last) { + last.href = buildHref(totalPages) + } +} + +const updatePaginationControls = (controls: HTMLElement, page: number, totalPages: number) => { + controls.dataset.page = String(page) + controls.dataset.totalPages = String(totalPages) + + const pageLabel = controls.querySelector('.code-snippets-pagination-page') + if (pageLabel) { + pageLabel.textContent = pageLabel.textContent?.replace(/\(\d+\/\d+\)/, `(${page}/${totalPages})`) ?? pageLabel.textContent + } + + const disableFirstPrev = 1 >= page + const disableNextLast = page >= totalPages + + setControlsLinkDisabled(controls, 'first', disableFirstPrev) + setControlsLinkDisabled(controls, 'prev', disableFirstPrev) + setControlsLinkDisabled(controls, 'next', disableNextLast) + setControlsLinkDisabled(controls, 'last', disableNextLast) + + updatePaginationHrefs(controls, page, totalPages, controls.dataset.queryArg) +} + +const replaceSnippetItems = (status: PaginationStatus, snippets: SnippetResponseItem[]) => { + const menuNode = getMenuNode(status) + if (!menuNode) { + return + } + + const subMenu = menuNode.querySelector('ul.ab-submenu') + if (!subMenu) { + return + } + + subMenu.querySelectorAll('li.code-snippets-snippet-item').forEach(node => node.remove()) + + const insertAfterId = 'active' === status + ? 'wp-admin-bar-code-snippets-active-pagination' + : 'wp-admin-bar-code-snippets-inactive-pagination' + + const insertAfter = subMenu.querySelector(`#${insertAfterId}`) + const fragment = document.createDocumentFragment() + + for (const snippet of snippets) { + const li = document.createElement('li') + li.id = `wp-admin-bar-code-snippets-snippet-${snippet.id}` + li.className = 'code-snippets-snippet-item' + + const a = document.createElement('a') + a.className = 'ab-item' + a.href = buildEditUrl(snippet.id) + a.textContent = formatSnippetTitle(snippet) + + li.appendChild(a) + fragment.appendChild(li) + } + + if (insertAfter && insertAfter.parentNode === subMenu) { + subMenu.insertBefore(fragment, insertAfter.nextSibling) + } else { + subMenu.appendChild(fragment) + } +} + +const navigateToPage = async (status: PaginationStatus, targetPage: number) => { + const controls = getPaginationControls(status) + if (!controls) { + return + } + + const { totalPages: currentTotalPages } = getPaginationState(controls) + const page = Math.max(1, Math.min(targetPage, currentTotalPages)) + + setLoading(controls, true) + + try { + const { snippets, totalPages } = await fetchSnippetsPage(status, page) + updatePaginationControls(controls, page, totalPages) + replaceSnippetItems(status, snippets) + } catch (error) { + console.error(error) + } finally { + setLoading(controls, false) + } +} + +const handlePaginationClick = (event: MouseEvent) => { + const target = event.target + if (!target) { + return + } + + const link = target.closest('.code-snippets-pagination-controls a[data-action]') + if (!link) { + return + } + + const controls = link.closest('.code-snippets-pagination-controls') + if (!controls) { + return + } + + if ('true' === controls.dataset.loading) { + return + } + + const status = controls.dataset.status + const action = link.dataset.action + + if (!status || !action) { + return + } + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + + const { page, totalPages } = getPaginationState(controls) + + let targetPage = page + switch (action) { + case 'first': + targetPage = 1 + break + case 'prev': + targetPage = page - 1 + break + case 'next': + targetPage = page + 1 + break + case 'last': + targetPage = totalPages + break + } + + if (targetPage === page || 1 > targetPage || targetPage > totalPages) { + return + } + + void navigateToPage(status, targetPage) +} + +if (config) { + document.addEventListener('click', handlePaginationClick, true) +} diff --git a/src/js/utils/files.ts b/src/js/utils/files.ts index 52a8eb24d..f87d4b66b 100644 --- a/src/js/utils/files.ts +++ b/src/js/utils/files.ts @@ -21,7 +21,16 @@ export const downloadAsFile = (content: BlobPart, filename: string, type: string link.href = URL.createObjectURL(new Blob([content], { type })) setTimeout(() => URL.revokeObjectURL(link.href), TIMEOUT_SECONDS * SECOND_IN_MS) - setTimeout(() => link.click(), 0) + + // Some browsers (notably headless Chromium) can ignore programmatic clicks on detached anchors. + // Appending the link to the DOM before clicking improves reliability. + link.style.display = 'none' + document.body.appendChild(link) + + setTimeout(() => { + link.click() + link.remove() + }, 0) } export const downloadSnippetExportFile = ( diff --git a/src/php/Integration/Admin_Bar.php b/src/php/Integration/Admin_Bar.php new file mode 100644 index 000000000..c231aab74 --- /dev/null +++ b/src/php/Integration/Admin_Bar.php @@ -0,0 +1,538 @@ + true ] + ); + + wp_localize_script( + self::SCRIPT_HANDLE, + 'CODE_SNIPPETS_ADMIN_BAR', + [ + 'restUrl' => esc_url_raw( rest_url( Snippets_REST_Controller::get_base_route() ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'perPage' => $this->get_snippet_limit(), + 'isNetwork' => is_network_admin(), + 'excludeTypes' => [ 'cond' ], + // translators: %d: snippet identifier. + 'snippetPlaceholder' => esc_html__( 'Snippet #%d', 'code-snippets' ), + 'editUrlBase' => code_snippets()->get_menu_url( 'edit' ), + 'activeNodeId' => 'wp-admin-bar-' . self::ROOT_NODE_ID . '-active-snippets', + 'inactiveNodeId' => 'wp-admin-bar-' . self::ROOT_NODE_ID . '-inactive-snippets', + ] + ); + } + + /** + * Register admin bar nodes. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * + * @return void + */ + public function register_nodes( WP_Admin_Bar $wp_admin_bar ): void { + if ( ! is_admin_bar_showing() || ! code_snippets()->current_user_can() ) { + return; + } + + // Always show safe mode indicator regardless of setting. + $this->add_safe_mode_nodes( $wp_admin_bar, code_snippets()->evaluate_functions->is_safe_mode_active() ); + + // Check if admin bar menu is enabled via settings. + $is_enabled = get_setting( 'general', 'enable_admin_bar' ); + + if ( ! $is_enabled && ! apply_filters( 'code_snippets/admin_bar/enabled', false ) ) { + return; + } + + $title = sprintf( + '%s', + esc_html__( 'Snippets', 'code-snippets' ) + ); + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID, + 'title' => $title, + 'href' => code_snippets()->get_menu_url( 'manage' ), + ] + ); + + $this->add_quick_links( $wp_admin_bar ); + $this->add_snippet_listings( $wp_admin_bar ); + $this->add_safe_mode_link( $wp_admin_bar ); + } + + /** + * Add menu item for safe mode status. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * @param bool $is_safe_mode_active Whether safe mode is active. + * + * @return void + */ + private function add_safe_mode_nodes( WP_Admin_Bar $wp_admin_bar, bool $is_safe_mode_active ): void { + if ( ! $is_safe_mode_active ) { + return; + } + + $wp_admin_bar->add_node( + [ + 'id' => self::SAFE_MODE_NODE_ID, + 'title' => esc_html__( 'Snippets Safe Mode Active', 'code-snippets' ), + 'href' => 'https://snipco.de/safe-mode', + 'parent' => 'top-secondary', + 'meta' => [ + 'class' => 'code-snippets-safe-mode code-snippets-safe-mode-active', + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ], + ] + ); + } + + /** + * Add a safe mode documentation link under the Code Snippets menu. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * + * @return void + */ + private function add_safe_mode_link( WP_Admin_Bar $wp_admin_bar ): void { + $title = sprintf( + '%s ', + esc_html__( 'Safe Mode', 'code-snippets' ) + ); + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-safe-mode-doc', + 'title' => $title, + 'href' => 'https://snipco.de/safe-mode', + 'parent' => self::ROOT_NODE_ID, + 'meta' => [ + 'class' => 'code-snippets-safe-mode', + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ], + ] + ); + } + + /** + * Add quick links to common Code Snippets screens. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * + * @return void + */ + private function add_quick_links( WP_Admin_Bar $wp_admin_bar ): void { + $plugin = code_snippets(); + $is_licensed = $plugin->licensing->is_licensed(); + $upgrade_url = self_admin_url( 'admin.php?page=code_snippets_upgrade' ); + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-manage', + 'title' => esc_html_x( 'Manage', 'snippets', 'code-snippets' ), + 'href' => $plugin->get_menu_url( 'manage' ), + 'parent' => self::ROOT_NODE_ID, + ] + ); + + $statuses = [ + 'all' => _x( 'All Snippets', 'snippets', 'code-snippets' ), + 'active' => _x( 'Active Snippets', 'snippets', 'code-snippets' ), + 'inactive' => _x( 'Inactive Snippets', 'snippets', 'code-snippets' ), + ]; + + foreach ( $statuses as $status => $label ) { + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . "-status-$status", + 'title' => esc_html( $label ), + 'href' => esc_url( add_query_arg( 'status', $status, $plugin->get_menu_url( 'manage' ) ) ), + 'parent' => self::ROOT_NODE_ID . '-manage', + ] + ); + } + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-add', + 'title' => esc_html_x( 'Add New', 'snippet', 'code-snippets' ), + 'href' => $plugin->get_menu_url( 'add' ), + 'parent' => self::ROOT_NODE_ID, + ] + ); + + $types = [ + 'php' => _x( 'Functions (PHP)', 'snippet type', 'code-snippets' ), + 'html' => _x( 'Content (HTML)', 'snippet type', 'code-snippets' ), + 'css' => _x( 'Styles (CSS)', 'snippet type', 'code-snippets' ), + 'js' => _x( 'Scripts (JS)', 'snippet type', 'code-snippets' ), + 'cond' => _x( 'Conditions (COND)', 'snippet type', 'code-snippets' ), + ]; + + $types = array_intersect_key( $types, array_flip( Snippet::get_types() ) ); + $pro_types = [ 'css', 'js', 'cond' ]; + + foreach ( $types as $type => $label ) { + $is_disabled = in_array( $type, $pro_types, true ) && ! $is_licensed; + + $url = $is_disabled ? + $upgrade_url : + add_query_arg( 'type', $type, $plugin->get_menu_url( 'add' ) ); + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . "-add-$type", + 'title' => esc_html( $label ), + 'href' => esc_url( $url ), + 'parent' => self::ROOT_NODE_ID . '-add', + 'meta' => $is_disabled ? [ 'class' => 'code-snippets-disabled' ] : [], + ] + ); + } + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-import', + 'title' => esc_html_x( 'Import', 'snippets', 'code-snippets' ), + 'href' => $plugin->get_menu_url( 'import' ), + 'parent' => self::ROOT_NODE_ID, + ] + ); + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-settings', + 'title' => esc_html_x( 'Settings', 'snippets', 'code-snippets' ), + 'href' => $plugin->get_menu_url( 'settings', are_settings_unified() ? 'network' : 'admin' ), + 'parent' => self::ROOT_NODE_ID, + ] + ); + } + + /** + * Add a list of snippets under the active and inactive statuses. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * + * @return void + */ + private function add_snippet_listings( WP_Admin_Bar $wp_admin_bar ): void { + $items_per_page = $this->get_snippet_limit(); + if ( $items_per_page < 1 ) { + return; + } + + $plugin = code_snippets(); + + $snippets = array_filter( + get_snippets(), + static function ( Snippet $snippet ): bool { + return ! $snippet->trashed && ! $snippet->is_condition(); + } + ); + + $active_snippets = array_values( + array_filter( + $snippets, + static function ( Snippet $snippet ): bool { + return $snippet->active; + } + ) + ); + + $inactive_snippets = array_values( + array_filter( + $snippets, + static function ( Snippet $snippet ): bool { + return ! $snippet->active; + } + ) + ); + + usort( + $active_snippets, + static function ( Snippet $a, Snippet $b ): int { + return strcasecmp( $a->display_name, $b->display_name ); + } + ); + + usort( + $inactive_snippets, + static function ( Snippet $a, Snippet $b ): int { + return strcasecmp( $a->display_name, $b->display_name ); + } + ); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $active_page = isset( $_GET[ self::ACTIVE_PAGE_QUERY_ARG ] ) ? absint( $_GET[ self::ACTIVE_PAGE_QUERY_ARG ] ) : 1; + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $inactive_page = isset( $_GET[ self::INACTIVE_PAGE_QUERY_ARG ] ) ? absint( $_GET[ self::INACTIVE_PAGE_QUERY_ARG ] ) : 1; + + $active_total_pages = max( 1, (int) ceil( count( $active_snippets ) / $items_per_page ) ); + $inactive_total_pages = max( 1, (int) ceil( count( $inactive_snippets ) / $items_per_page ) ); + + $active_page = max( 1, min( $active_page, $active_total_pages ) ); + $inactive_page = max( 1, min( $inactive_page, $inactive_total_pages ) ); + + $active_offset = ( $active_page - 1 ) * $items_per_page; + $inactive_offset = ( $inactive_page - 1 ) * $items_per_page; + + $active_page_snippets = array_slice( $active_snippets, $active_offset, $items_per_page ); + $inactive_page_snippets = array_slice( $inactive_snippets, $inactive_offset, $items_per_page ); + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-active-snippets', + // translators: %d: number of active snippets. + 'title' => sprintf( esc_html__( 'Active snippets (%d)', 'code-snippets' ), count( $active_snippets ) ), + 'href' => esc_url( add_query_arg( 'status', 'active', $plugin->get_menu_url( 'manage' ) ) ), + 'parent' => self::ROOT_NODE_ID, + ] + ); + + if ( $active_total_pages > 1 ) { + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-active-pagination', + 'title' => $this->get_pagination_controls_html( 'active', $active_page, $active_total_pages, self::ACTIVE_PAGE_QUERY_ARG ), + 'parent' => self::ROOT_NODE_ID . '-active-snippets', + 'meta' => [ 'class' => 'code-snippets-pagination-node' ], + ] + ); + } + + foreach ( $active_page_snippets as $snippet ) { + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-snippet-' . $snippet->id, + 'title' => esc_html( $this->format_snippet_title( $snippet ) ), + 'href' => esc_url( add_query_arg( 'edit', $snippet->id, $plugin->get_menu_url( 'edit' ) ) ), + 'parent' => self::ROOT_NODE_ID . '-active-snippets', + 'meta' => [ 'class' => 'code-snippets-snippet-item' ], + ] + ); + } + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-inactive-snippets', + // translators: %d: number of inactive snippets. + 'title' => sprintf( esc_html__( 'Inactive snippets (%d)', 'code-snippets' ), count( $inactive_snippets ) ), + 'href' => esc_url( add_query_arg( 'status', 'inactive', $plugin->get_menu_url( 'manage' ) ) ), + 'parent' => self::ROOT_NODE_ID, + ] + ); + + if ( $inactive_total_pages > 1 ) { + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-inactive-pagination', + 'title' => $this->get_pagination_controls_html( 'inactive', $inactive_page, $inactive_total_pages, self::INACTIVE_PAGE_QUERY_ARG ), + 'parent' => self::ROOT_NODE_ID . '-inactive-snippets', + 'meta' => [ 'class' => 'code-snippets-pagination-node' ], + ] + ); + } + + foreach ( $inactive_page_snippets as $snippet ) { + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-snippet-' . $snippet->id, + 'title' => esc_html( $this->format_snippet_title( $snippet ) ), + 'href' => esc_url( add_query_arg( 'edit', $snippet->id, $plugin->get_menu_url( 'edit' ) ) ), + 'parent' => self::ROOT_NODE_ID . '-inactive-snippets', + 'meta' => [ 'class' => 'code-snippets-snippet-item' ], + ] + ); + } + } + + /** + * Build an admin bar snippet title including type prefix. + * + * @param Snippet $snippet Snippet object. + * + * @return string + */ + private function format_snippet_title( Snippet $snippet ): string { + return sprintf( '(%s) %s', strtoupper( $snippet->type ), $snippet->display_name ); + } + + /** + * Retrieve the number of snippets to show per page in the admin bar. + * + * @return int + */ + private function get_snippet_limit(): int { + $limit = (int) get_setting( 'general', 'admin_bar_snippet_limit' ); + + if ( $limit < 1 ) { + $limit = 20; + } + + return max( 1, (int) apply_filters( 'code_snippets/admin_bar/snippet_limit', $limit ) ); + } + + /** + * Build pagination controls HTML for a snippet listing submenu. + * + * @param string $status Snippet status: "active" or "inactive". + * @param int $page Current page. + * @param int $total_pages Total pages. + * @param string $page_query_arg Query arg used for progressive enhancement. + * + * @return string + */ + private function get_pagination_controls_html( string $status, int $page, int $total_pages, string $page_query_arg ): string { + $first_url = remove_query_arg( $page_query_arg ); + $prev_url = $page > 2 ? add_query_arg( $page_query_arg, $page - 1 ) : remove_query_arg( $page_query_arg ); + $next_url = add_query_arg( $page_query_arg, $page + 1 ); + $last_url = add_query_arg( $page_query_arg, $total_pages ); + + $disabled_first = $page <= 1 ? 'true' : 'false'; + $disabled_prev = $page <= 1 ? 'true' : 'false'; + $disabled_next = $page >= $total_pages ? 'true' : 'false'; + $disabled_last = $page >= $total_pages ? 'true' : 'false'; + + $html = sprintf( + '' . + '«' . + '‹ %6$s' . + '%7$s (%2$d/%3$d)' . + '%12$s ›' . + '»' . + '', + esc_attr( $status ), + $page, + $total_pages, + esc_url( $first_url ), + esc_url( $prev_url ), + esc_html__( 'Back', 'code-snippets' ), + esc_html__( 'Page', 'code-snippets' ), + esc_url( $next_url ), + $disabled_first, + $disabled_prev, + $disabled_next, + esc_html__( 'Next', 'code-snippets' ), + esc_url( $last_url ), + $disabled_last, + esc_attr( $page_query_arg ) + ); + + return wp_kses( + $html, + [ + 'span' => [ + 'class' => [], + 'data-status' => [], + 'data-page' => [], + 'data-total-pages' => [], + 'data-query-arg' => [], + ], + 'a' => [ + 'class' => [], + 'href' => [], + 'data-action' => [], + 'aria-disabled' => [], + ], + ] + ); + } +} diff --git a/src/php/migration/Export/Export.php b/src/php/Migration/Export/Export.php similarity index 100% rename from src/php/migration/Export/Export.php rename to src/php/Migration/Export/Export.php diff --git a/src/php/migration/Export/Export_Code.php b/src/php/Migration/Export/Export_Code.php similarity index 100% rename from src/php/migration/Export/Export_Code.php rename to src/php/Migration/Export/Export_Code.php diff --git a/src/php/migration/Export/Export_JSON.php b/src/php/Migration/Export/Export_JSON.php similarity index 100% rename from src/php/migration/Export/Export_JSON.php rename to src/php/Migration/Export/Export_JSON.php diff --git a/src/php/migration/Export/Import.php b/src/php/Migration/Export/Import.php similarity index 100% rename from src/php/migration/Export/Import.php rename to src/php/Migration/Export/Import.php diff --git a/src/php/Plugin.php b/src/php/Plugin.php index 8a5753915..c50c648a8 100644 --- a/src/php/Plugin.php +++ b/src/php/Plugin.php @@ -7,6 +7,7 @@ use Code_Snippets\Core\DB; use Code_Snippets\Core\Licensing; use Code_Snippets\Core\Upgrader; +use Code_Snippets\Integration\Admin_Bar; use Code_Snippets\Integration\Classic_Editor\MCE_Plugin; use Code_Snippets\Integration\Evaluate_Content; use Code_Snippets\Integration\Evaluate_Functions; @@ -135,6 +136,7 @@ public function load_plugin() { new Shortcodes(); new MCE_Plugin(); new Upgrader( PLUGIN_VERSION, $this->db ); + new Admin_Bar(); new Promotion_Manager(); $this->init_snippet_files(); diff --git a/src/php/REST_API/Snippets/Snippets_REST_Controller.php b/src/php/REST_API/Snippets/Snippets_REST_Controller.php index a2bb9f77d..d4cfdeb3a 100644 --- a/src/php/REST_API/Snippets/Snippets_REST_Controller.php +++ b/src/php/REST_API/Snippets/Snippets_REST_Controller.php @@ -53,6 +53,42 @@ public function register_routes() { // Allow standard collection parameters (page, per_page, etc.) on the collection route. $collection_args = array_merge( $network_args, $this->get_collection_params() ); + $collection_args['status'] = [ + 'description' => esc_html__( 'Filter snippets by activation status.', 'code-snippets' ), + 'type' => 'string', + 'enum' => [ 'all', 'active', 'inactive' ], + 'default' => 'all', + 'sanitize_callback' => 'sanitize_key', + ]; + + $collection_args['exclude_types'] = [ + 'description' => esc_html__( 'List of snippet types to exclude from the response.', 'code-snippets' ), + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + 'default' => [], + 'sanitize_callback' => static function ( $value ): array { + $values = is_array( $value ) ? $value : [ $value ]; + return array_values( array_filter( array_map( 'sanitize_key', $values ) ) ); + }, + ]; + + $collection_args['orderby'] = [ + 'description' => esc_html__( 'Sort collection by object attribute.', 'code-snippets' ), + 'type' => 'string', + 'enum' => [ 'id', 'name', 'display_name' ], + 'sanitize_callback' => 'sanitize_key', + ]; + + $collection_args['order'] = [ + 'description' => esc_html__( 'Sort direction.', 'code-snippets' ), + 'type' => 'string', + 'enum' => [ 'asc', 'desc' ], + 'default' => 'asc', + 'sanitize_callback' => 'sanitize_key', + ]; + register_rest_route( $this->namespace, $route, @@ -178,8 +214,60 @@ public function register_routes() { */ public function get_items( $request ): WP_REST_Response { $network = $request->get_param( 'network' ); - $all_snippets = get_snippets( [], $network ); - $all_snippets = $this->get_network_items( $all_snippets, $network ); + $all_snippets = $this->get_network_items( get_snippets( [], $network ), $network ); + + $status = sanitize_key( (string) $request->get_param( 'status' ) ); + + $exclude_types = $request->get_param( 'exclude_types' ); + $exclude_types = is_array( $exclude_types ) ? array_map( 'sanitize_key', $exclude_types ) : []; + + if ( $exclude_types || 'all' !== $status ) { + $all_snippets = array_filter( + $all_snippets, + static function ( Snippet $snippet ) use ( $exclude_types, $status ): bool { + if ( $exclude_types && in_array( $snippet->type, $exclude_types, true ) ) { + return false; + } + + if ( 'active' === $status ) { + return ! $snippet->trashed && $snippet->active; + } + + if ( 'inactive' === $status ) { + return ! $snippet->trashed && ! $snippet->active; + } + + return true; + } + ); + } + + $orderby = sanitize_key( (string) $request->get_param( 'orderby' ) ); + $order = sanitize_key( (string) $request->get_param( 'order' ) ); + + if ( $orderby ) { + $direction = 'desc' === $order ? -1 : 1; + + usort( + $all_snippets, + static function ( Snippet $a, Snippet $b ) use ( $orderby, $direction ): int { + switch ( $orderby ) { + case 'display_name': + $cmp = strcasecmp( $a->display_name, $b->display_name ); + break; + case 'name': + $cmp = strcasecmp( $a->name, $b->name ); + break; + case 'id': + default: + $cmp = $a->id <=> $b->id; + break; + } + + return 0 === $cmp ? ( $a->id <=> $b->id ) * $direction : $cmp * $direction; + } + ); + } $total_items = count( $all_snippets ); $query_params = $request->get_query_params(); @@ -189,6 +277,7 @@ public function get_items( $request ): WP_REST_Response { $per_page = isset( $query_params['per_page'] ) ? max( 1, (int) $query_params['per_page'] ) : (int) $collection_params['per_page']['default']; + $page_request = (int) $request->get_param( 'page' ); $page = max( 1, $page_request ? $page_request : (int) $collection_params['page']['default'] ); $total_pages = (int) ceil( $total_items / $per_page ); @@ -200,14 +289,16 @@ public function get_items( $request ): WP_REST_Response { $total_pages = 1; } - $snippets_data = []; - - foreach ( $snippets as $snippet ) { - $snippet_data = $this->prepare_item_for_response( $snippet, $request ); - $snippets_data[] = $this->prepare_response_for_collection( $snippet_data ); - } + $response = rest_ensure_response( + array_map( + function ( $snippet ) use ( $request ) { + $response_item = $this->prepare_item_for_response( $snippet, $request ); + return $this->prepare_response_for_collection( $response_item ); + }, + $snippets + ) + ); - $response = rest_ensure_response( $snippets_data ); $response->header( 'X-WP-Total', (string) $total_items ); $response->header( 'X-WP-TotalPages', (string) $total_pages ); diff --git a/src/php/Settings/Settings_Fields.php b/src/php/Settings/Settings_Fields.php index bb14499c0..b11b3e903 100644 --- a/src/php/Settings/Settings_Fields.php +++ b/src/php/Settings/Settings_Fields.php @@ -84,14 +84,17 @@ public static function get_field_definitions(): array { private function init_defaults() { $this->defaults = [ 'general' => [ - 'activate_by_default' => true, - 'enable_tags' => true, - 'enable_description' => true, - 'visual_editor_rows' => 5, - 'list_order' => 'priority-asc', - 'disable_prism' => false, - 'hide_upgrade_menu' => false, - 'complete_uninstall' => false, + 'activate_by_default' => true, + 'enable_tags' => true, + 'enable_description' => true, + 'visual_editor_rows' => 5, + 'list_order' => 'priority-asc', + 'disable_prism' => false, + 'hide_upgrade_menu' => false, + 'complete_uninstall' => false, + 'enable_flat_files' => false, + 'enable_admin_bar' => true, + 'admin_bar_snippet_limit' => 20, ], 'editor' => [ 'indent_with_tabs' => true, @@ -220,6 +223,26 @@ private function init_fields() { ]; } + $this->fields['general']['enable_admin_bar'] = [ + 'name' => __( 'Enable Admin Bar Menu', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Show a Snippets menu in the admin bar for quick access to snippets.', 'code-snippets' ), + ]; + + $this->fields['general']['admin_bar_snippet_limit'] = [ + 'name' => __( 'Admin Bar Snippets Per Page', 'code-snippets' ), + 'type' => 'number', + 'desc' => __( 'Number of snippets to show in the admin bar Active/Inactive menus before paginating.', 'code-snippets' ), + 'label' => __( 'snippets', 'code-snippets' ), + 'min' => 1, + 'max' => 100, + 'show_if' => [ + 'section' => 'general', + 'field' => 'enable_admin_bar', + 'value' => true, + ], + ]; + $this->fields['editor'] = [ 'indent_with_tabs' => [ 'name' => __( 'Indent With Tabs', 'code-snippets' ), diff --git a/src/php/Settings/settings.php b/src/php/Settings/settings.php index 350aa03e3..e0445f17c 100644 --- a/src/php/Settings/settings.php +++ b/src/php/Settings/settings.php @@ -125,6 +125,8 @@ function register_plugin_settings() { add_self_option( are_settings_unified(), OPTION_NAME, Settings_Fields::get_default_values() ); } + $current_settings = get_settings_values(); + // Register the setting. register_setting( OPTION_GROUP, @@ -145,6 +147,10 @@ function register_plugin_settings() { } foreach ( $fields as $field_id => $field ) { + if ( ! should_render_setting_field( $field, $current_settings ) ) { + continue; + } + $field_object = new Setting_Field( $section_id, $field_id, $field ); add_settings_field( $field_id, $field['name'], [ $field_object, 'render' ], 'code-snippets', $section_id ); } @@ -166,6 +172,56 @@ function register_plugin_settings() { add_action( 'admin_init', __NAMESPACE__ . '\\register_plugin_settings' ); +/** + * Determine whether a setting field should be rendered. + * + * @param array $field Field definition. + * @param array> $settings Current settings values. + * @param array>|null $input Optional raw input values. + * + * @return bool + */ +function should_render_setting_field( array $field, array $settings, ?array $input = null ): bool { + if ( empty( $field['show_if'] ) || ! is_array( $field['show_if'] ) ) { + return true; + } + + $show_if = array_merge( + [ + 'section' => '', + 'field' => '', + 'value' => true, + ], + $field['show_if'] + ); + + $section = is_string( $show_if['section'] ) ? $show_if['section'] : ''; + $field_id = is_string( $show_if['field'] ) ? $show_if['field'] : ''; + $expected = $show_if['value']; + + if ( '' === $section || '' === $field_id ) { + return true; + } + + $actual = null; + + if ( is_array( $input ) && isset( $input[ $section ] ) && is_array( $input[ $section ] ) && array_key_exists( $field_id, $input[ $section ] ) ) { + $actual = $input[ $section ][ $field_id ]; + } elseif ( isset( $settings[ $section ] ) && array_key_exists( $field_id, $settings[ $section ] ) ) { + $actual = $settings[ $section ][ $field_id ]; + } + + if ( is_bool( $expected ) ) { + if ( is_bool( $actual ) ) { + return $actual === $expected; + } + + return ( 'on' === $actual ) === $expected; + } + + return $actual === $expected; +} + /** * Sanitize a single setting value. * @@ -286,6 +342,9 @@ function sanitize_settings( array $input ): array { // Don't directly loop through $input as it does not include as deselected checkboxes. foreach ( Settings_Fields::get_field_definitions() as $section_id => $fields ) { foreach ( $fields as $field_id => $field ) { + if ( ! should_render_setting_field( $field, $settings, $input ) ) { + continue; + } // Fetch the corresponding input value from the posted data. $input_value = $input[ $section_id ][ $field_id ] ?? null; diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index c34d80daa..a44bdb59b 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -850,7 +850,11 @@ function update_snippet_fields( int $snippet_id, array $fields, ?bool $network = set_snippet_locked( $snippet->id, $locked_value, $network ); } - do_action( 'code_snippets/update_snippet', $snippet->id, $table ); + $updated = get_snippet( $snippet->id, $network ); + if ( $updated && $updated->id ) { + do_action( 'code_snippets/update_snippet', $updated, $table ); + } + clean_snippets_cache( $table ); } diff --git a/tests/README.md b/tests/README.md index 397e2d7b9..52135b6a6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,23 +2,29 @@ ## Quick Start -### 1. Install WordPress Test Suite +### 1. Install WordPress Test Suite (recommended) -Run the install script with your database credentials: +Run the setup script (downloads WordPress + the WP test suite into the repo, and creates the test DB if needed): ```bash -bash tests/install-wp-tests.sh wordpress_test root password localhost latest +npm run test:setup:php ``` -**For Local by Flywheel users**, your database credentials are typically: -- **DB Name**: Choose any name like `wordpress_test` or `wp_phpunit_test` +Defaults used by `test:setup:php`: +- **DB Name**: `code_snippets_phpunit` - **DB User**: `root` -- **DB Password**: `root` -- **DB Host**: `localhost` +- **DB Password**: *(empty)* +- **DB Host**: `127.0.0.1` +- **WP Version**: `latest` -Example for Local: +Override defaults via env vars (example): ```bash -bash tests/install-wp-tests.sh wp_phpunit_test root root localhost latest +WP_PHPUNIT_DB_NAME=wp_phpunit_test \ +WP_PHPUNIT_DB_USER=root \ +WP_PHPUNIT_DB_PASS=root \ +WP_PHPUNIT_DB_HOST=127.0.0.1 \ +WP_PHPUNIT_WP_VERSION=latest \ +npm run test:setup:php ``` ### 2. Run Tests @@ -33,23 +39,23 @@ Run tests with detailed output: npm run test:php:watch ``` -Or run PHPUnit directly from the src directory: +Or run PHPUnit directly: ```bash -cd src && ../vendor/bin/phpunit -c ../phpunit.xml +WP_TESTS_DIR=./.wp-tests-lib WP_DEVELOP_DIR=./.wp-core src/vendor/bin/phpunit -c phpunit.xml ``` ## What Gets Installed -The `install-wp-tests.sh` script will: -1. Download WordPress core to `/tmp/wordpress/` -2. Download the WordPress test library to `/tmp/wordpress-tests-lib/` +The `test:setup:php` script will: +1. Download WordPress core to `./.wp-core/` +2. Download the WordPress test library to `./.wp-tests-lib/` 3. Create a test database (if it doesn't exist) -4. Configure the test environment +4. Generate `./.wp-tests-lib/wp-tests-config.php` ## Troubleshooting ### "Could not find includes/functions.php" -Run the install script to download the WordPress test suite. +Run `npm run test:setup:php` to download the WordPress test suite. ### Database connection errors Verify your database credentials and that MySQL is running. @@ -60,9 +66,12 @@ Make sure the install script is executable: chmod +x tests/install-wp-tests.sh ``` +### Missing `svn` +The WordPress test suite download uses `svn export`. Install Subversion if you don't already have it. + ## Writing Tests -Tests should be placed in the `tests/` directory with the naming pattern `test-*.php`. +Tests should be placed in `tests/phpunit/` with the naming pattern `test-*.php`. Example test: ```php @@ -70,10 +79,88 @@ Example test: namespace Code_Snippets\Tests; class My_Test extends TestCase { - + public function test_something() { $this->assertTrue( true ); } } ``` +### Guidelines / caveats +- Keep tests isolated: create your own fixtures and clean up after each test where possible. +- Prefer plugin APIs (`save_snippet`, `delete_snippet`, etc.) over direct SQL so behavior matches runtime (and keeps flat-file mode in sync). +- Avoid depending on UI strings/markup in PHPUnit tests—assert on behavior, data, and registered WP objects (e.g. `WP_Admin_Bar` nodes). + +--- + +# Playwright E2E Testing + +## Setup + +Prereqs: +- Docker (required for `wp-env`) +- Node.js/npm + +Install JS deps: +```bash +npm ci +``` + +Install Playwright browsers (once): +```bash +npx playwright install +``` + +Start the WordPress environment: +```bash +npm run wp-env:start +``` + +Optional (recommended when switching branches / after failures): reset the WP env: +```bash +npm run wp-env:clean +npm run wp-env:start +``` + +Prepare the environment for E2E (cleans stale flat-file artifacts, ensures plugin active, etc.): +```bash +npm run test:setup:playwright +``` + +## Run tests + +Run everything: +```bash +npm run test:playwright +``` + +Run a single project: +```bash +npm run test:playwright -- --project=chromium-db-snippets +``` + +Run the file-based snippets project (includes flat-file setup): +```bash +npm run test:playwright -- --project=chromium-file-based-snippets +``` + +Run with HTML reporter but don’t auto-open the report: +```bash +PW_TEST_HTML_REPORT_OPEN=never npm run test:playwright +``` + +## Debugging failures + +- Traces are saved under `test-results/` on failures. View one with: +```bash +npx playwright show-trace test-results/**/trace.zip +``` + +## Writing Playwright tests + +Guidelines / caveats: +- Prefer resilient locators (`getByRole`, `getByLabel`, stable ids) over fragile CSS selectors. +- Use `wpCli()` for setup/fixtures when possible (fast + deterministic). +- Always clean up created snippets/pages (prefer the helper methods so file-based mode stays in sync). +- Avoid leaking global state between tests (e.g. Safe Mode, mu-plugins, settings toggles). +- Keep per-test timeouts explicit only when needed (and use constants rather than magic numbers). diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4158f6483..f00d960f5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -6,16 +6,30 @@ $_tests_dir = false; -if ( getenv( 'WP_TESTS_DIR' ) ) { - $_tests_dir = getenv( 'WP_TESTS_DIR' ); -} elseif ( getenv( 'WP_DEVELOP_DIR' ) ) { - $_tests_dir = getenv( 'WP_DEVELOP_DIR' ) . '/tests/phpunit'; -} elseif ( getenv( 'WP_PHPUNIT__DIR' ) ) { - $_tests_dir = getenv( 'WP_PHPUNIT__DIR' ); -} elseif ( file_exists( '/tmp/wordpress-tests-lib/includes/functions.php' ) ) { - $_tests_dir = '/tmp/wordpress-tests-lib'; -} else { - $_tests_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/wordpress-tests-lib'; +switch ( true ) { + case (bool) getenv( 'WP_TESTS_DIR' ): + $_tests_dir = getenv( 'WP_TESTS_DIR' ); + break; + + case (bool) getenv( 'WP_DEVELOP_DIR' ): + $_tests_dir = getenv( 'WP_DEVELOP_DIR' ) . '/tests/phpunit'; + break; + + case (bool) getenv( 'WP_PHPUNIT__DIR' ): + $_tests_dir = getenv( 'WP_PHPUNIT__DIR' ); + break; + + case file_exists( dirname( __DIR__ ) . '/.wp-tests-lib/includes/functions.php' ): + $_tests_dir = dirname( __DIR__ ) . '/.wp-tests-lib'; + break; + + case file_exists( '/tmp/wordpress-tests-lib/includes/functions.php' ): + $_tests_dir = '/tmp/wordpress-tests-lib'; + break; + + default: + $_tests_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/wordpress-tests-lib'; + break; } if ( ! file_exists( $_tests_dir . '/includes/functions.php' ) ) { @@ -24,9 +38,12 @@ require_once $_tests_dir . '/includes/functions.php'; -tests_add_filter( 'muplugins_loaded', function() { - require dirname( __DIR__ ) . '/src/code-snippets.php'; -} ); +tests_add_filter( + 'muplugins_loaded', + function () { + require dirname( __DIR__ ) . '/src/code-snippets.php'; + } +); require $_tests_dir . '/includes/bootstrap.php'; diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts index 63d56d1b7..50e9fd7bd 100644 --- a/tests/e2e/auth.setup.ts +++ b/tests/e2e/auth.setup.ts @@ -1,16 +1,49 @@ import { join } from 'path' import { expect, test as setup } from '@playwright/test' +import { wpCli } from './helpers/wpCli' const authFile = join(__dirname, '.auth/user.json') +const AUTH_SETUP_TIMEOUT_MS = 120000 setup('authenticate', async ({ page }) => { + setup.setTimeout(AUTH_SETUP_TIMEOUT_MS) + + // Ensure a clean environment across local runs / retries. + // If Safe Mode is enabled via `wp-config.php` it disables snippet execution and can + // break unrelated tests (e.g., those expecting snippets to run). + try { + await wpCli(['config', 'delete', 'CODE_SNIPPETS_SAFE_MODE']) + } catch { + // Ignore if the constant isn't present. + } + + // CI sometimes boots with WordPress already installed (so the workflow's + // `wp core install --admin_password=...` step is skipped). Ensure the admin + // credentials are set to the expected values before logging in via UI. + try { + await wpCli(['user', 'update', 'admin', '--user_pass=password']) + } catch { + // If the user doesn't exist, create it (local/wp-env + CI both support this). + await wpCli([ + 'user', + 'create', + 'admin', + 'admin@example.org', + '--user_pass=password', + '--role=administrator' + ]) + } + await page.goto('/wp-login.php') await page.waitForSelector('#user_login') await page.fill('#user_login', 'admin') await page.fill('#user_pass', 'password') - await page.click('#wp-submit') + await Promise.all([ + page.waitForLoadState('domcontentloaded'), + page.click('#wp-submit') + ]) // If WordPress shows the DB upgrade interstitial it includes a link to // `upgrade.php`. In that case navigate back to `/wp-admin` (the upgrade diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts index de1204b2c..d8dc103b8 100644 --- a/tests/e2e/code-snippets-edit.spec.ts +++ b/tests/e2e/code-snippets-edit.spec.ts @@ -1,8 +1,6 @@ import { test } from '@playwright/test' import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' -import { MESSAGES } from './helpers/constants' - -const TEST_SNIPPET_NAME = 'E2E Test Snippet' +import { MESSAGES, SELECTORS } from './helpers/constants' test.describe('Code Snippets Admin', () => { let helper: SnippetsTestHelper @@ -17,25 +15,41 @@ test.describe('Code Snippets Admin', () => { }) test('Can add a new snippet', async () => { + const snippetName = SnippetsTestHelper.makeUniqueSnippetName() await helper.createSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: "add_filter('show_admin_bar', '__return_false');" }) }) test('Can activate and deactivate a snippet', async () => { - await helper.openSnippet(TEST_SNIPPET_NAME) + const snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await helper.createSnippet({ + name: snippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + + await helper.openSnippet(snippetName) + // Activate it. await helper.saveSnippet('save_and_activate') - await helper.expectSuccessMessageInParagraph(MESSAGES.SNIPPET_UPDATED_AND_ACTIVATED) + await helper.expectSuccessMessage(MESSAGES.SNIPPET_UPDATED_AND_ACTIVATED) + // Deactivate it (Status toggle + save in the new UI). await helper.saveSnippet('save_and_deactivate') - await helper.expectSuccessMessageInParagraph(MESSAGES.SNIPPET_UPDATED_AND_DEACTIVATED) + await helper.expectSuccessMessage(MESSAGES.SNIPPET_UPDATED_AND_DEACTIVATED) }) test('Can delete a snippet', async () => { - await helper.openSnippet(TEST_SNIPPET_NAME) + const snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await helper.createSnippet({ + name: snippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + + await helper.openSnippet(snippetName) await helper.deleteSnippet() - await helper.expectTextNotVisible(TEST_SNIPPET_NAME) + await helper.deleteSnippetFromList(snippetName) + await helper.expectElementCount(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`, 0) }) }) diff --git a/tests/e2e/code-snippets-evaluation.spec.ts b/tests/e2e/code-snippets-evaluation.spec.ts index 3bccb5884..dc7f4b63d 100644 --- a/tests/e2e/code-snippets-evaluation.spec.ts +++ b/tests/e2e/code-snippets-evaluation.spec.ts @@ -1,10 +1,10 @@ import { expect, test } from '@playwright/test' -import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { DEFAULT_E2E_SNIPPET_BASE_NAME, SnippetsTestHelper } from './helpers/SnippetsTestHelper' import { SELECTORS } from './helpers/constants' import { wpCli } from './helpers/wpCli' import type { Page } from '@playwright/test' -const TEST_SNIPPET_NAME = 'E2E Snippet Test' +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const BODY_CLASS_TEST_CODE = ` add_filter('admin_body_class', function($classes) { @@ -32,8 +32,8 @@ const verifyShortcodeRendersCorrectly = async ( await helper.expectTextVisible('Page content after shortcode.') } -const createPageWithShortcode = async (snippetId: string): Promise => { - const shortcode = `[code_snippet id=${snippetId} format name="${TEST_SNIPPET_NAME}"]` +const createPageWithShortcode = async (page: Page, snippetId: string, snippetName: string): Promise => { + const shortcode = `[code_snippet id=${snippetId} format name="${snippetName}"]` const pageContent = `

Page content before shortcode.

\n\n${shortcode}\n\n

Page content after shortcode.

` try { @@ -50,48 +50,78 @@ const createPageWithShortcode = async (snippetId: string): Promise => { const pageUrl = (await wpCli(['post', 'url', pageId])).trim() return pageUrl } catch (error) { - console.error('Failed to create page via WP-CLI:', error) + console.error('Failed to create page via WP-CLI.', error) + // The suite depends on WP-CLI in local/wp-env mode; keep failures explicit to avoid + // silently exercising a different creation path. throw error } } -const createHtmlSnippetForEditor = async (helper: SnippetsTestHelper, page: Page): Promise => { +const createHtmlSnippetForEditor = async ( + helper: SnippetsTestHelper, + page: Page, + snippetName: string +): Promise => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: '
' + '

Custom HTML Content

This content was inserted via shortcode!

', type: 'HTML', location: 'IN_EDITOR' }) - const currentUrl = page.url() - const urlMatch = /[?&]id=(?\d+)/.exec(currentUrl) + // `createAndActivateSnippet` ends on the list screen; pull the ID from the edit link. + await helper.navigateToSnippetsAdmin() + const row = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + await expect(row).toBeVisible() + + const nameLink = row.getByRole('link', { name: new RegExp(escapeRegExp(snippetName)) }).first() + const editHref = await nameLink.evaluate(el => el.getAttribute('href') ?? '') + + const urlMatch = /[?&]id=(?\d+)/.exec(editHref) expect(urlMatch).toBeTruthy() return urlMatch?.groups?.id ?? '0' } test.describe('Code Snippets Evaluation', () => { let helper: SnippetsTestHelper + let snippetName: string test.beforeEach(async ({ page }) => { helper = new SnippetsTestHelper(page) + snippetName = SnippetsTestHelper.makeUniqueSnippetName() await helper.navigateToSnippetsAdmin() + + // Ensure isolation: file-based execution runs from flat files, so we must delete old snippets + // via plugin operations (to keep flat files in sync), not via direct SQL. + await wpCli([ + 'eval', + ` + global $wpdb; + $table = $wpdb->prefix . 'snippets'; + $ids = $wpdb->get_col( + $wpdb->prepare( "SELECT id FROM {$table} WHERE name LIKE %s", "${DEFAULT_E2E_SNIPPET_BASE_NAME}%" ) + ); + foreach ( $ids as $id ) { \\Code_Snippets\\delete_snippet( intval( $id ), false ); } + ` + ]) }) test('PHP snippet is evaluating correctly', async () => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: "add_filter('show_admin_bar', '__return_false');" }) await helper.navigateToFrontend() - await helper.expectElementNotVisible(SELECTORS.ADMIN_BAR) await helper.expectElementCount(SELECTORS.ADMIN_BAR, 0) }) test('PHP Snippet runs everywhere', async ({ page }) => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, location: 'EVERYWHERE', code: BODY_CLASS_TEST_CODE }) @@ -105,7 +135,7 @@ test.describe('Code Snippets Evaluation', () => { test('PHP Snippet runs only in Admin', async ({ page }) => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, location: 'ADMIN_ONLY', code: BODY_CLASS_TEST_CODE }) @@ -119,7 +149,7 @@ test.describe('Code Snippets Evaluation', () => { test('PHP Snippet runs only in Frontend', async ({ page }) => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, location: 'FRONTEND_ONLY', code: BODY_CLASS_TEST_CODE }) @@ -133,7 +163,7 @@ test.describe('Code Snippets Evaluation', () => { test('HTML snippet is evaluating correctly in footer', async () => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: '

Hello World HTML snippet in footer!

', type: 'HTML', location: 'SITE_FOOTER' @@ -146,7 +176,7 @@ test.describe('Code Snippets Evaluation', () => { test('HTML snippet is evaluating correctly in header', async () => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: '

Hello World HTML snippet in header!

', type: 'HTML', location: 'SITE_HEADER' @@ -158,13 +188,13 @@ test.describe('Code Snippets Evaluation', () => { }) test('HTML snippet works with shortcode in editor', async ({ page }) => { - const snippetId = await createHtmlSnippetForEditor(helper, page) - const pageUrl = await createPageWithShortcode(snippetId) + const snippetId = await createHtmlSnippetForEditor(helper, page, snippetName) + const pageUrl = await createPageWithShortcode(page, snippetId, snippetName) await verifyShortcodeRendersCorrectly(helper, page, pageUrl) }) test.afterEach(async () => { - await helper.cleanupSnippet(TEST_SNIPPET_NAME) + await helper.cleanupSnippet(snippetName) }) }) diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index 07ecedf54..d5eb1a8ac 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -2,100 +2,134 @@ import { expect, test } from '@playwright/test' import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' import { SELECTORS } from './helpers/constants' -const TEST_SNIPPET_NAME = 'E2E List Test Snippet' - test.describe('Code Snippets List Page Actions', () => { let helper: SnippetsTestHelper + let snippetName: string + const EXPORT_TEST_TIMEOUT_MS = 60000 test.beforeEach(async ({ page }) => { helper = new SnippetsTestHelper(page) + snippetName = SnippetsTestHelper.makeUniqueSnippetName() await helper.navigateToSnippetsAdmin() await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: "add_filter('show_admin_bar', '__return_false');" }) await helper.navigateToSnippetsAdmin() }) test.afterEach(async () => { - await helper.cleanupSnippet(TEST_SNIPPET_NAME) + await helper.cleanupSnippet(snippetName) }) test('Can toggle snippet activation from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const toggleSwitch = snippetRow.locator('a.snippet-activation-switch') - - await expect(toggleSwitch).toHaveAttribute('title', 'Deactivate') - - await toggleSwitch.click() - await page.waitForLoadState('networkidle') - - const updatedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const updatedToggle = updatedRow.locator('a.snippet-activation-switch') - await expect(updatedToggle).toHaveAttribute('title', 'Activate') - - await updatedToggle.click() - await page.waitForLoadState('networkidle') - - const reactivatedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const reactivatedToggle = reactivatedRow.locator('a.snippet-activation-switch') - await expect(reactivatedToggle).toHaveAttribute('title', 'Deactivate') + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + + const toggleCell = snippetRow.locator('td').first() + const toggleCheckbox = toggleCell.getByRole('checkbox').first() + + const initialChecked = await toggleCheckbox.isChecked() + await expect(toggleCell).toContainText(initialChecked ? 'Deactivate' : 'Activate') + + await toggleCheckbox.click({ force: true }) + if (initialChecked) { + await expect(toggleCheckbox).not.toBeChecked() + } else { + await expect(toggleCheckbox).toBeChecked() + } + await expect(toggleCell).toContainText(!initialChecked ? 'Deactivate' : 'Activate') + + await toggleCheckbox.click({ force: true }) + if (initialChecked) { + await expect(toggleCheckbox).toBeChecked() + } else { + await expect(toggleCheckbox).not.toBeChecked() + } + await expect(toggleCell).toContainText(initialChecked ? 'Deactivate' : 'Activate') }) test('Can access edit from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() - await snippetRow.locator(SELECTORS.EDIT_ACTION).click() + await snippetRow.locator(SELECTORS.SNIPPET_NAME_LINK).first().click() await expect(page).toHaveURL(/page=edit-snippet/) - await expect(page.locator('#title')).toHaveValue(TEST_SNIPPET_NAME) + await expect(page.locator('#title')).toHaveValue(snippetName) }) test('Can clone snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() await snippetRow.locator(SELECTORS.CLONE_ACTION).click() - await page.waitForLoadState('networkidle') await expect(page).toHaveURL(/page=snippets/) + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() - await helper.expectTextVisible(`${TEST_SNIPPET_NAME} [CLONE]`) - - const clonedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME} [CLONE]")`) - - page.on('dialog', async dialog => { - expect(dialog.type()).toBe('confirm') - await dialog.accept() - }) + // Verify that a cloned snippet exists in the table (use table-scoped check to avoid admin bar matches) + const clonedRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName} [CLONE]"))`) + .first() + await expect(clonedRow).toBeVisible() + // Clean up the clone by trashing it await clonedRow.locator(SELECTORS.DELETE_ACTION).click() - await page.waitForLoadState('networkidle') + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() }) test('Can delete snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - - page.on('dialog', async dialog => { - expect(dialog.type()).toBe('confirm') - await dialog.accept() - }) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + // Click "Trash" in row actions — in the new React UI, this moves to trash immediately (no dialog) await snippetRow.locator(SELECTORS.DELETE_ACTION).click() - await page.waitForLoadState('networkidle') + + // Some implementations show a confirmation modal that must be dismissed. + const confirmDialog = page.locator('[role="dialog"]').filter({ hasText: /Are you sure\\?/i }) + const dialogVisible = await confirmDialog + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false) + + if (dialogVisible) { + await confirmDialog.locator('button:has-text("Trash"), button:has-text("Delete")').first().click() + await confirmDialog.waitFor({ state: 'hidden', timeout: 30000 }).catch(() => undefined) + } await expect(page).toHaveURL(/page=snippets/) - await helper.expectElementCount(`tr:has-text("${TEST_SNIPPET_NAME}")`, 0) + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() + + // Navigate to the trash view using the new filter link format + const trashedLink = page.locator('a[href*="status=trashed"]').first() + await expect(trashedLink).toBeVisible() + await trashedLink.click() + + await expect(page).toHaveURL(/status=trashed/, { timeout: 30000 }) + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() + + const trashedRow = page.locator(`${SELECTORS.SNIPPET_ROW}:has-text("${snippetName}")`).first() + await expect(trashedRow).toBeVisible({ timeout: 30000 }) + await expect(trashedRow).toContainText(/Restore/i) }) test('Can export snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - - const downloadPromise = page.waitForEvent('download') + test.setTimeout(EXPORT_TEST_TIMEOUT_MS) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() - await snippetRow.locator(SELECTORS.EXPORT_ACTION).click() + const download = await Promise.all([ + page.waitForEvent('download'), + snippetRow.locator(SELECTORS.EXPORT_ACTION).click() + ]).then(([downloadEvent]) => downloadEvent) - const download = await downloadPromise expect(download.suggestedFilename()).toMatch(/\.json$/) }) }) diff --git a/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts b/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts new file mode 100644 index 000000000..366f979d0 --- /dev/null +++ b/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts @@ -0,0 +1,239 @@ +import { expect, test } from '@playwright/test' +import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { wpCli } from './helpers/wpCli' +import type { Page } from '@playwright/test' + +const QUICKNAV_PREFIX = 'E2E QuickNav' +const QUICKNAV_PER_PAGE = 2 +const QUICKNAV_TEST_TIMEOUT_MS = 180000 + +test.describe('Admin Bar Snippets QuickNav', () => { + let activeA: string + let activeB: string + let activeC: string + let inactiveB: string + let inactiveC: string + let inactiveA: string + + test.beforeAll(async () => { + await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: true, perPage: QUICKNAV_PER_PAGE }) + await SnippetsTestHelper.cleanupSnippetsByPrefix(QUICKNAV_PREFIX) + + activeA = `${QUICKNAV_PREFIX} Active A` + activeB = `${QUICKNAV_PREFIX} Active B` + activeC = `${QUICKNAV_PREFIX} Active C` + inactiveA = `${QUICKNAV_PREFIX} Inactive A` + inactiveB = `${QUICKNAV_PREFIX} Inactive B` + inactiveC = `${QUICKNAV_PREFIX} Inactive Z HTML` + + await SnippetsTestHelper.createSnippetViaCli({ name: activeA, active: true, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: activeB, active: true, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: activeC, active: true, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: inactiveA, active: false, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: inactiveB, active: false, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: inactiveC, active: false, type: 'html' }) + }) + + test.afterAll(async () => { + await SnippetsTestHelper.cleanupSnippetsByPrefix(QUICKNAV_PREFIX) + await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: true, perPage: QUICKNAV_PER_PAGE }) + }) + + const openListing = async (page: Page, query: string) => { + await page.goto(`/wp-admin/admin.php?page=snippets${query}`) + + const root = page.locator('#wp-admin-bar-code-snippets') + await expect(root).toBeVisible() + await root.hover() + } + + const getTotalPagesForListing = async (page: Page, status: 'active' | 'inactive') => { + const node = page.locator(`#wp-admin-bar-code-snippets-${status}-snippets`) + await node.hover() + + const controls = node.locator(`.code-snippets-pagination-controls[data-status="${status}"]`).first() + const totalPagesAttr = await controls.getAttribute('data-total-pages').catch(() => null) + const parsed = totalPagesAttr ? Number(totalPagesAttr) : NaN + return Number.isFinite(parsed) && 0 < parsed ? parsed : 1 + } + + const expectSnippetVisibleInListingPages = async ( + page: Page, + options: { status: 'active' | 'inactive'; queryArg: string; snippetName: string } + ) => { + await openListing(page, '') + + const totalPages = await getTotalPagesForListing(page, options.status) + + for (let pageNo = 1; pageNo <= totalPages; pageNo++) { + await openListing(page, `&${options.queryArg}=${pageNo}`) + + const node = page.locator(`#wp-admin-bar-code-snippets-${options.status}-snippets`) + await node.hover() + + const items = node.locator('li.code-snippets-snippet-item a') + await items.first().waitFor({ state: 'visible', timeout: 5000 }).catch(() => null) + + const match = items.filter({ hasText: options.snippetName }).first() + if (await match.isVisible().catch(() => false)) { + await expect(match).toBeVisible({ timeout: 30000 }) + return + } + } + + throw new Error(`Snippet not found in ${options.status} listing after checking ${totalPages} page(s): ${options.snippetName}`) + } + + test('Menu structure, gating, and pagination work', async ({ page }) => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + + const helper = new SnippetsTestHelper(page) + await helper.navigateToSnippetsAdmin() + + const root = page.locator('#wp-admin-bar-code-snippets') + await expect(root).toBeVisible() + await root.hover() + + await expect(page.locator('#wp-admin-bar-code-snippets-manage')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-add')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-import')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-settings')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-active-snippets')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-inactive-snippets')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-safe-mode-doc')).toBeVisible() + + const safeModeDocLink = page.locator('#wp-admin-bar-code-snippets-safe-mode-doc a').first() + await expect(safeModeDocLink).toHaveAttribute('href', 'https://snipco.de/safe-mode') + await expect(safeModeDocLink).toHaveAttribute('target', '_blank') + + // Free vs Pro gating: CSS/JS/COND are disabled and lead to upgrade. + const cssNode = page.locator('#wp-admin-bar-code-snippets-add-css') + await expect(cssNode).toHaveClass(/code-snippets-disabled/) + await expect(cssNode.locator('a')).toHaveAttribute('href', /page=code_snippets_upgrade/) + + const jsNode = page.locator('#wp-admin-bar-code-snippets-add-js') + await expect(jsNode).toHaveClass(/code-snippets-disabled/) + await expect(jsNode.locator('a')).toHaveAttribute('href', /page=code_snippets_upgrade/) + + const condNode = page.locator('#wp-admin-bar-code-snippets-add-cond') + await expect(condNode).toHaveClass(/code-snippets-disabled/) + await expect(condNode.locator('a')).toHaveAttribute('href', /page=code_snippets_upgrade/) + + // Pagination: perPage=2 and we created 3 active snippets. + const activeNode = page.locator('#wp-admin-bar-code-snippets-active-snippets') + await activeNode.hover() + + const activeControls = activeNode.locator('.code-snippets-pagination-controls[data-status="active"]') + await expect(activeControls).toBeVisible() + + const activeItems = activeNode.locator('li.code-snippets-snippet-item a') + await expect(activeItems.filter({ hasText: activeA })).toBeVisible() + await expect(activeItems.filter({ hasText: activeB })).toBeVisible() + await expect(activeItems.filter({ hasText: activeC })).not.toBeVisible() + + await expectSnippetVisibleInListingPages(page, { status: 'active', queryArg: 'code_snippets_ab_active_page', snippetName: activeC }) + + // Ensure titles are type-prefixed. + await expect(activeItems.filter({ hasText: activeC })).toContainText('(PHP)') + + // Inactive list exists and includes our inactive snippet. + const inactiveNode = page.locator('#wp-admin-bar-code-snippets-inactive-snippets') + await inactiveNode.hover() + const inactiveControls = inactiveNode.locator('.code-snippets-pagination-controls[data-status="inactive"]') + await expect(inactiveControls).toBeVisible() + + const inactiveItems = inactiveNode.locator('li.code-snippets-snippet-item a') + await expect(inactiveItems.first()).toBeVisible({ timeout: 30000 }) + + await expectSnippetVisibleInListingPages(page, { + status: 'inactive', + queryArg: 'code_snippets_ab_inactive_page', + snippetName: inactiveA + }) + await expectSnippetVisibleInListingPages(page, { + status: 'inactive', + queryArg: 'code_snippets_ab_inactive_page', + snippetName: inactiveC + }) + const inactiveCLink = page + .locator('#wp-admin-bar-code-snippets-inactive-snippets li.code-snippets-snippet-item a') + .filter({ hasText: inactiveC }) + .first() + await expect(inactiveCLink).toContainText('(HTML)') + }) + + test('Manage submenu contains status quick links', async ({ page }) => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + + const helper = new SnippetsTestHelper(page) + await helper.navigateToSnippetsAdmin() + + const root = page.locator('#wp-admin-bar-code-snippets') + await expect(root).toBeVisible() + await root.hover() + + const manageNode = page.locator('#wp-admin-bar-code-snippets-manage') + await manageNode.hover() + + await expect(page.locator('#wp-admin-bar-code-snippets-status-all a')).toHaveAttribute('href', /page=snippets&status=all/) + await expect(page.locator('#wp-admin-bar-code-snippets-status-active a')).toHaveAttribute('href', /page=snippets&status=active/) + await expect(page.locator('#wp-admin-bar-code-snippets-status-inactive a')).toHaveAttribute('href', /page=snippets&status=inactive/) + }) + + test('QuickNav menu can be disabled via setting', async ({ page }) => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + + await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: false, perPage: QUICKNAV_PER_PAGE }) + + const helper = new SnippetsTestHelper(page) + await helper.navigateToSnippetsAdmin() + + await expect(page.locator('#wp-admin-bar-code-snippets')).toHaveCount(0) + + await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: true, perPage: QUICKNAV_PER_PAGE }) + await helper.navigateToSnippetsAdmin() + await expect(page.locator('#wp-admin-bar-code-snippets')).toBeVisible() + }) + + test('Safe Mode indicator appears only when Safe Mode is active', async ({ page }) => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + const safeModeMuPluginPath = 'wp-content/mu-plugins/code-snippets-e2e-safe-mode.php' + + const removeMuPlugin = async () => { + await wpCli(['eval', `@unlink( ABSPATH . ${JSON.stringify(safeModeMuPluginPath)} );`]) + } + + await removeMuPlugin() + + await page.goto('/wp-admin/admin.php?page=snippets') + await expect(page.locator('#wp-admin-bar-code-snippets-safe-mode')).toHaveCount(0) + + try { + // Enable safe mode via a temporary mu-plugin so we don't rely on mutating wp-config.php. + await wpCli([ + 'eval', + ` + $path = ABSPATH . ${JSON.stringify(safeModeMuPluginPath)}; + wp_mkdir_p( dirname( $path ) ); + file_put_contents( + $path, + " { await page.goto(`${wpAdminbase}/admin.php?page=snippets-settings`) await page.waitForSelector('#wpbody-content') - await page.waitForSelector('form') + // Await page.waitForSelector('form') const flatFilesCheckbox = page.locator('input[name="code_snippets_settings[general][enable_flat_files]"]') await expect(flatFilesCheckbox).toBeVisible() @@ -17,10 +17,17 @@ setup('enable flat files', async ({ page }) => { await flatFilesCheckbox.check() } - await page.click('input[type="submit"][name="submit"]') + // Await page.click('input[type="submit"][name="submit"]') - await page.waitForSelector('.notice-success', { timeout: 10000 }) - await expect(page.locator('.notice-success')).toContainText('Settings saved') + // await page.waitForSelector('.notice-success', { timeout: 10000 }) + // await expect(page.locator('.notice-success')).toContainText('Settings saved') + const saveButton = page.getByRole('button', { name: 'Save Changes' }) + + await Promise.all([ + page.waitForURL(/settings-updated=true/, { timeout: 10000 }), + saveButton.click() + ]) + await page.reload() await page.waitForSelector('input[name="code_snippets_settings[general][enable_flat_files]"]') diff --git a/tests/e2e/helpers/SnippetsTestHelper.ts b/tests/e2e/helpers/SnippetsTestHelper.ts index d67b5e18c..daca19522 100644 --- a/tests/e2e/helpers/SnippetsTestHelper.ts +++ b/tests/e2e/helpers/SnippetsTestHelper.ts @@ -1,14 +1,32 @@ import { expect } from '@playwright/test' -import { - BUTTONS, - MESSAGES, - SELECTORS, - SNIPPET_LOCATIONS, - SNIPPET_TYPES, - TIMEOUTS, - URLS +import { + BUTTONS, + MESSAGES, + SELECTORS, + SNIPPET_LOCATIONS, + SNIPPET_TYPES, + TIMEOUTS, + URLS } from './constants' -import type { Page} from '@playwright/test' +import { wpCli } from './wpCli' +import type { Page } from '@playwright/test' + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +const META_OR_CONTROL_A = 'darwin' === process.platform ? 'Meta+A' : 'Control+A' + +const RANDOM_RADIX = 36 +const RANDOM_SLICE_START = 2 +const RANDOM_SLICE_END = 7 +const CLICK_RETRIES = 3 +const AT_LEAST_ONE = 1 + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message + } + return String(error) +} export interface SnippetFormOptions { name: string; @@ -17,16 +35,179 @@ export interface SnippetFormOptions { location?: keyof typeof SNIPPET_LOCATIONS; } +export interface CreateSnippetCliOptions { + name: string; + active: boolean; + type?: 'php' | 'html' | 'css' | 'js' | 'cond'; +} + +export const DEFAULT_E2E_SNIPPET_BASE_NAME = 'E2E Snippet Test' + export class SnippetsTestHelper { - constructor(private page: Page) {} + constructor(private page: Page) { } + + static makeUniqueSnippetName(baseName: string = DEFAULT_E2E_SNIPPET_BASE_NAME): string { + return `${baseName} ${Date.now()}-${Math.random().toString(RANDOM_RADIX).slice(RANDOM_SLICE_START, RANDOM_SLICE_END)}` + } + + static async setAdminBarQuickNavSettings(options: { enabled: boolean; perPage: number }): Promise { + const php = ` + \\Code_Snippets\\Settings\\update_setting('general', 'enable_admin_bar', ${options.enabled ? 'true' : 'false'}); + \\Code_Snippets\\Settings\\update_setting('general', 'admin_bar_snippet_limit', ${options.perPage}); + ` + + await wpCli(['eval', php]) + } + + static async createSnippetViaCli(options: CreateSnippetCliOptions): Promise { + const type = options.type ?? 'php' + let scope = 'global' + switch (type) { + case 'html': + scope = 'content' + break + case 'css': + scope = 'site-css' + break + case 'js': + scope = 'site-footer-js' + break + case 'cond': + scope = 'condition' + break + } + + const code = 'html' === type ? `

${options.name}

\n` : `// ${options.name}\n` + + const php = ` + $snippet = new \\Code_Snippets\\Model\\Snippet([ + 'name' => ${JSON.stringify(options.name)}, + 'desc' => '', + 'code' => ${JSON.stringify(code)}, + 'scope' => ${JSON.stringify(scope)}, + 'active' => ${options.active ? 'true' : 'false'}, + 'tags' => [], + ]); + \\Code_Snippets\\save_snippet($snippet); + ` + + await wpCli(['eval', php]) + } + + static async cleanupSnippetsByPrefix(prefix: string): Promise { + const php = ` + global $wpdb; + $prefix = ${JSON.stringify(prefix)}; + $like = $wpdb->esc_like( $prefix ) . '%'; + $targets = [ [ false, \\Code_Snippets\\code_snippets()->db->get_table_name( false ) ] ]; + + if ( is_multisite() ) { + $targets[] = [ true, \\Code_Snippets\\code_snippets()->db->get_table_name( true ) ]; + } + + foreach ( $targets as $target ) { + [ $network, $table ] = $target; + $ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$table} WHERE name LIKE %s", $like ) ); + foreach ( $ids as $id ) { + \\Code_Snippets\\delete_snippet( intval( $id ), (bool) $network ); + } + } + ` + + await wpCli(['eval', php]) + } + + private async clickButton(name: RegExp, options: { force?: boolean } = {}): Promise { + const force = options.force ?? true + + for (let attempt = 0; CLICK_RETRIES > attempt; attempt++) { + try { + const buttons = this.page.getByRole('button', { name }) + const count = await buttons.count() + + for (let i = 0; i < Math.max(count, AT_LEAST_ONE); i++) { + const candidate = 0 === count ? buttons.first() : buttons.nth(i) + const visible = await candidate.isVisible().catch(() => false) + if (!visible && 0 !== count) { + continue + } + await candidate.click({ timeout: TIMEOUTS.DEFAULT, force }) + return + } + + // Fallback: attempt to click the first match even if not considered "visible". + await buttons.first().click({ timeout: TIMEOUTS.DEFAULT, force }) + return + } catch (error: unknown) { + const message = getErrorMessage(error) + if (!message.includes('not attached to the DOM') && !message.includes('Target closed')) { + throw error + } + } + } + + throw new Error(`Failed to click button: ${name}`) + } + + private async setCodeMirrorValue(value: string): Promise { + const didSetViaApi = await this.page + .evaluate(newValue => { + const wrapper = document.querySelector('.CodeMirror') + const cm = (<{ CodeMirror?: unknown }>wrapper).CodeMirror + + if (!cm || 'object' !== typeof cm) { + return false + } + + const { setValue, refresh } = <{ setValue?: unknown; refresh?: unknown }>cm + + if ('function' !== typeof setValue) { + return false + } + + setValue.call(cm, newValue) + + if ('function' === typeof refresh) { + refresh.call(cm) + } + + return true + }, value) + .catch(() => false) + + if (didSetViaApi) { + return + } + + const editor = this.page.locator('.CodeMirror').first() + await expect(editor).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await editor.click() + await this.page.keyboard.press(META_OR_CONTROL_A) + await this.page.keyboard.type(value) + } + + private async selectSnippetLocation(location: keyof typeof SNIPPET_LOCATIONS): Promise { + const locationLabel = SNIPPET_LOCATIONS[location] + + const combobox = this.page.getByRole('combobox', { name: /location/i }).first() + await expect(combobox).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await combobox.click() + + const listbox = this.page.getByRole('listbox').first() + await expect(listbox).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await listbox + .getByRole('option', { name: new RegExp(escapeRegExp(locationLabel), 'i') }) + .click() + + await expect(this.page.locator(SELECTORS.LOCATION_SELECT)).toContainText(locationLabel) + } /** * Navigate to the Code Snippets admin page */ async navigateToSnippetsAdmin(): Promise { await this.page.goto(URLS.SNIPPETS_ADMIN) - await this.page.waitForLoadState('networkidle') - await this.page.waitForSelector(SELECTORS.WPBODY_CONTENT, { timeout: TIMEOUTS.DEFAULT }) + await this.page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) } /** @@ -34,16 +215,15 @@ export class SnippetsTestHelper { */ async navigateToFrontend(): Promise { await this.page.goto(URLS.FRONTEND) - await this.page.waitForLoadState('networkidle') + await this.page.waitForSelector('body', { timeout: TIMEOUTS.DEFAULT }) } /** * Click the "Add New" button to start creating a snippet */ async clickAddNewSnippet(): Promise { - await this.page.waitForSelector(SELECTORS.PAGE_TITLE, { timeout: TIMEOUTS.DEFAULT }) - await this.page.click(SELECTORS.ADD_NEW_BUTTON) - await this.page.waitForLoadState('networkidle') + await this.page.goto(URLS.ADD_SNIPPET) + await this.page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) } /** @@ -54,19 +234,23 @@ export class SnippetsTestHelper { await this.page.fill(SELECTORS.TITLE_INPUT, options.name) if (options.type && 'PHP' !== options.type) { - await this.page.click(SELECTORS.SNIPPET_TYPE_SELECT) - await this.page.click(`text=${SNIPPET_TYPES[options.type]}`) + const snippetTypeInput = this.page.locator(SELECTORS.SNIPPET_TYPE_SELECT) + await snippetTypeInput.click() + + // React Select renders options in a listbox; scope the click to options to avoid matching + // other UI strings like "Skip to main content". + const listboxId = await snippetTypeInput.getAttribute('aria-controls') + const listbox = listboxId ? this.page.locator(`#${listboxId}`) : this.page.getByRole('listbox') + const optionLabel = SNIPPET_TYPES[options.type] + + await listbox.getByRole('option', { name: new RegExp(escapeRegExp(optionLabel), 'i') }).click() } await this.page.waitForSelector(SELECTORS.CODE_MIRROR_TEXTAREA) - await this.page.fill(SELECTORS.CODE_MIRROR_TEXTAREA, options.code) + await this.setCodeMirrorValue(options.code) if (options.location) { - await this.page.waitForSelector(SELECTORS.LOCATION_SELECT, { timeout: TIMEOUTS.SHORT }) - await this.page.click(SELECTORS.LOCATION_SELECT) - - await this.page.waitForSelector(`text=${SNIPPET_LOCATIONS[options.location]}`, { timeout: TIMEOUTS.SHORT }) - await this.page.click(`text=${SNIPPET_LOCATIONS[options.location]}`, { force: true }) + await this.selectSnippetLocation(options.location) } } @@ -74,13 +258,38 @@ export class SnippetsTestHelper { * Save the snippet with the specified action */ async saveSnippet(action: 'save' | 'save_and_activate' | 'save_and_deactivate' = 'save'): Promise { - const buttonMap = { - save: BUTTONS.SAVE, - save_and_activate: BUTTONS.SAVE_AND_ACTIVATE, - save_and_deactivate: BUTTONS.SAVE_AND_DEACTIVATE, + if ('save_and_activate' === action) { + const activateButton = this.page.locator(BUTTONS.SAVE_AND_ACTIVATE).first() + if (await activateButton.isVisible().catch(() => false)) { + await this.clickButton(/^Save and Activate$/i) + return + } + + // Fallback: toggle status to active and save. + const inactiveToggle = this.page.getByRole('checkbox', { name: /^Inactive$/ }).first() + if (await inactiveToggle.isVisible().catch(() => false)) { + await inactiveToggle.click({ timeout: TIMEOUTS.DEFAULT, force: true }) + } + await this.clickButton(/^Save Snippet$/i) + return + } + + if ('save_and_deactivate' === action) { + // New UI deactivates via Status toggle + "Save Snippet". + const activeToggle = this.page.getByRole('checkbox', { name: /^Active$/ }).first() + if (await activeToggle.isVisible().catch(() => false)) { + await activeToggle.click({ timeout: TIMEOUTS.DEFAULT, force: true }) + } else { + const statusToggle = this.page.getByRole('checkbox', { name: /Active|Inactive/ }).first() + if (await statusToggle.isVisible().catch(() => false)) { + await statusToggle.click({ timeout: TIMEOUTS.DEFAULT, force: true }) + } + } + await this.clickButton(/^Save Snippet$/i) + return } - await this.page.click(buttonMap[action]) + await this.clickButton(/^Save Snippet$/i) } /** @@ -90,28 +299,107 @@ export class SnippetsTestHelper { await expect(this.page.locator(SELECTORS.SUCCESS_MESSAGE)).toContainText(expectedMessage) } - /** - * Expect a success message in paragraph element - */ - async expectSuccessMessageInParagraph(expectedMessage: string): Promise { - await expect(this.page.locator(SELECTORS.SUCCESS_MESSAGE_P)).toContainText(expectedMessage) - } - /** * Open an existing snippet by name */ async openSnippet(snippetName: string): Promise { - await this.page.waitForSelector(`text=${snippetName}`) - await this.page.click(`text=${snippetName}`) - await this.page.waitForLoadState('networkidle') + await this.page.goto(URLS.SNIPPETS_ADMIN) + await this.page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const row = this.page.locator(SELECTORS.SNIPPET_ROW).filter({ hasText: snippetName }).first() + await expect(row).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + await row.locator(SELECTORS.SNIPPET_NAME_LINK).click() + await this.page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) } /** * Delete a snippet (assumes you're already on the snippet edit page) */ async deleteSnippet(): Promise { - await this.page.click(BUTTONS.DELETE) - await this.page.click(SELECTORS.DELETE_CONFIRM_BUTTON) + await this.page.locator(BUTTONS.DELETE).first().click() + + // Some UIs show a React dialog, others navigate immediately. + const dialog = this.page.locator('[role="dialog"]').filter({ hasText: /Are you sure\?/i }) + const dialogVisible = await dialog + .waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT }) + .then(() => true) + .catch(() => false) + + if (dialogVisible) { + await Promise.all([ + this.page.waitForURL(/page=snippets/, { timeout: TIMEOUTS.DEFAULT }), + dialog.locator('button:has-text("Trash"), button:has-text("Delete")').first().click() + ]) + } else { + await this.page.waitForURL(/page=snippets/, { timeout: TIMEOUTS.DEFAULT }) + } + + await expect(this.page).toHaveURL(/page=snippets/) + await expect(this.page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + } + + /** + * Delete a snippet by name from the snippets list page. + */ + async deleteSnippetFromList(snippetName: string): Promise { + await this.navigateToSnippetsAdmin() + + const row = this.page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + + const rowVisible = await row + .waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT }) + .then(() => true) + .catch(() => false) + + if (!rowVisible) { + return + } + + await row.locator(SELECTORS.DELETE_ACTION).first().click() + + // After trashing, it may still show depending on current filter; navigate to trash to ensure it's gone. + const trashedLink = this.page.locator('a[href*="status=trashed"]').first() + const trashedLinkVisible = await trashedLink + .waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT }) + .then(() => true) + .catch(() => false) + + if (!trashedLinkVisible) { + return + } + + await trashedLink.click() + await expect(this.page).toHaveURL(/status=trashed/, { timeout: TIMEOUTS.DEFAULT }) + + const trashedRow = this.page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + + const trashedVisible = await trashedRow + .waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT }) + .then(() => true) + .catch(() => false) + + if (!trashedVisible) { + return + } + + await trashedRow.locator('button:has-text("Delete Permanently")').click() + + const dialog = this.page.locator('[role="dialog"]').filter({ hasText: /Are you sure\?/i }) + const dialogVisible = await dialog + .waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT }) + .then(() => true) + .catch(() => false) + + if (dialogVisible) { + await dialog.locator('button:has-text("Delete")').click() + } + + await expect(this.page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) } /** @@ -123,14 +411,15 @@ export class SnippetsTestHelper { } /** - * Clean up a snippet by name (navigate to admin, find snippet, delete it) + * Clean up all snippets by name (navigate to admin, find snippets, delete them) */ async cleanupSnippet(snippetName: string): Promise { - await this.navigateToSnippetsAdmin() - - if (await this.snippetExists(snippetName)) { - await this.openSnippet(snippetName) - await this.deleteSnippet() + // Prefer WP-CLI cleanup for speed and determinism. Use plugin operations so + // file-based execution stays in sync (flat files update via hooks). + try { + await SnippetsTestHelper.cleanupSnippetsByPrefix(snippetName) + } catch { + // Cleanup should never fail the test run. } } @@ -140,7 +429,7 @@ export class SnippetsTestHelper { async expectToBeOnSnippetsAdminPage(): Promise { const currentUrl = this.page.url() expect(currentUrl).toContain('page=snippets') - await expect(this.page.locator(SELECTORS.PAGE_TITLE)).toBeVisible() + await expect(this.page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() } /** @@ -187,6 +476,24 @@ export class SnippetsTestHelper { await this.fillSnippetForm(options) await this.saveSnippet('save_and_activate') await this.expectSuccessMessage(MESSAGES.SNIPPET_CREATED_AND_ACTIVATED) + + // Ensure activation is actually persisted by toggling from the list screen. + await this.navigateToSnippetsAdmin() + const row = this.page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${options.name}"))`) + .first() + await expect(row).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + const toggleCell = row.locator('td').first() + const toggleCheckbox = toggleCell.getByRole('checkbox').first() + + const isChecked = await toggleCheckbox.isChecked().catch(() => false) + if (!isChecked) { + await toggleCheckbox.click({ timeout: TIMEOUTS.DEFAULT, force: true }) + await expect(toggleCheckbox).toBeChecked({ timeout: TIMEOUTS.DEFAULT }) + } + + await expect(toggleCell).toContainText(/Deactivate/i, { timeout: TIMEOUTS.DEFAULT }) } /** diff --git a/tests/e2e/helpers/constants.ts b/tests/e2e/helpers/constants.ts index 1686accf3..fdcabe077 100644 --- a/tests/e2e/helpers/constants.ts +++ b/tests/e2e/helpers/constants.ts @@ -1,55 +1,48 @@ -export const SELECTORS = { - WPBODY_CONTENT: '#wpbody-content, .wrap, #wpcontent', - PAGE_TITLE: 'h1, .page-title', - ADD_NEW_BUTTON: '.page-title-action', - +export const SELECTORS = { TITLE_INPUT: '#title', CODE_MIRROR_TEXTAREA: '.CodeMirror textarea', SNIPPET_TYPE_SELECT: '#snippet-type-select-input', LOCATION_SELECT: '.code-snippets-select-location', - SUCCESS_MESSAGE: '#message.notice', - SUCCESS_MESSAGE_P: '#message.notice p', - - DELETE_CONFIRM_BUTTON: 'button.components-button.is-destructive.is-primary', + SUCCESS_MESSAGE: '.snippet-editor-sidebar .notice.updated', SNIPPETS_TABLE: '.wp-list-table', SNIPPET_ROW: '.wp-list-table tbody tr', - SNIPPET_TOGGLE: '.snippet-activation-switch input[type="checkbox"]', - SNIPPET_NAME_LINK: '.row-title', + SNIPPET_TOGGLE: 'input.switch', + SNIPPET_NAME_LINK: '.snippet-name', - EDIT_ACTION: '.row-actions .edit a', - CLONE_ACTION: '.row-actions .clone a', - DELETE_ACTION: '.row-actions .delete a', - EXPORT_ACTION: '.row-actions .export a', + CLONE_ACTION: '.row-actions button:has-text("Clone")', + DELETE_ACTION: '.row-actions button:has-text("Trash")', + EXPORT_ACTION: '.row-actions button:has-text("Export")', ADMIN_BAR: '#wpadminbar' } -export const TIMEOUTS = { - DEFAULT: 10000, +export const TIMEOUTS = { + DEFAULT: 30000, SHORT: 5000 } -export const URLS = { +export const URLS = { SNIPPETS_ADMIN: '/wp-admin/admin.php?page=snippets', + ADD_SNIPPET: '/wp-admin/admin.php?page=add-snippet', FRONTEND: '/' } -export const MESSAGES = { - SNIPPET_CREATED: 'Snippet created', - SNIPPET_CREATED_AND_ACTIVATED: 'Snippet created and activated', - SNIPPET_UPDATED_AND_ACTIVATED: 'Snippet updated and activated', - SNIPPET_UPDATED_AND_DEACTIVATED: 'Snippet updated and deactivated' +export const MESSAGES = { + SNIPPET_CREATED: /Snippet (?:created|updated)/i, + SNIPPET_CREATED_AND_ACTIVATED: /Snippet (?:created|updated)(?: and activated)?/i, + SNIPPET_UPDATED_AND_ACTIVATED: /Snippet updated/i, + SNIPPET_UPDATED_AND_DEACTIVATED: /Snippet updated/i } -export const SNIPPET_TYPES = { - PHP: 'PHP', - HTML: 'HTML' +export const SNIPPET_TYPES = { + PHP: 'Functions', + HTML: 'Content' } -export const SNIPPET_LOCATIONS = { - SITE_FOOTER: 'In site footer', +export const SNIPPET_LOCATIONS = { + SITE_FOOTER: 'In site footer (end of )', SITE_HEADER: 'In site section', IN_EDITOR: 'Where inserted in editor', ADMIN_ONLY: 'Only run in administration area', @@ -57,9 +50,8 @@ export const SNIPPET_LOCATIONS = { EVERYWHERE: 'Run everywhere' } -export const BUTTONS = { - SAVE: 'text=Save Snippet', - SAVE_AND_ACTIVATE: 'text=Save and Activate', - SAVE_AND_DEACTIVATE: 'text=Save and Deactivate', - DELETE: 'text=Delete' +export const BUTTONS = { + SAVE: 'role=button[name="Save Snippet"]', + SAVE_AND_ACTIVATE: 'role=button[name="Save and Activate"]', + DELETE: 'button:has-text("Trash")' } diff --git a/tests/phpunit/test-admin-bar.php b/tests/phpunit/test-admin-bar.php new file mode 100644 index 000000000..a0609aab1 --- /dev/null +++ b/tests/phpunit/test-admin-bar.php @@ -0,0 +1,338 @@ +user->create( + [ + 'role' => 'administrator', + ] + ); + } + + /** + * Set up before each test. + * + * @return void + */ + public function set_up() { + parent::set_up(); + + wp_set_current_user( self::$admin_user_id ); + + add_filter( 'show_admin_bar', '__return_true' ); + + update_setting( 'general', 'enable_admin_bar', true ); + update_setting( 'general', 'admin_bar_snippet_limit', 20 ); + + remove_all_filters( 'code_snippets/execute_snippets' ); + remove_all_filters( 'code_snippets/admin_bar/enabled' ); + + $this->truncate_snippets_table(); + unset( $_GET['code_snippets_ab_active_page'], $_GET['code_snippets_ab_inactive_page'] ); + } + + /** + * Clear all snippets from the database. + * + * @return void + */ + private function truncate_snippets_table(): void { + global $wpdb; + $table_name = code_snippets()->db->get_table_name(); + $wpdb->query( "TRUNCATE TABLE {$table_name}" ); + } + + /** + * Create a snippet test fixture. + * + * @param string $name Snippet name. + * @param bool $active Whether the snippet should be active. + * @param string $type Snippet type. + * + * @return Snippet + */ + private function create_snippet( string $name, bool $active, string $type = 'php' ): Snippet { + $scope = 'html' === $type ? + 'content' : + ( 'css' === $type ? + 'site-css' : + ( 'js' === $type ? + 'site-footer-js' : + ( 'cond' === $type ? 'condition' : 'global' ) + ) + ); + + $code = 'html' === $type ? + "

{$name}

\n" : + " $name, + 'code' => $code, + 'scope' => $scope, + 'active' => $active, + 'tags' => [], + ] + ); + + save_snippet( $snippet ); + return $snippet; + } + + /** + * Build an isolated admin bar instance for assertions. + * + * @return WP_Admin_Bar + */ + private function build_admin_bar(): WP_Admin_Bar { + if ( ! class_exists( 'WP_Admin_Bar' ) ) { + require_once ABSPATH . WPINC . '/class-wp-admin-bar.php'; + } + + $wp_admin_bar = new WP_Admin_Bar(); + + if ( method_exists( $wp_admin_bar, 'initialize' ) ) { + $wp_admin_bar->initialize(); + } + + return $wp_admin_bar; + } + + /** + * Read nodes from a WP_Admin_Bar instance. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * + * @return array + */ + private function get_nodes( WP_Admin_Bar $wp_admin_bar ): array { + if ( method_exists( $wp_admin_bar, 'get_nodes' ) ) { + return (array) $wp_admin_bar->get_nodes(); + } + + $ref = new \ReflectionClass( $wp_admin_bar ); + if ( $ref->hasProperty( 'nodes' ) ) { + $prop = $ref->getProperty( 'nodes' ); + $prop->setAccessible( true ); + return (array) $prop->getValue( $wp_admin_bar ); + } + + return []; + } + + /** + * Admin bar menu is hidden when the setting is disabled. + * + * @return void + */ + public function test_admin_bar_menu_is_disabled_by_setting(): void { + update_setting( 'general', 'enable_admin_bar', false ); + + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $this->assertNull( $wp_admin_bar->get_node( 'code-snippets' ) ); + } + + /** + * Safe mode indicator is shown even if the main Snippets menu is disabled. + * + * @return void + */ + public function test_safe_mode_indicator_is_shown_even_when_menu_disabled(): void { + update_setting( 'general', 'enable_admin_bar', false ); + add_filter( 'code_snippets/execute_snippets', '__return_false' ); + + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $this->assertNull( $wp_admin_bar->get_node( 'code-snippets' ) ); + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-safe-mode' ) ); + } + + /** + * Pro-only snippet types should be disabled in the free plugin. + * + * @return void + */ + public function test_pro_types_are_disabled_when_unlicensed(): void { + update_setting( 'general', 'enable_admin_bar', true ); + + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $php = $wp_admin_bar->get_node( 'code-snippets-add-php' ); + $this->assertNotNull( $php ); + $this->assertStringContainsString( 'type=php', $php->href ); + + $css = $wp_admin_bar->get_node( 'code-snippets-add-css' ); + $this->assertNotNull( $css ); + $this->assertStringContainsString( 'page=code_snippets_upgrade', $css->href ); + $this->assertArrayHasKey( 'class', $css->meta ); + $this->assertStringContainsString( 'code-snippets-disabled', (string) $css->meta['class'] ); + } + + /** + * Active/inactive snippet listings paginate and accept progressive enhancement query args. + * + * @return void + */ + public function test_snippet_listings_paginate_and_respect_query_arg(): void { + update_setting( 'general', 'admin_bar_snippet_limit', 2 ); + + $this->create_snippet( 'QuickNav Active A', true ); + $this->create_snippet( 'QuickNav Active B', true ); + $this->create_snippet( 'QuickNav Active C', true ); + + $this->create_snippet( 'QuickNav Inactive A', false ); + $this->create_snippet( 'QuickNav Inactive B', false ); + $this->create_snippet( 'QuickNav Inactive Z HTML', false, 'html' ); + + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-active-pagination' ) ); + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-inactive-pagination' ) ); + + $nodes = $this->get_nodes( $wp_admin_bar ); + + $active_children = array_filter( + $nodes, + static fn( $node ) => isset( $node->parent ) && 'code-snippets-active-snippets' === $node->parent + ); + + $active_titles = array_map( + static fn( $node ) => (string) ( $node->title ?? '' ), + array_values( $active_children ) + ); + + $active_titles = array_values( array_filter( $active_titles, static fn( $title ) => false !== strpos( $title, 'QuickNav Active' ) ) ); + $this->assertCount( 2, $active_titles ); + $this->assertStringContainsString( '(PHP) QuickNav Active A', $active_titles[0] ); + $this->assertStringContainsString( '(PHP) QuickNav Active B', $active_titles[1] ); + + $_GET['code_snippets_ab_active_page'] = 2; + + $wp_admin_bar_page_2 = $this->build_admin_bar(); + $admin_bar->register_nodes( $wp_admin_bar_page_2 ); + + $nodes_page_2 = $this->get_nodes( $wp_admin_bar_page_2 ); + $active_children_page_2 = array_filter( + $nodes_page_2, + static fn( $node ) => isset( $node->parent ) && 'code-snippets-active-snippets' === $node->parent + ); + + $active_titles_page_2 = array_map( + static fn( $node ) => (string) ( $node->title ?? '' ), + array_values( $active_children_page_2 ) + ); + + $active_titles_page_2 = array_values( array_filter( $active_titles_page_2, static fn( $title ) => false !== strpos( $title, 'QuickNav Active' ) ) ); + $this->assertCount( 1, $active_titles_page_2 ); + $this->assertStringContainsString( '(PHP) QuickNav Active C', $active_titles_page_2[0] ); + + $_GET['code_snippets_ab_inactive_page'] = 2; + + $wp_admin_bar_inactive_page_2 = $this->build_admin_bar(); + $admin_bar->register_nodes( $wp_admin_bar_inactive_page_2 ); + + $nodes_inactive_page_2 = $this->get_nodes( $wp_admin_bar_inactive_page_2 ); + $inactive_children_page_2 = array_filter( + $nodes_inactive_page_2, + static fn( $node ) => isset( $node->parent ) && 'code-snippets-inactive-snippets' === $node->parent + ); + + $inactive_titles_page_2 = array_map( + static fn( $node ) => (string) ( $node->title ?? '' ), + array_values( $inactive_children_page_2 ) + ); + + $inactive_titles_page_2 = array_values( + array_filter( $inactive_titles_page_2, static fn( $title ) => false !== strpos( $title, 'QuickNav Inactive' ) ) + ); + + $this->assertCount( 1, $inactive_titles_page_2 ); + $this->assertStringContainsString( '(HTML) QuickNav Inactive Z HTML', $inactive_titles_page_2[0] ); + } + + /** + * Admin bar menu can be enabled via filter even when the setting is disabled. + * + * @return void + */ + public function test_admin_bar_menu_can_be_enabled_via_filter(): void { + update_setting( 'general', 'enable_admin_bar', false ); + add_filter( 'code_snippets/admin_bar/enabled', '__return_true' ); + + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets' ) ); + } + + /** + * QuickNav nodes include Manage status quick links. + * + * @return void + */ + public function test_manage_quick_links_are_registered(): void { + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-manage' ) ); + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-status-all' ) ); + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-status-active' ) ); + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-status-inactive' ) ); + } + + /** + * Safe mode documentation link is registered under the Snippets root node. + * + * @return void + */ + public function test_safe_mode_docs_link_is_registered(): void { + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $node = $wp_admin_bar->get_node( 'code-snippets-safe-mode-doc' ); + $this->assertNotNull( $node ); + $this->assertStringContainsString( 'https://snipco.de/safe-mode', $node->href ); + } +} diff --git a/tests/phpunit/test-flat-files-hooks.php b/tests/phpunit/test-flat-files-hooks.php new file mode 100644 index 000000000..7876ac74f --- /dev/null +++ b/tests/phpunit/test-flat-files-hooks.php @@ -0,0 +1,45 @@ + 'E2E Flat Files Hook Test', + 'desc' => '', + 'code' => '/* test */', + 'scope' => 'global', + 'active' => false, + ] + ); + + $saved = save_snippet( $snippet ); + $this->assertNotNull( $saved ); + $this->assertGreaterThan( 0, $saved->id ); + + $observed = null; + $callback = static function ( $snippet_arg ) use ( &$observed ) { + $observed = $snippet_arg; + }; + + add_action( 'code_snippets/update_snippet', $callback, 0, 1 ); + + update_snippet_fields( $saved->id, [ 'priority' => 9 ] ); + + remove_action( 'code_snippets/update_snippet', $callback, 0 ); + + $this->assertInstanceOf( Snippet::class, $observed ); + $this->assertSame( $saved->id, $observed->id ); + } +} + diff --git a/tests/test-rest-api-snippets.php b/tests/phpunit/test-rest-api-snippets.php similarity index 78% rename from tests/test-rest-api-snippets.php rename to tests/phpunit/test-rest-api-snippets.php index fa7c5969c..a8c574ff2 100644 --- a/tests/test-rest-api-snippets.php +++ b/tests/phpunit/test-rest-api-snippets.php @@ -1,6 +1,6 @@ user->create( [ - 'role' => 'administrator', - ] ); + self::$admin_user_id = $factory->user->create( + [ + 'role' => 'administrator', + ] + ); } /** @@ -67,14 +75,16 @@ protected function clear_all_snippets() { */ protected function seed_test_snippets( $count = 25 ) { for ( $i = 1; $i <= $count; $i++ ) { - $snippet = new Snippet( [ - 'name' => "Test Snippet {$i}", - 'desc' => "This is test snippet number {$i}", - 'code' => "// Test snippet {$i}\necho 'Hello World {$i}';", - 'scope' => 'global', - 'active' => false, - 'tags' => [ 'test', "batch-{$i}" ], - ] ); + $snippet = new Snippet( + [ + 'name' => "Test Snippet {$i}", + 'desc' => "This is test snippet number {$i}", + 'code' => "// Test snippet {$i}\necho 'Hello World {$i}';", + 'scope' => 'global', + 'active' => false, + 'tags' => [ 'test', "batch-{$i}" ], + ] + ); save_snippet( $snippet ); } @@ -102,13 +112,9 @@ protected function make_request( $endpoint, $params = [] ) { * Test that we can retrieve all snippets without pagination. */ public function test_get_all_snippets_without_pagination() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - - // Act. $response = $this->make_request( $endpoint, [ 'network' => false ] ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 25, $response, 'Should return all 25 snippets when no pagination params are provided' ); @@ -121,16 +127,16 @@ public function test_get_all_snippets_without_pagination() { * Test pagination with per_page parameter only (first page). */ public function test_get_snippets_with_per_page() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 2, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 2, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 2, $response, 'Should return exactly 2 snippets when per_page=2' ); @@ -142,17 +148,17 @@ public function test_get_snippets_with_per_page() { * Test pagination with per_page and page parameters. */ public function test_get_snippets_with_per_page_and_page() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 2, - 'page' => 3, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 2, + 'page' => 3, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 2, $response, 'Should return exactly 2 snippets for page 3 with per_page=2' ); @@ -164,26 +170,27 @@ public function test_get_snippets_with_per_page_and_page() { * Test pagination with page parameter only (should use default per_page). */ public function test_get_snippets_with_page_only() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $page_1_response = $this->make_request( $endpoint, [ - 'network' => false, - 'page' => 1, - ] ); + $page_1_response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'page' => 1, + ] + ); - // Assert. $this->assertIsArray( $page_1_response ); $this->assertGreaterThan( 0, count( $page_1_response ), 'Page 1 should have snippets' ); - // Act. - $page_2_response = $this->make_request( $endpoint, [ - 'network' => false, - 'page' => 2, - ] ); + $page_2_response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'page' => 2, + ] + ); - // Assert. $this->assertIsArray( $page_2_response ); $this->assertCount( 10, $page_2_response, 'Page 2 with default per_page should have 10 snippets' ); } @@ -192,18 +199,15 @@ public function test_get_snippets_with_page_only() { * Test that headers contain correct pagination metadata. */ public function test_pagination_headers() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; $request = new WP_REST_Request( 'GET', $endpoint ); $request->set_param( 'network', false ); $request->set_param( 'per_page', 5 ); $request->set_param( 'page', 1 ); - // Act. $response = rest_do_request( $request ); $headers = $response->get_headers(); - // Assert. $this->assertEquals( 25, $headers['X-WP-Total'], 'X-WP-Total header should show 25 total snippets' ); $this->assertEquals( 5, $headers['X-WP-TotalPages'], 'X-WP-TotalPages should be 5 (25 snippets / 5 per_page)' ); } @@ -212,17 +216,17 @@ public function test_pagination_headers() { * Test that last page returns correct number of snippets. */ public function test_last_page_with_partial_results() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 10, - 'page' => 3, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 10, + 'page' => 3, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 5, $response, 'Last page should have only 5 remaining snippets (25 % 10)' ); } @@ -231,17 +235,17 @@ public function test_last_page_with_partial_results() { * Test that requesting a page beyond available pages returns empty array. */ public function test_page_beyond_available_returns_empty() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 10, - 'page' => 100, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 10, + 'page' => 100, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 0, $response, 'Requesting page beyond available should return empty array' ); } @@ -250,17 +254,17 @@ public function test_page_beyond_available_returns_empty() { * Test per_page with value of 1. */ public function test_per_page_one() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 1, - 'page' => 5, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 1, + 'page' => 5, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 1, $response, 'Should return exactly 1 snippet when per_page=1' ); $this->assertStringContainsString( 'Test Snippet 5', $response[0]['name'] ); @@ -270,17 +274,17 @@ public function test_per_page_one() { * Test that per_page larger than total returns all snippets. */ public function test_per_page_larger_than_total() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 100, - 'page' => 1, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 100, + 'page' => 1, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 25, $response, 'Should return all 25 snippets when per_page exceeds total' ); } @@ -289,16 +293,16 @@ public function test_per_page_larger_than_total() { * Test that snippet data structure is correct. */ public function test_snippet_data_structure() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 1, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 1, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 1, $response ); diff --git a/tests/phpunit/test-unit-tests.php b/tests/phpunit/test-unit-tests.php new file mode 100644 index 000000000..3f4f34ad5 --- /dev/null +++ b/tests/phpunit/test-unit-tests.php @@ -0,0 +1,18 @@ +assertTrue( true ); // The unit tests are running. + } +} diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 10c2e67f5..354b274e7 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -2,7 +2,6 @@ import { join } from 'path' import { defineConfig, devices } from '@playwright/test' -const RETRIES = 2 const WORKERS = 1 /** @@ -13,16 +12,23 @@ export default defineConfig({ snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{platform}{ext}', fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? RETRIES : 0, - workers: process.env.CI ? WORKERS : undefined, - reporter: [ - ['html'], - ['json', { outputFile: 'test-results/results.json' }], - ['junit', { outputFile: 'test-results/results.xml' }] - ], + retries: 0, + workers: process.env.CI ? WORKERS : WORKERS, + reporter: process.env.CI + ? [ + ['line'], + ['html'], + ['json', { outputFile: join(process.cwd(), 'test-results', 'results.json') }], + ['junit', { outputFile: join(process.cwd(), 'test-results', 'results.xml') }] + ] + : [ + ['html'], + ['json', { outputFile: join(process.cwd(), 'test-results', 'results.json') }], + ['junit', { outputFile: join(process.cwd(), 'test-results', 'results.xml') }] + ], use: { baseURL: 'http://localhost:8888', - trace: 'on-first-retry', + trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' }, @@ -64,10 +70,10 @@ export default defineConfig({ } ], - timeout: 30000, + timeout: 60000, // 60 seconds per test expect: { - timeout: 10000, + timeout: 30000, // 30 seconds for each expect assertion toHaveScreenshot: { maxDiffPixels: 100 } } }) diff --git a/tests/test-setup-phpunit.ts b/tests/test-setup-phpunit.ts new file mode 100644 index 000000000..54a45c017 --- /dev/null +++ b/tests/test-setup-phpunit.ts @@ -0,0 +1,75 @@ +#!/usr/bin/env ts-node + +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' + +const getEnv = (key: string, fallback: string): string => process.env[key] ?? fallback + +const run = (cmd: string, args: readonly string[], options: { env?: NodeJS.ProcessEnv } = {}) => { + const extraEnv = options.env ?? {} + execFileSync(cmd, args, { stdio: 'inherit', env: { ...process.env, ...extraEnv } }) +} + +const buildMysqlArgs = (options: { user: string; password: string; host: string }) => { + const args = ['-u', options.user] + + if (options.password) { + args.push(`--password=${options.password}`) + } + + if (options.host) { + args.push('-h', options.host) + } + + return args +} + +const assertSafeDbName = (dbName: string) => { + if (!/^[A-Za-z0-9_]+$/.test(dbName)) { + throw new Error(`Invalid DB name "${dbName}". Use only letters, numbers, and underscore.`) + } +} + +const main = () => { + const dbName = getEnv('WP_PHPUNIT_DB_NAME', 'code_snippets_phpunit') + const dbUser = getEnv('WP_PHPUNIT_DB_USER', 'root') + const dbPass = getEnv('WP_PHPUNIT_DB_PASS', '') + const dbHost = getEnv('WP_PHPUNIT_DB_HOST', '127.0.0.1') + const wpVersion = getEnv('WP_PHPUNIT_WP_VERSION', 'latest') + + assertSafeDbName(dbName) + + const wpTestsDir = resolve(process.cwd(), '.wp-tests-lib') + const wpCoreDir = resolve(process.cwd(), '.wp-core') + const wpTestsConfig = resolve(wpTestsDir, 'wp-tests-config.php') + const installScript = resolve(process.cwd(), 'tests', 'install-wp-tests.sh') + + // Create the database if needed (avoid install-wp-tests.sh prompt / destructive behavior). + const mysqlArgs = buildMysqlArgs({ user: dbUser, password: dbPass, host: dbHost }) + run('mysql', [...mysqlArgs, '-e', `CREATE DATABASE IF NOT EXISTS \`${dbName}\`;`]) + + // Ensure config is regenerated with current DB settings. + run('rm', ['-f', wpTestsConfig, `${wpTestsConfig}.bak`]) + + run('bash', [ + installScript, + dbName, + dbUser, + dbPass, + dbHost, + wpVersion, + 'true' + ], { + env: { + WP_TESTS_DIR: wpTestsDir, + WP_CORE_DIR: wpCoreDir + } + }) +} + +try { + main() +} catch (error: unknown) { + console.error(error) + process.exitCode = 1 +} diff --git a/tests/test-setup-playwright.ts b/tests/test-setup-playwright.ts new file mode 100644 index 000000000..5dc9308fe --- /dev/null +++ b/tests/test-setup-playwright.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env ts-node + +import { execFileSync } from 'node:child_process' + +const run = (cmd: string, args: readonly string[]) => { + execFileSync(cmd, args, { stdio: 'inherit' }) +} + +const runWpEnvCli = (args: readonly string[]) => run('npx', ['wp-env', 'run', 'cli', ...args]) + +const main = () => { + // Ensure a clean slate for file-based execution tests: + // - remove flat-file execution directory (stale indexes can break the WP site) + // - ensure plugin is active + // - force enable_flat_files=false so the Playwright setup test can flip it to true + // - delete all DB snippets with an E2E prefix (keeps list clean across runs) + + runWpEnvCli(['sh', '-lc', 'rm -rf wp-content/code-snippets']) + runWpEnvCli(['wp', 'plugin', 'activate', 'code-snippets']) + + runWpEnvCli([ + 'wp', + 'eval', + ` + $settings = get_option('code_snippets_settings', []); + $settings['general']['enable_flat_files'] = false; + update_option('code_snippets_settings', $settings); + ` + ]) + + runWpEnvCli([ + 'wp', + 'eval', + ` + global $wpdb; + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->prefix}snippets WHERE name LIKE %s", + "E2E%" + ) + ); + ` + ]) +} + +try { + main() +} catch (error: unknown) { + console.error(error) + process.exitCode = 1 +} + diff --git a/tests/test-unit-tests.php b/tests/test-unit-tests.php deleted file mode 100644 index 3fe894b0e..000000000 --- a/tests/test-unit-tests.php +++ /dev/null @@ -1,10 +0,0 @@ -assertTrue( true ); // The unit tests are running - } -}