diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a642d17 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI + +# Lints and tests every pull request and every push to main. +# +# Until this workflow existed nothing ran the suites on a PR, while every merge to +# main is split straight onto the npm/* branches that consumers install from, so a +# broken merge reached new installs with no gate in between. +# +# Node: every npm workspace's lint + Jest suite with coverage (each workspace's +# jest.config.js holds its own coverageThreshold), on the engines floor and the +# current LTS. +# +# PHP: each composer-packages/* package is installed and tested standalone, the +# way it ships after the subtree split, on the consumer floor (8.2) and newer. + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + node: + name: Node ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 22.19 is the engines floor of every package; 24 is the current LTS. + node: ["22.19", "24"] + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + cache: npm + + - name: Install workspaces + run: npm ci --no-audit --no-fund + + - name: Lint + run: npm run lint + + - name: Test with coverage + run: npm run test:coverage + + php: + name: PHP ${{ matrix.php }} / wp-${{ matrix.package }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ["8.2", "8.3", "8.4"] + package: [phpcs, phpstan] + defaults: + run: + working-directory: composer-packages/${{ matrix.package }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --strict --no-check-lock + + - name: Install the package standalone + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Test + run: composer test diff --git a/.github/workflows/release-php.yml b/.github/workflows/release-php.yml index 967136a..7cd68d9 100644 --- a/.github/workflows/release-php.yml +++ b/.github/workflows/release-php.yml @@ -4,8 +4,10 @@ name: Release Composer Packages # can serve rtcamp/wp-phpcs and rtcamp/wp-phpstan from their own repositories. # Fired by the tag that `vendor/bin/monorepo-builder release` pushes. # -# splitsh.json is the single source of truth for mirror repo -> subdirectory; -# to add or remove a package, edit that file, never this workflow. +# splitsh.json is the single source of truth for mirror repo -> subdirectory. +# To add a package, add it there and list its deploy-key secret in the split +# step's env below (secrets are referenced by name so the job only receives +# the deploy keys, never the whole secrets context). # # Each mirror is pushed with its own SSH deploy key (repo secret # SPLIT_KEY_, "Allow write access" required) rather than a PAT bound to @@ -29,6 +31,11 @@ on: type: boolean required: false default: false + resync_branch: + description: "Only force the mirror branch back onto the split (repairs a diverged mirror); pushes no tag" + type: boolean + required: false + default: false # Pushes to the mirrors must not race each other. concurrency: @@ -159,7 +166,10 @@ jobs: - name: Split ${{ matrix.package_directory }} → ${{ matrix.mirror_repo }} shell: bash env: - SSH_KEY: ${{ secrets[matrix.secret_name] }} + # Named explicitly: a dynamic secrets[...] lookup would hand the job the + # whole secrets context. A new mirror's key has to be listed here. + SPLIT_KEY_PHPCS: ${{ secrets.SPLIT_KEY_PHPCS }} + SPLIT_KEY_PHPSTAN: ${{ secrets.SPLIT_KEY_PHPSTAN }} SECRET_NAME: ${{ matrix.secret_name }} PKG_PATH: ${{ matrix.package_directory }} ORG: ${{ needs.build-matrix.outputs.organization }} @@ -167,11 +177,19 @@ jobs: BRANCH: ${{ needs.build-matrix.outputs.branch }} TAG: ${{ needs.build-matrix.outputs.tag }} FORCE_PUSH: ${{ github.event_name == 'workflow_dispatch' && inputs.force_push }} + RESYNC_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.resync_branch }} run: | set -euo pipefail + # The name comes from splitsh.json, so check its shape before the + # indirect expansion reads a variable by that name. + if [[ ! "$SECRET_NAME" =~ ^SPLIT_KEY_[A-Z0-9_]+$ ]]; then + echo "::error::Refusing deploy-key secret name '$SECRET_NAME': it must look like SPLIT_KEY_." + exit 1 + fi + SSH_KEY="${!SECRET_NAME:-}" if [[ -z "$SSH_KEY" ]]; then - echo "::error::No deploy key in $SECRET_NAME. Add the private half of ${ORG}/${REPO}'s write-enabled deploy key as that repository secret." + echo "::error::No deploy key in $SECRET_NAME. Add the private half of ${ORG}/${REPO}'s write-enabled deploy key as that repository secret, and list it in this step's env." exit 1 fi @@ -215,7 +233,21 @@ jobs: fi remote="git@github.com:$ORG/$REPO.git" - "${push[@]}" "$remote" "$split_sha:refs/heads/$BRANCH" + + # Repair mode: put the mirror branch back onto the split and stop, so a + # mirror that something else committed to (an org bot PR, a hand edit) + # can be fixed without minting a tag that Packagist would publish. + if [[ "$RESYNC_BRANCH" == "true" ]]; then + echo "::warning::resync_branch is on: $ORG/$REPO $BRANCH is being forced onto the split; no tag is pushed." + git push --force "$remote" "$split_sha:refs/heads/$BRANCH" + echo "- **$ORG/$REPO**: \`$BRANCH\` resynced to \`$split_sha\` (no tag)" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + if ! "${push[@]}" "$remote" "$split_sha:refs/heads/$BRANCH"; then + echo "::error::$ORG/$REPO $BRANCH did not fast-forward: something other than this workflow committed to the mirror. Inspect what it holds, then run this workflow by dispatch on the $TAG tag with resync_branch enabled, and re-run this release run." + exit 1 + fi # Packagist versions a package from its tags, so this push is not optional. "${push[@]}" "$remote" "$split_sha:refs/tags/$TAG" diff --git a/.github/workflows/split-npm-packages.yml b/.github/workflows/split-npm-packages.yml index f4d7c85..088815e 100644 --- a/.github/workflows/split-npm-packages.yml +++ b/.github/workflows/split-npm-packages.yml @@ -31,9 +31,9 @@ on: - '.github/workflows/split-npm-packages.yml' workflow_dispatch: -# Pushes branches to this repository; nothing else is needed. +# Read-only by default; only the split job, which pushes the branches, gets write. permissions: - contents: write + contents: read # Branch pushes must not race each other. concurrency: @@ -113,11 +113,16 @@ jobs: name: Split ${{ matrix.package_name }} needs: build-matrix runs-on: ubuntu-latest + # Pushes the npm/* branches of this repository; nothing else is needed. + permissions: + contents: write strategy: fail-fast: false # one bad package must not hide the others matrix: ${{ fromJson(needs.build-matrix.outputs.matrix) }} steps: - - name: Checkout monorepo (full history) + # The persisted GITHUB_TOKEN is what the push below authenticates with, and + # this job uploads no artifacts, so there is nothing for it to leak into. + - name: Checkout monorepo (full history) # zizmor: ignore[artipacked] uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # the split walks every commit that touched the subtree diff --git a/AGENTS.md b/AGENTS.md index b55231d..f5d3500 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ composer install # install s - `node-packages/tailwind-config/` — `@rtcamp/tailwind-config`, Tailwind v4 PostCSS config + `theme.json` webpack plugin - `composer-packages/phpcs/` — `rtcamp/wp-phpcs`, PHP_CodeSniffer standards (`rtCampWP`, `rtCampWP-Basic`) - `composer-packages/phpstan/` — `rtcamp/wp-phpstan`, shared PHPStan baseline for WordPress projects -- `.github/workflows/` — the two subtree-split release workflows; no lint/test CI runs here +- `.github/workflows/` holds `ci.yml` (lint + tests on every PR and push to main) and the two subtree-split release workflows ## Progressive discovery @@ -53,7 +53,7 @@ For full release-mechanism details, see `.github/workflows/release-php.yml` and - Subtree-split artifacts — the `npm/` branches and the `wp-phpcs`/`wp-phpstan` mirror repos — are generated. Never hand-edit them; a diverged target fails the next split run instead of being silently rewritten. - `npm install @rtcamp/wp-tooling` does not resolve from the public npm registry today; the current install path is a git URL (`#npm/wp-tooling`). Don't assume a registry publish exists yet — see Architectural decisions above. - Every one of the six packages carries its own `LICENSE` file — `git subtree split` only carries history of files *inside* the split directory, so the root `LICENSE` never reaches a mirror repo or split branch. -- No CI workflow lints or tests on every push — `.github/workflows/` only holds the two release/split workflows above. Run the Code quality commands locally before opening a PR. +- `ci.yml` runs every workspace lint, every Jest suite with its coverage threshold (Node 22.19 and 24), and each `composer-packages/*` suite installed standalone (PHP 8.2, 8.3 and 8.4) on every PR and push to main. A red CI blocks the merge that would otherwise ship straight onto the `npm/*` branches. - `.vscode/extensions.json` only recommends extensions from verified publishers (Microsoft, GitHub, Red Hat, EditorConfig Foundation) — don't add others, regardless of popularity. ## PR instructions diff --git a/composer-packages/phpcs/CHANGELOG.md b/composer-packages/phpcs/CHANGELOG.md index f974013..178b5c6 100644 --- a/composer-packages/phpcs/CHANGELOG.md +++ b/composer-packages/phpcs/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- `homepage`, `authors` and `support` metadata in `composer.json`, so the Packagist + page links to the monorepo for issues and source (the mirror repository is a + read-only split). + ### Changed - README: a header with the license, PHP and tool-version badges, and a note that the diff --git a/composer-packages/phpcs/composer.json b/composer-packages/phpcs/composer.json index 9be746f..5e6c566 100644 --- a/composer-packages/phpcs/composer.json +++ b/composer-packages/phpcs/composer.json @@ -11,6 +11,17 @@ "WordPress", "static analysis" ], + "homepage": "https://github.com/rtCamp/wp-tooling/tree/main/composer-packages/phpcs", + "authors": [ + { + "name": "rtCamp", + "homepage": "https://rtcamp.com" + } + ], + "support": { + "issues": "https://github.com/rtCamp/wp-tooling/issues", + "source": "https://github.com/rtCamp/wp-tooling/tree/main/composer-packages/phpcs" + }, "require": { "php": ">=8.2", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", diff --git a/composer-packages/phpstan/CHANGELOG.md b/composer-packages/phpstan/CHANGELOG.md index 83244af..9365eff 100644 --- a/composer-packages/phpstan/CHANGELOG.md +++ b/composer-packages/phpstan/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- `homepage`, `authors` and `support` metadata in `composer.json`, so the Packagist + page links to the monorepo for issues and source (the mirror repository is a + read-only split). + ### Changed - README: a header with the license, PHP and tool-version badges, and a note that the diff --git a/composer-packages/phpstan/composer.json b/composer-packages/phpstan/composer.json index 33ae839..b6851ca 100644 --- a/composer-packages/phpstan/composer.json +++ b/composer-packages/phpstan/composer.json @@ -9,6 +9,17 @@ "wordpress", "rtcamp" ], + "homepage": "https://github.com/rtCamp/wp-tooling/tree/main/composer-packages/phpstan", + "authors": [ + { + "name": "rtCamp", + "homepage": "https://rtcamp.com" + } + ], + "support": { + "issues": "https://github.com/rtCamp/wp-tooling/issues", + "source": "https://github.com/rtCamp/wp-tooling/tree/main/composer-packages/phpstan" + }, "require": { "php": ">=8.2", "phpstan/phpstan": "^2.0", diff --git a/node-packages/tailwind-config/tests/GenerateTailwindThemePlugin.test.js b/node-packages/tailwind-config/tests/GenerateTailwindThemePlugin.test.js index fc2466d..e26f9d0 100644 --- a/node-packages/tailwind-config/tests/GenerateTailwindThemePlugin.test.js +++ b/node-packages/tailwind-config/tests/GenerateTailwindThemePlugin.test.js @@ -360,3 +360,92 @@ describe('GenerateTailwindThemePlugin.generate()', () => { ); }); }); + +describe('GenerateTailwindThemePlugin.apply()', () => { + /** + * A webpack compiler double: records each hook's tapped callback. + * + * @return {Object} `{ compiler, taps }`. + */ + const fakeCompiler = () => { + const taps = {}; + const hook = (name) => ({ + tap: (pluginName, fn) => { + taps[name] = { pluginName, fn }; + }, + }); + return { + taps, + compiler: { + hooks: { + run: hook('run'), + watchRun: hook('watchRun'), + afterEmit: hook('afterEmit'), + }, + }, + }; + }; + + let plugin; + let generate; + const themeJsonPath = path.join(os.tmpdir(), 'apply-test', 'theme.json'); + + beforeEach(() => { + plugin = new GenerateTailwindThemePlugin({ + themeJson: themeJsonPath, + tailwindCss: path.join(os.tmpdir(), 'apply-test', 'tailwind.css'), + }); + generate = jest.spyOn(plugin, 'generate').mockImplementation(() => {}); + }); + + afterEach(() => jest.restoreAllMocks()); + + test('taps run, watchRun and afterEmit under the plugin name', () => { + const { compiler, taps } = fakeCompiler(); + plugin.apply(compiler); + expect(Object.keys(taps).sort()).toEqual([ + 'afterEmit', + 'run', + 'watchRun', + ]); + for (const tap of Object.values(taps)) { + expect(tap.pluginName).toBe('GenerateTailwindThemePlugin'); + } + }); + + test('a single build generates once', () => { + const { compiler, taps } = fakeCompiler(); + plugin.apply(compiler); + taps.run.fn(); + expect(generate).toHaveBeenCalledTimes(1); + }); + + test('watch mode generates on the first run and when theme.json changed', () => { + const { compiler, taps } = fakeCompiler(); + plugin.apply(compiler); + + taps.watchRun.fn({ modifiedFiles: undefined }); + taps.watchRun.fn({ modifiedFiles: new Set([themeJsonPath]) }); + + expect(generate).toHaveBeenCalledTimes(2); + }); + + test('watch mode skips rebuilds that did not touch theme.json', () => { + const { compiler, taps } = fakeCompiler(); + plugin.apply(compiler); + + taps.watchRun.fn({ modifiedFiles: new Set(['/src/other.css']) }); + + expect(generate).not.toHaveBeenCalled(); + }); + + test('every emit re-registers theme.json as a file dependency', () => { + const { compiler, taps } = fakeCompiler(); + plugin.apply(compiler); + const compilation = { fileDependencies: new Set() }; + + taps.afterEmit.fn(compilation); + + expect(compilation.fileDependencies.has(themeJsonPath)).toBe(true); + }); +}); diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 6e65458..9ebc433 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `detect-changes --strict` (library: `detectChanges({ strict: true })`): a failed `git diff` exits 1 (throws the new `DetectChangesError`, code `EDIFFFAIL`, exported from `@rtcamp/wp-tooling/ci`) instead of reporting zero changes, which skipped every job gated on the counts and left the run green. Off by default, so existing callers see no change; CI callers should turn it on. The stderr wording keeps the "git diff failed" phrase that rtCamp/wp-shared-workflows matches today. - `wp-tooling coverage-gate` subcommand (`src/ci/coverage-gate/` — `diff`, `reports`, `gate`, `render`, `cli`; `computeGate`, `parseClover`, `parseLcov`, `parseDiffHunks`, `GateError`, `UNMEASURED_POLICIES`, `DEFAULT_UNMEASURED_POLICY` exported from `@rtcamp/wp-tooling/ci`). Diffs committed changes only (`...HEAD`, `--unified=0 --find-renames --diff-filter=AMR --relative`) from `--working-dir`, intersects the added/modified lines with a Clover (`--coverage-clover`) or LCOV report, and exits 1 when the covered share of changed executable lines is below `--threshold` (default 80; exactly at the threshold passes — compared in integer hundredths of a percent, so 8 of 10 at 80 and 147 of 1500 at 9.8 pass). Only Clover `stmt`/`cond` lines count — `type="method"` signature lines don't. Report paths are matched to repo paths by path-segment suffix, so wp-env container paths and absolute LCOV paths resolve. Changed source files missing from the report are listed as "not measured" and never counted as covered; `--unmeasured warn|fail|ignore` (default `warn`) decides whether they annotate a warning, fail the gate, or are only listed. Test and dependency paths are skipped (`--exclude ` replaces the pattern, `--no-exclude` disables it); other out-of-scope files are counted in the summary. Base resolves from `--base`, then the `pull_request`/`merge_group` base SHA in `$GITHUB_EVENT_PATH`, then `origin/$GITHUB_BASE_REF`; with none (push events) the gate is skipped with a notice. A missing or empty report, or a missing base commit, exits 2 rather than passing. `--output github` writes `changed-lines`, `covered-lines`, `percent`, `unmeasured-files`, `passed` step outputs, a `$GITHUB_STEP_SUMMARY` table with per-file missed line ranges, and per-file annotations. An empty flag value counts as not given, so workflows can pass unset inputs through. `--soft-fail` exits 0 and downgrades failure annotations to warnings; `--dry-run` previews the files and annotations without emitting workflow commands. `--threshold` accepts a plain decimal from 0 to 100 with at most two decimals. For rtCamp/wp-shared-workflows#36. - `lint/i18n` scaffold — a standalone `phpcs.i18n.xml.dist` running only `WordPress.WP.I18n` against the project's text domain, plus a `lint:i18n` composer script. Standalone because the `text_domain` property is project-specific and the engine never edits an existing `phpcs.xml.dist`. The `text_domain` input is `required` (discovered from `.wp-tooling.json` `textDomain` when present) rather than defaulted, because the sniff silently loses its `MissingArgDomain` / `TextDomainMismatch` checks when the property is wrong or unset. The rendered ruleset deliberately omits `default` from the allowed domains — listing it downgrades a missing domain argument from the `MissingArgDomain` error to the `MissingArgDomainDefault` warning — with a commented opt-in for projects that intentionally reuse core strings. Companion to the i18n lens skill in `rtcamp/wp-devtools`. - `utility/*` package scaffolds — `utility/cache`, `utility/transients`, `utility/logger`, `utility/timer` and `utility/feature-selector` wire the `rtcamp/wp-primitives` `Utils\*` classes with no template files. Each is `source: "package"`: zero files written, `rtcamp/wp-primitives: ^2.0` reported under `developer.install.composer`, and one `ai.wiring` accessor snippet for `/Helpers/Util.php` under anchor `// scaffold:utility/`. `context_slug` is discovered from `composer.json:name` and normalised to snake_case (`rtcamp/acme-blog` → `rtcamp_acme_blog`); pass `--context_slug` to override. Nothing is installed and nothing is wired automatically. These utilities are neither `Registrable` nor `Shareable`, so the wiring targets the consumer's static helper rather than a module or `Main::CLASSES` — the `Loader` would otherwise construct them with a missing or empty constructor argument. @@ -43,6 +44,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Block and script module asset paths now derive the project root from `base_path`, so nested or root-level PSR-4 directories resolve build output correctly. - Generated block JavaScript follows the current shared ESLint formatting rules. - PHPCS scaffolds exclude intentionally broken AI evaluation fixtures. The standalone server profiler uses short arrays and documents the specific PHPCS exceptions required by WP-CLI evaluation, privileged CLI query data and STDERR diagnostics. +- Feature hooks: `readEnv`/`setEnv` and `readDefine`/`setDefine` treat the key or define name as a literal. They were interpolated into a regular expression, so a name containing `.` or another metacharacter could read or flip a different line. +- `wp-tooling features`: when `npm install` fails after enabling a feature, the retry commands it prints no longer put runtime dependencies under `--save-dev`, and a failed interactive change reads "Failed to enable " rather than "Failed to enabling ". +- `lint/eslint`, `lint/stylelint` and `setup/tailwind` asked for `@rtcamp/eslint-config`, `@rtcamp/stylelint-config` and `@rtcamp/tailwind-config` at `^0.1.0` from the npm registry. None of them is published there (all three 404, and the packages are at 1.0.0), so `npm install` failed, and `features --enable tailwind --record-deps` wrote a spec into package.json that broke the project's next `npm install`. They now use the git specs the skeletons already use (`github:rtCamp/wp-tooling#npm/`). +- `lint/phpcs/full` now extends `rtCampWP-Basic` from `rtcamp/wp-phpcs` (and reports it plus `dealerdirect/phpcodesniffer-composer-installer` as dev dependencies) instead of `vendor/rtcamp/wp-framework/phpcs.xml.dist`. The framework export-ignores that file, so every dist install, the Composer default, ended up with a ruleset pointing at a file that was not there. The generated ruleset also sets `testVersion` to `8.2-`, which PHPCompatibilityWP needs. +- `detect-changes --output github` and `coverage-gate --output github` write multi-line outputs under a random per-output heredoc delimiter instead of the fixed `EOF_WP_TOOLING`. The file lists come from the pull request, so a PR adding a file named `EOF_WP_TOOLING` followed by one named `php-count=0` could close the heredoc early and override the real count, skipping the PHP jobs. - `discover_from: composer.json:autoload.psr-4` now resolves **path** inputs (`base_path`, `*_path`, `*_dir`) from the map entry's directory instead of skipping them, grafted the same way namespaces already were — with a root of `Acme\Blog\` → `inc/`, a `base_path` default of `includes/Services` resolves to `inc/Services`. Previously the namespace was grafted but the directory kept the manifest default, so any project not laid out under `includes/` got a correctly-namespaced class written outside its autoload root, where it never loaded. Affects the 12 bundled scaffolds that declare PSR-4 discovery on `base_path`. Projects already using `includes/`, projects without a `composer.json`, and callers that pass `--base_path` explicitly are unaffected. - Feature finalization rolls back successful hooks when detection or state persistence fails, and defers next-step messages until persistence succeeds. Setup/reinit identity and version writes share rollback protection. Rename batches reuse one plan, support chains and swaps, and detect case-insensitive destination collisions. Cleanup targets and explicit example markers reject malformed values before mutation. - Init validates shared setup/manage selections before mutation, confirms setup choices before applying identity, and uses current persisted tokens for reinitialization. Version-only edits now update files and identity; reinit preserves one-shot example choices and refuses corrupt identity. diff --git a/node-packages/wp-tooling/docs/examples.md b/node-packages/wp-tooling/docs/examples.md index f75969d..e02da3d 100644 --- a/node-packages/wp-tooling/docs/examples.md +++ b/node-packages/wp-tooling/docs/examples.md @@ -97,7 +97,7 @@ Developer actions: composer require rtcamp/wp-primitives:^2.0 composer require --dev rtcamp/wp-phpcs:^1.0 phpunit/phpunit:^12.0 yoast/phpunit-polyfills:^4.0 brain/monkey:^2.6 - npm install --save-dev eslint@^10.0.0 @wordpress/eslint-plugin@^25.1.0 @rtcamp/eslint-config@^0.1.0 + npm install --save-dev eslint@^10.0.0 @wordpress/eslint-plugin@^25.1.0 @rtcamp/eslint-config@^1.0.0 Scripts to add to composer.json: "lint:php": "phpcs --standard=phpcs.xml.dist", "test": "phpunit", ... diff --git a/node-packages/wp-tooling/scaffolds/lint/eslint/scaffold.json b/node-packages/wp-tooling/scaffolds/lint/eslint/scaffold.json index 761f93b..9046df9 100644 --- a/node-packages/wp-tooling/scaffolds/lint/eslint/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/lint/eslint/scaffold.json @@ -13,7 +13,7 @@ "npm_dev_dependencies": { "eslint": "^10.0.0", "@wordpress/eslint-plugin": "^25.1.0", - "@rtcamp/eslint-config": "^0.1.0" + "@rtcamp/eslint-config": "github:rtCamp/wp-tooling#npm/eslint-config" }, "scripts": { "npm": { diff --git a/node-packages/wp-tooling/scaffolds/lint/stylelint/scaffold.json b/node-packages/wp-tooling/scaffolds/lint/stylelint/scaffold.json index 79c3009..1199949 100644 --- a/node-packages/wp-tooling/scaffolds/lint/stylelint/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/lint/stylelint/scaffold.json @@ -14,7 +14,7 @@ "stylelint": "^16.8.2", "@wordpress/stylelint-config": "^23.37.0", "stylelint-scss": "^6.4.0", - "@rtcamp/stylelint-config": "^0.1.0" + "@rtcamp/stylelint-config": "github:rtCamp/wp-tooling#npm/stylelint-config" }, "scripts": { "npm": { diff --git a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md index a230cc0..8f8a4e6 100644 --- a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md +++ b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md @@ -315,7 +315,7 @@ Developer actions (run these yourself): npm install --save-dev \ eslint@^10.0.0 \ @wordpress/eslint-plugin@^25.1.0 \ - @rtcamp/eslint-config@^0.1.0 + @rtcamp/eslint-config@github:rtCamp/wp-tooling#npm/eslint-config Scripts to add to composer.json: diff --git a/node-packages/wp-tooling/scaffolds/setup/tailwind/scaffold.json b/node-packages/wp-tooling/scaffolds/setup/tailwind/scaffold.json index 9292d26..e0a7d3d 100644 --- a/node-packages/wp-tooling/scaffolds/setup/tailwind/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/setup/tailwind/scaffold.json @@ -29,7 +29,7 @@ "gitignore": [ "{{css_dir}}/_tailwind-theme.css" ] }, "npm_dev_dependencies": { - "@rtcamp/tailwind-config": "^0.1.0", + "@rtcamp/tailwind-config": "github:rtCamp/wp-tooling#npm/tailwind-config", "tailwindcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0" } diff --git a/node-packages/wp-tooling/skills/setup/SKILL.md b/node-packages/wp-tooling/skills/setup/SKILL.md index a230cc0..8f8a4e6 100644 --- a/node-packages/wp-tooling/skills/setup/SKILL.md +++ b/node-packages/wp-tooling/skills/setup/SKILL.md @@ -315,7 +315,7 @@ Developer actions (run these yourself): npm install --save-dev \ eslint@^10.0.0 \ @wordpress/eslint-plugin@^25.1.0 \ - @rtcamp/eslint-config@^0.1.0 + @rtcamp/eslint-config@github:rtCamp/wp-tooling#npm/eslint-config Scripts to add to composer.json: diff --git a/node-packages/wp-tooling/src/ci/detect-changes.js b/node-packages/wp-tooling/src/ci/detect-changes.js index 08a1cea..06a8627 100644 --- a/node-packages/wp-tooling/src/ci/detect-changes.js +++ b/node-packages/wp-tooling/src/ci/detect-changes.js @@ -13,6 +13,7 @@ 'use strict'; const { execFileSync } = require('child_process'); +const crypto = require('crypto'); const fs = require('fs'); /** @@ -37,6 +38,19 @@ const DEFAULT_PATTERNS = { const DEFAULT_IGNORE = /\.github\/(?!workflows)(?!actions)|\.wordpress-org\/|docs\//; +/** + * Thrown in strict mode when the changed files cannot be computed, so a caller + * that gates jobs on the counts fails instead of reading "nothing changed". + */ +class DetectChangesError extends Error { + constructor(message, details = {}) { + super(message); + this.name = 'DetectChangesError'; + this.code = 'EDIFFFAIL'; + Object.assign(this, details); + } +} + /** * Compute per-bucket change counts (and optionally file lists) for a set of files. * @@ -45,14 +59,16 @@ const DEFAULT_IGNORE = * @param {RegExp|string|null} [options.ignore] Override default ignore regex. `null` disables ignoring. * @param {string} [options.base] Override the diff base ref. * @param {boolean} [options.includeFiles] When true, include `-files` arrays alongside counts. + * @param {boolean} [options.strict] When true, a failed `git diff` throws DetectChangesError instead of counting as no changes. * @return {Object} Counts keyed `-count`. When `includeFiles` is true, matching `-files` arrays are included. + * @throws {DetectChangesError} In strict mode, when `git diff` fails. */ function detectChanges(options = {}) { const ignore = resolveIgnore(options.ignore); const files = options.files !== undefined ? normaliseFiles(options.files) - : gitDiffFiles(options.base); + : gitDiffFiles(options.base, options.strict === true); const ignored = []; const relevant = []; @@ -143,13 +159,17 @@ function normaliseFiles(input) { /** * Run `git diff --name-only` against the resolved base ref. * - * Fails soft -- on any git error (shallow clone, missing ref) writes a clear - * message to stderr and returns an empty list rather than throwing. + * By default it fails soft: on any git error (shallow clone, missing ref) it + * writes a clear message to stderr and returns an empty list. That reads as + * "nothing changed", which skips every gated job green, so CI callers should + * pass `strict` and get a DetectChangesError instead. * - * @param {string} [explicitBase] - * @return {string[]} Changed file paths, or `[]` on failure. + * @param {string} [explicitBase] + * @param {boolean} [strict=false] Throw instead of returning `[]` on failure. + * @return {string[]} Changed file paths, or `[]` on a soft failure. + * @throws {DetectChangesError} When `strict` and `git diff` fails. */ -function gitDiffFiles(explicitBase) { +function gitDiffFiles(explicitBase, strict = false) { const base = explicitBase || resolveBaseRef(); try { const out = execFileSync('git', ['diff', '--name-only', base, 'HEAD'], { @@ -159,6 +179,12 @@ function gitDiffFiles(explicitBase) { return out.split(/\r?\n/).filter(Boolean); } catch (err) { const detail = (err.stderr || err.message || '').toString().trim(); + if (strict) { + throw new DetectChangesError( + `git diff failed against "${base}" (${detail})`, + { base, detail } + ); + } process.stderr.write( `detect-changes: git diff failed against "${base}" (${detail}). Treating as no changes.\n` ); @@ -304,6 +330,9 @@ function parseArgs(argv) { case '--include-files': opts.includeFiles = true; break; + case '--strict': + opts.strict = true; + break; case '--help': case '-h': opts.help = true; @@ -329,14 +358,26 @@ function readFilesArg(filesArg) { return fs.readFileSync(filesArg, 'utf8'); } -/** GHA heredoc delimiter. File paths cannot contain newlines, so a fixed sentinel is safe. */ -const GHA_HEREDOC = 'EOF_WP_TOOLING'; +/** + * A fresh, unguessable heredoc delimiter for one multi-line output. + * + * The file list comes from the pull request, so a fixed sentinel is forgeable: + * a PR adding a file named after it closes the heredoc early, and a following + * file named `php-count=0` then overrides the real count, skipping the jobs + * gated on it. + * + * @return {string} Delimiter of the form `ghadelim_<32 hex chars>`. + */ +function makeHeredocDelimiter() { + return `ghadelim_${crypto.randomBytes(16).toString('hex')}`; +} /** * Render one `$GITHUB_OUTPUT` line for a scalar or array value. * - * Arrays use the GitHub Actions heredoc syntax for multi-line outputs. - * Empty arrays render as `key=` (no heredoc) for compactness. + * Arrays use the GitHub Actions heredoc syntax for multi-line outputs, under a + * random delimiter that no value line may equal. Empty arrays render as `key=` + * (no heredoc) for compactness. * * @param {string} key * @param {number|string|string[]} value @@ -347,7 +388,11 @@ function formatGithubLine(key, value) { if (value.length === 0) { return `${key}=`; } - return `${key}<<${GHA_HEREDOC}\n${value.join('\n')}\n${GHA_HEREDOC}`; + let delimiter = makeHeredocDelimiter(); + while (value.includes(delimiter)) { + delimiter = makeHeredocDelimiter(); + } + return `${key}<<${delimiter}\n${value.join('\n')}\n${delimiter}`; } return `${key}=${value}`; } @@ -416,6 +461,7 @@ function printUsage() { ' --files Read newline-delimited file list from a path', ' or stdin (`-`); skip git entirely.', ' --include-files Include the matching file paths per bucket in the output.', + ' --strict Exit 1 when git diff fails instead of reporting no changes.', ' --dry-run With --output github, preview to stdout (no file write).', ' --help, -h Print this help.', '', @@ -427,7 +473,7 @@ function printUsage() { * Run the CLI. Returns the intended exit code. * * @param {string[]} argv argv slice (without `node` and script path). - * @return {number} Process exit code (0 on success, 1 on I/O failure, 2 on usage error). + * @return {number} Process exit code (0 on success, 1 on I/O failure or a strict-mode diff failure, 2 on usage error). */ function runCli(argv) { let opts; @@ -473,12 +519,24 @@ function runCli(argv) { } } - const result = detectChanges({ - files, - ignore: opts.ignore, - base: opts.base, - includeFiles: opts.includeFiles === true, - }); + let result; + try { + result = detectChanges({ + files, + ignore: opts.ignore, + base: opts.base, + includeFiles: opts.includeFiles === true, + strict: opts.strict === true, + }); + } catch (err) { + if (err instanceof DetectChangesError) { + process.stderr.write( + `detect-changes: ${err.message}. Failing because --strict is set.\n` + ); + return 1; + } + throw err; + } emit(result, opts.output, opts.dryRun === true); return 0; @@ -489,6 +547,7 @@ module.exports = { runCli, takeValue, formatGithubLine, + DetectChangesError, DEFAULT_PATTERNS, DEFAULT_IGNORE, }; diff --git a/node-packages/wp-tooling/src/ci/index.js b/node-packages/wp-tooling/src/ci/index.js index 0905c9b..914c3ef 100644 --- a/node-packages/wp-tooling/src/ci/index.js +++ b/node-packages/wp-tooling/src/ci/index.js @@ -6,6 +6,7 @@ const { detectChanges, + DetectChangesError, DEFAULT_PATTERNS, DEFAULT_IGNORE, } = require('./detect-changes'); @@ -21,6 +22,7 @@ const { module.exports = { detectChanges, + DetectChangesError, DEFAULT_PATTERNS, DEFAULT_IGNORE, computeGate, diff --git a/node-packages/wp-tooling/src/init/features.js b/node-packages/wp-tooling/src/init/features.js index 00d84da..33aa5ac 100644 --- a/node-packages/wp-tooling/src/init/features.js +++ b/node-packages/wp-tooling/src/init/features.js @@ -193,6 +193,16 @@ const firstMissingAncestor = (dir) => { return cur; }; +/** + * Escape a literal for use inside a RegExp, so a dotenv key or define name + * such as `APP.DEBUG` matches only itself. + * + * @param {string} value - Literal text. + * @return {string} Pattern source matching exactly `value`. + */ +const escapeRegExp = (value) => + String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + /** * Build the FeatureApi handed to every hook / probe. All mutating calls record * an undo entry in `api._journal` so a feature can be rolled back on failure. @@ -361,7 +371,10 @@ const makeFeatureApi = (root, identity, ui) => { } const raw = fs.readFileSync(abs, 'utf8'); const match = raw.match( - new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*(.*)$`, 'm') + new RegExp( + `^[ \\t]*${escapeRegExp(key)}[ \\t]*=[ \\t]*(.*)$`, + 'm' + ) ); return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; }, @@ -371,7 +384,10 @@ const makeFeatureApi = (root, identity, ui) => { setEnv(rel, key, value) { const raw = api.read(rel) || ''; const line = `${key}=${value}`; - const re = new RegExp(`^[ \\t]*${key}[ \\t]*=.*$`, 'm'); + const re = new RegExp( + `^[ \\t]*${escapeRegExp(key)}[ \\t]*=.*$`, + 'm' + ); let next; if (re.test(raw)) { next = raw.replace(re, line); @@ -397,7 +413,7 @@ const makeFeatureApi = (root, identity, ui) => { const raw = fs.readFileSync(abs, 'utf8'); const match = raw.match( new RegExp( - `^[ \\t]*define\\(\\s*['"]${name}['"]\\s*,\\s*(true|false)\\s*\\)`, + `^[ \\t]*define\\(\\s*['"]${escapeRegExp(name)}['"]\\s*,\\s*(true|false)\\s*\\)`, 'im' ) ); @@ -414,7 +430,7 @@ const makeFeatureApi = (root, identity, ui) => { throw new Error(`setDefine: ${rel} not found`); } const re = new RegExp( - `(^[ \\t]*define\\(\\s*['"]${name}['"]\\s*,\\s*)(?:true|false)(\\s*\\))`, + `(^[ \\t]*define\\(\\s*['"]${escapeRegExp(name)}['"]\\s*,\\s*)(?:true|false)(\\s*\\))`, 'im' ); if (!re.test(raw)) { diff --git a/node-packages/wp-tooling/src/scaffolds/features.js b/node-packages/wp-tooling/src/scaffolds/features.js index 5a00db2..92d5400 100644 --- a/node-packages/wp-tooling/src/scaffolds/features.js +++ b/node-packages/wp-tooling/src/scaffolds/features.js @@ -359,6 +359,7 @@ async function runInteractive(opts) { const summaries = []; for (const change of changes) { const verb = change.target ? 'Enabling' : 'Disabling'; + const action = change.target ? 'enable' : 'disable'; const s = spinner(`${verb} ${change.id}...`); s.start(); try { @@ -369,7 +370,7 @@ async function runInteractive(opts) { s.succeed(`${summary.action} ${change.id}`); summaries.push(summary); } catch (err) { - s.fail(`Failed to ${verb.toLowerCase()} ${change.id}`); + s.fail(`Failed to ${action} ${change.id}`); throw err; } } @@ -461,7 +462,10 @@ function printHumanReport(summaries, opts) { writeln( ' re-run the install yourself once the cause is fixed:' ); - for (const [p, v] of [...prod, ...dev]) { + for (const [p, v] of prod) { + writeln(` npm install ${p}@${v}`); + } + for (const [p, v] of dev) { writeln(` npm install --save-dev ${p}@${v}`); } } else if (s.installed) { diff --git a/node-packages/wp-tooling/tests/ci/detect-changes-git.test.js b/node-packages/wp-tooling/tests/ci/detect-changes-git.test.js new file mode 100644 index 0000000..9e59c3e --- /dev/null +++ b/node-packages/wp-tooling/tests/ci/detect-changes-git.test.js @@ -0,0 +1,344 @@ +'use strict'; + +/** + * detect-changes against real git repositories: how the diff base is resolved + * in pull request and push mode, what a failed diff does with and without + * --strict, and that a crafted file list cannot forge a step output. + */ + +const { execFileSync } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + detectChanges, + runCli, + formatGithubLine, + DetectChangesError, +} = require('../../src/ci/detect-changes'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + GIT_CONFIG_NOSYSTEM: '1', +}; + +const ENV_KEYS = ['GITHUB_BASE_REF', 'GITHUB_EVENT_PATH', 'GITHUB_OUTPUT']; + +const tempDirs = []; +let savedEnv; +let savedCwd; +let stderrChunks; + +function git(cwd, ...args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, ...GIT_ENV }, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function makeTmpDir(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function commitFile(repo, file, body, message) { + fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true }); + fs.writeFileSync(path.join(repo, file), body); + git(repo, 'add', '--', file); + git(repo, 'commit', '-q', '-m', message); + return git(repo, 'rev-parse', 'HEAD'); +} + +/** + * A working repo with an `origin` remote (a local bare repo) holding `main`, + * and a `feature` branch checked out one commit ahead of it. + */ +function makePullRequestRepo() { + const remote = makeTmpDir('dc-remote-'); + git(remote, 'init', '-q', '--bare', '-b', 'main'); + const repo = makeTmpDir('dc-repo-'); + git(repo, 'init', '-q', '-b', 'main'); + commitFile(repo, 'README.md', 'base\n', 'base'); + git(repo, 'remote', 'add', 'origin', remote); + git(repo, 'push', '-q', 'origin', 'main'); + git(repo, 'checkout', '-q', '-b', 'feature'); + commitFile(repo, 'src/feature.js', 'module.exports = 1;\n', 'feature js'); + commitFile(repo, 'inc/Feature.php', '} Outputs. + */ +function parseGithubOutput(text) { + const out = {}; + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const heredoc = /^([^=<]+)<<(.+)$/.exec(line); + if (heredoc) { + const [, key, delimiter] = heredoc; + const body = []; + i++; + while (i < lines.length && lines[i] !== delimiter) { + body.push(lines[i]); + i++; + } + out[key] = body.join('\n'); + continue; + } + const eq = line.indexOf('='); + if (eq > 0) { + out[line.slice(0, eq)] = line.slice(eq + 1); + } + } + return out; +} + +beforeEach(() => { + savedCwd = process.cwd(); + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + stderrChunks = []; + jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrChunks.push(String(chunk)); + return true; + }); + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); +}); + +afterEach(() => { + process.chdir(savedCwd); + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('detectChanges against a git repository', () => { + test('pull request mode diffs HEAD against origin/', () => { + const repo = makePullRequestRepo(); + process.chdir(repo); + process.env.GITHUB_BASE_REF = 'main'; + + const result = detectChanges({ includeFiles: true }); + + expect(result['total-files']).toEqual([ + 'inc/Feature.php', + 'src/feature.js', + ]); + expect(result['php-count']).toBe(1); + expect(result['js-count']).toBe(1); + }); + + test('push mode diffs against the event payload before SHA', () => { + const repo = makeTmpDir('dc-push-'); + git(repo, 'init', '-q', '-b', 'main'); + const before = commitFile(repo, 'README.md', 'one\n', 'one'); + commitFile(repo, 'style.scss', 'a{}\n', 'two'); + commitFile(repo, 'plugin.php', ' { + const repo = makeTmpDir('dc-newbranch-'); + git(repo, 'init', '-q', '-b', 'main'); + commitFile(repo, 'README.md', 'one\n', 'one'); + commitFile(repo, 'a.js', '1;\n', 'two'); + commitFile(repo, 'b.php', ' { + const repo = makePullRequestRepo(); + process.chdir(repo); + process.env.GITHUB_BASE_REF = 'does-not-exist'; + const base = git(repo, 'rev-parse', 'HEAD~1'); + + const result = detectChanges({ base, includeFiles: true }); + + expect(result['total-files']).toEqual(['inc/Feature.php']); + }); + + test('a failed diff is soft by default: no changes plus a stderr note', () => { + const repo = makePullRequestRepo(); + process.chdir(repo); + + const result = detectChanges({ base: 'no-such-ref' }); + + expect(result['total-count']).toBe(0); + expect(stderrChunks.join('')).toMatch( + /git diff failed against "no-such-ref"[\s\S]*Treating as no changes/ + ); + }); + + test('strict mode throws DetectChangesError instead', () => { + const repo = makePullRequestRepo(); + process.chdir(repo); + + let thrown; + try { + detectChanges({ base: 'no-such-ref', strict: true }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(DetectChangesError); + expect(thrown.code).toBe('EDIFFFAIL'); + expect(thrown.base).toBe('no-such-ref'); + expect(thrown.message).toMatch(/git diff failed against "no-such-ref"/); + }); + + test('a pull request base that cannot be fetched fails strict mode', () => { + const repo = makePullRequestRepo(); + process.chdir(repo); + process.env.GITHUB_BASE_REF = 'gone'; + + expect(() => detectChanges({ strict: true })).toThrow( + DetectChangesError + ); + expect(stderrChunks.join('')).toMatch(/git fetch origin gone failed/); + }); +}); + +describe('runCli --strict', () => { + test('exits 1, writes no outputs and keeps the "git diff failed" wording', () => { + const repo = makePullRequestRepo(); + process.chdir(repo); + const outPath = path.join(makeTmpDir('dc-out-'), 'output'); + fs.writeFileSync(outPath, ''); + process.env.GITHUB_OUTPUT = outPath; + + const code = runCli([ + '--strict', + '--base', + 'no-such-ref', + '--output', + 'github', + ]); + + expect(code).toBe(1); + expect(fs.readFileSync(outPath, 'utf8')).toBe(''); + // rtCamp/wp-shared-workflows greps for this phrase; keep it stable. + expect(stderrChunks.join('')).toMatch( + /git diff failed against "no-such-ref"[\s\S]*--strict/ + ); + }); + + test('passes through normally when the diff works', () => { + const repo = makePullRequestRepo(); + process.chdir(repo); + process.env.GITHUB_BASE_REF = 'main'; + const outPath = path.join(makeTmpDir('dc-out-'), 'output'); + fs.writeFileSync(outPath, ''); + process.env.GITHUB_OUTPUT = outPath; + + expect(runCli(['--strict', '--output', 'github'])).toBe(0); + expect( + parseGithubOutput(fs.readFileSync(outPath, 'utf8')) + ).toMatchObject({ + 'total-count': '2', + 'php-count': '1', + 'js-count': '1', + }); + }); +}); + +describe('GitHub output heredocs', () => { + test('a crafted file list cannot close the heredoc and forge a count', () => { + const list = path.join(makeTmpDir('dc-list-'), 'files.txt'); + // The old fixed delimiter, then a file whose name is a forged output. + fs.writeFileSync( + list, + ['inc/Plugin.php', 'EOF_WP_TOOLING', 'php-count=0', ''].join('\n') + ); + const outPath = path.join(makeTmpDir('dc-out-'), 'output'); + fs.writeFileSync(outPath, ''); + process.env.GITHUB_OUTPUT = outPath; + + expect( + runCli(['--files', list, '--include-files', '--output', 'github']) + ).toBe(0); + const outputs = parseGithubOutput(fs.readFileSync(outPath, 'utf8')); + + expect(outputs['php-count']).toBe('1'); + expect(outputs['total-files'].split('\n')).toEqual([ + 'inc/Plugin.php', + 'EOF_WP_TOOLING', + 'php-count=0', + ]); + }); + + test('every multi-line output gets its own random delimiter', () => { + const first = formatGithubLine('a-files', ['x.php']); + const second = formatGithubLine('a-files', ['x.php']); + + const delimiter = (block) => + /<<(ghadelim_[0-9a-f]{32})\n/.exec(block)[1]; + expect(delimiter(first)).not.toBe(delimiter(second)); + }); + + test('draws a new delimiter when a value line equals the first one', () => { + const clash = Buffer.alloc(16, 0xab); + const fresh = Buffer.alloc(16, 0xcd); + jest.spyOn(crypto, 'randomBytes') + .mockImplementationOnce(() => clash) + .mockImplementationOnce(() => fresh); + const clashing = `ghadelim_${clash.toString('hex')}`; + + const block = formatGithubLine('x-files', [clashing, 'b.php']); + + expect(block).toBe( + `x-files< { + expect(formatGithubLine('php-count', 3)).toBe('php-count=3'); + expect(formatGithubLine('php-files', [])).toBe('php-files='); + }); +}); + +test('@rtcamp/wp-tooling/ci exposes DetectChangesError', () => { + const ci = require('../../src/ci'); + expect(ci.DetectChangesError).toBe(DetectChangesError); + expect(new ci.DetectChangesError('x')).toBeInstanceOf(Error); +}); diff --git a/node-packages/wp-tooling/tests/ci/detect-changes.test.js b/node-packages/wp-tooling/tests/ci/detect-changes.test.js index b48ffea..8e9aa42 100644 --- a/node-packages/wp-tooling/tests/ci/detect-changes.test.js +++ b/node-packages/wp-tooling/tests/ci/detect-changes.test.js @@ -543,10 +543,10 @@ describe('runCli', () => { const written = fs.readFileSync(outPath, 'utf8'); expect(written).toMatch(/total-count=2/); expect(written).toMatch( - /js-files< { const out = stdoutChunks.join(''); expect(out).toMatch(/\[dry-run\] would append/); expect(out).toMatch( - /js-files< fs.readFileSync(path.join(root, rel), 'utf8'); + +/** + * Replay the api journal in reverse, as a failed transaction does. + * + * @param {Object} api - FeatureApi whose journal to unwind. + */ +const rollback = async (api) => { + for (let i = api._journal.length - 1; i >= 0; i--) { + await api._journal[i].undo(); + } + api._journal.length = 0; +}; + +let root; +let api; + +beforeEach(() => { + root = makeRoot('feature-api-'); + api = makeFeatureApi(root, IDENTITY, UI); +}); + +afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + +describe('readEnv / setEnv (dotenv files, the HMR toggle)', () => { + it('reads a value, trimming whitespace and quotes', () => { + touch(root, '.env.local', '# comment\nENABLE_HMR = "false"\nOTHER=1\n'); + expect(api.readEnv('.env.local', 'ENABLE_HMR')).toBe('false'); + expect(api.readEnv('.env.local', 'OTHER')).toBe('1'); + }); + + it('returns null for a missing file or key', () => { + expect(api.readEnv('.env.local', 'ENABLE_HMR')).toBeNull(); + touch(root, '.env.local', 'OTHER=1\n'); + expect(api.readEnv('.env.local', 'ENABLE_HMR')).toBeNull(); + }); + + it('treats a key as a literal, not a pattern', () => { + touch(root, '.env.local', 'APPxDEBUG=1\n'); + expect(api.readEnv('.env.local', 'APP.DEBUG')).toBeNull(); + }); + + it('replaces an existing line in place', () => { + touch(root, '.env.local', 'A=1\nENABLE_HMR=true\nB=2\n'); + api.setEnv('.env.local', 'ENABLE_HMR', 'false'); + expect(raw(root, '.env.local')).toBe('A=1\nENABLE_HMR=false\nB=2\n'); + }); + + it('appends to a file with or without a trailing newline', () => { + touch(root, 'a.env', 'A=1\n'); + touch(root, 'b.env', 'A=1'); + api.setEnv('a.env', 'ENABLE_HMR', 'true'); + api.setEnv('b.env', 'ENABLE_HMR', 'true'); + expect(raw(root, 'a.env')).toBe('A=1\nENABLE_HMR=true\n'); + expect(raw(root, 'b.env')).toBe('A=1\nENABLE_HMR=true\n'); + }); + + it('creates the file when it is missing, and rollback removes it again', async () => { + api.setEnv('.env.local', 'ENABLE_HMR', 'true'); + expect(raw(root, '.env.local')).toBe('ENABLE_HMR=true\n'); + + await rollback(api); + + expect(fs.existsSync(path.join(root, '.env.local'))).toBe(false); + }); +}); + +describe('readDefine / setDefine (PHP defines, the Tailwind toggle)', () => { + const ENTRY = 'functions.php'; + const NAME = 'DEMO_ENABLE_TAILWIND'; + + it('reads true, false and a mixed-case value', () => { + touch(root, ENTRY, ` { + touch(root, ENTRY, ` { + touch(root, ENTRY, ` { + const before = ` { + expect(() => api.setDefine('missing.php', NAME, true)).toThrow( + /setDefine: missing\.php not found/ + ); + touch(root, ENTRY, ' api.setDefine(ENTRY, NAME, true)).toThrow( + new RegExp( + `define\\( '${NAME}', \\.\\.\\. \\) not found in ${ENTRY}` + ) + ); + }); +}); + +describe('writeFlag / hasDep', () => { + it('writes .wp-features.json, merging into existing flags, and rolls back', async () => { + api.writeFlag('tailwind', true); + expect(JSON.parse(raw(root, '.wp-features.json'))).toEqual({ + tailwind: true, + }); + api.writeFlag('hmr', false); + expect(JSON.parse(raw(root, '.wp-features.json'))).toEqual({ + tailwind: true, + hmr: false, + }); + + await rollback(api); + + expect(fs.existsSync(path.join(root, '.wp-features.json'))).toBe(false); + }); + + it('restores the previous flags file on rollback', async () => { + touch(root, '.wp-features.json', '{\n\t"tailwind": false\n}\n'); + api.writeFlag('tailwind', true); + await rollback(api); + expect(raw(root, '.wp-features.json')).toBe( + '{\n\t"tailwind": false\n}\n' + ); + }); + + it('finds a dependency in either section, and none without package.json', () => { + expect(api.hasDep('tailwindcss')).toBe(false); + touch( + root, + 'package.json', + JSON.stringify({ + dependencies: { react: '^18' }, + devDependencies: { tailwindcss: '^4' }, + }) + ); + expect(api.hasDep('tailwindcss')).toBe(true); + expect(api.hasDep('react')).toBe(true); + expect(api.hasDep('left-pad')).toBe(false); + }); +}); + +describe('write / remove / editJson', () => { + it('rollback of a nested write removes the directories it created', async () => { + api.write('src/css/frontend/tailwind.css', '@import "tailwindcss";\n'); + expect(fs.existsSync(path.join(root, 'src/css/frontend'))).toBe(true); + + await rollback(api); + + expect(fs.existsSync(path.join(root, 'src'))).toBe(false); + }); + + it('remove restores the file on rollback and refuses directories', async () => { + touch(root, 'postcss.config.js', 'module.exports = {};\n'); + api.remove('postcss.config.js'); + expect(fs.existsSync(path.join(root, 'postcss.config.js'))).toBe(false); + api.remove('not-there.js'); // a no-op, not an error + + await rollback(api); + + expect(raw(root, 'postcss.config.js')).toBe('module.exports = {};\n'); + fs.mkdirSync(path.join(root, 'build')); + expect(() => api.remove('build')).toThrow( + /refusing to delete directory/ + ); + }); + + it('editJson names the file when it is not valid JSON', () => { + touch(root, 'package.json', '{ nope'); + expect(() => api.editJson('package.json', () => {})).toThrow( + /editJson: package\.json is not valid JSON/ + ); + }); + + it('editJson create takes its indentation from indentFrom', () => { + touch(root, 'package.json', '{\n "name": "demo"\n}\n'); + api.editJson( + 'config/tooling.json', + (obj) => { + obj.a = 1; + }, + { create: true, indentFrom: 'package.json' } + ); + expect(raw(root, 'config/tooling.json')).toBe('{\n "a": 1\n}\n'); + }); + + it('editJson without create refuses a missing file', () => { + expect(() => api.editJson('nope.json', () => {})).toThrow( + /editJson: nope\.json not found/ + ); + }); +}); + +describe('paths stay inside the project', () => { + it.each([ + ['read', (a) => a.read('../outside.txt')], + ['write', (a) => a.write('../outside.txt', 'x')], + ['setEnv', (a) => a.setEnv('../.env', 'A', '1')], + ['readDefine', (a) => a.readDefine('../functions.php', 'X')], + ])('%s rejects a path that escapes the root', (_name, call) => { + expect(() => call(api)).toThrow(); + expect( + fs.existsSync(path.join(path.dirname(root), 'outside.txt')) + ).toBe(false); + }); +}); diff --git a/node-packages/wp-tooling/tests/init/manage-flags.test.js b/node-packages/wp-tooling/tests/init/manage-flags.test.js new file mode 100644 index 0000000..ccbca2c --- /dev/null +++ b/node-packages/wp-tooling/tests/init/manage-flags.test.js @@ -0,0 +1,214 @@ +/** + * Manage mode driven by flags, the way CI and AI callers run it + * (`npm run init -- --enable=tailwind --yes`), plus the read-only status view. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const { + manageFlow, + parseManageFlags, + showStatus, +} = require('../../src/init/manage'); +const { identityFromName } = require('../../src/init/identity'); +const { makeRoot, touch } = require('./_helpers'); + +const makeUi = () => ({ + radio: jest.fn(), + text: jest.fn(), + confirm: jest.fn(async () => true), + table: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + success: jest.fn(), + error: jest.fn(), + heading: jest.fn(), + spinner: () => ({ start() {}, succeed() {}, fail() {} }), +}); + +/** + * A feature whose state is a marker file, so tests can see it flip. + * + * @param {string} key - Feature key. + * @return {Object} Feature declaration. + */ +const markerFeature = (key) => ({ + key, + label: key.toUpperCase(), + description: `The ${key} feature.`, + detect: (api) => api.exists(`${key}.flag`), + onEnable: (api) => api.write(`${key}.flag`, 'on'), + onDisable: (api) => api.remove(`${key}.flag`), +}); + +const CONFIG = { + kind: 'theme', + features: [markerFeature('alpha'), markerFeature('beta')], +}; + +let root; +let identity; +let ui; + +beforeEach(() => { + root = makeRoot('manage-flags-'); + identity = { + ...identityFromName('Acme Blog', CONFIG), + version: '1.0.0', + features: { alpha: false, beta: false }, + }; + touch(root, '.wp-scaffold.json', JSON.stringify(identity)); + ui = makeUi(); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + process.exitCode = 0; +}); + +const persisted = () => + JSON.parse(fs.readFileSync(path.join(root, '.wp-scaffold.json'), 'utf8')) + .features; + +describe('parseManageFlags', () => { + test('reads --yes/-y, list flags and the --manage marker', () => { + expect( + parseManageFlags([ + '-y', + '--manage', + '--features=alpha, beta', + '--enable=alpha', + '--disable=beta', + ]) + ).toEqual({ + flags: { + yes: true, + features: ['alpha', 'beta'], + enable: ['alpha'], + disable: ['beta'], + }, + unknown: [], + }); + }); + + test('collects anything else as unknown', () => { + expect(parseManageFlags(['--yes', '--wat']).unknown).toEqual(['--wat']); + }); +}); + +describe('manageFlow with flags', () => { + test('--enable turns a feature on and records it', async () => { + await manageFlow( + CONFIG, + root, + ['--enable=alpha', '--yes'], + identity, + ui, + jest.fn() + ); + + expect(fs.existsSync(path.join(root, 'alpha.flag'))).toBe(true); + expect(fs.existsSync(path.join(root, 'beta.flag'))).toBe(false); + expect(persisted()).toMatchObject({ alpha: true, beta: false }); + expect(ui.radio).not.toHaveBeenCalled(); + }); + + test('--features sets the exact set, turning off what it leaves out', async () => { + touch(root, 'alpha.flag', 'on'); + await manageFlow( + CONFIG, + root, + ['--features=beta', '--yes'], + { ...identity, features: { alpha: true, beta: false } }, + ui, + jest.fn() + ); + + expect(fs.existsSync(path.join(root, 'alpha.flag'))).toBe(false); + expect(fs.existsSync(path.join(root, 'beta.flag'))).toBe(true); + expect(persisted()).toMatchObject({ alpha: false, beta: true }); + }); + + test('--disable turns an enabled feature off', async () => { + touch(root, 'beta.flag', 'on'); + await manageFlow( + CONFIG, + root, + ['--disable=beta', '--yes'], + { ...identity, features: { alpha: false, beta: true } }, + ui, + jest.fn() + ); + + expect(fs.existsSync(path.join(root, 'beta.flag'))).toBe(false); + expect(persisted()).toMatchObject({ beta: false }); + }); + + test('--yes alone is refused with exit code 1, changing nothing', async () => { + await manageFlow(CONFIG, root, ['--yes'], identity, ui, jest.fn()); + + expect(ui.error).toHaveBeenCalledWith( + '--yes in manage mode requires --features / --enable / --disable.' + ); + expect(process.exitCode).toBe(1); + expect(fs.existsSync(path.join(root, 'alpha.flag'))).toBe(false); + }); + + test('an undeclared feature key is rejected before anything runs', async () => { + await expect( + manageFlow( + CONFIG, + root, + ['--enable=gamma', '--yes'], + identity, + ui, + jest.fn() + ) + ).rejects.toThrow(/gamma/); + expect(fs.existsSync(path.join(root, 'alpha.flag'))).toBe(false); + }); +}); + +describe('showStatus', () => { + test('says so when no optional features are declared', () => { + showStatus([], [], ui); + expect(ui.info).toHaveBeenCalledWith( + 'No optional features are declared for this project.' + ); + expect(ui.table).not.toHaveBeenCalled(); + }); + + test('labels each state, flags drift, prints descriptions and retired keys', () => { + showStatus( + [ + { + key: 'a', + label: 'A', + on: true, + drift: false, + description: 'First.', + }, + { key: 'b', label: 'B', on: false, drift: true }, + { key: 'c', label: 'C', on: null, drift: false }, + ], + ['legacy'], + ui + ); + + expect(ui.table).toHaveBeenCalledWith( + [ + ['A', 'enabled'], + ['B', 'disabled (drift)'], + ['C', 'unknown'], + ], + { title: 'Feature status' } + ); + expect(ui.info).toHaveBeenCalledWith('A: First.'); + expect(ui.warn).toHaveBeenCalledWith( + 'legacy: recorded in .wp-scaffold.json but no longer declared.' + ); + }); +}); diff --git a/node-packages/wp-tooling/tests/scaffolds/add-cli.test.js b/node-packages/wp-tooling/tests/scaffolds/add-cli.test.js new file mode 100644 index 0000000..cdf1f50 --- /dev/null +++ b/node-packages/wp-tooling/tests/scaffolds/add-cli.test.js @@ -0,0 +1,301 @@ +/** + * `wp-tooling add`: the human report sections the existing suite does not + * reach (composer installs, secrets, wiring, scaffolded tests, warnings, + * skipped files) and the interactive wizard flow (prompting for missing + * inputs, confirm, spinner, cancel, dry run, unknown id). + * + * The real Wizard runs; only the prompts and the spinner are stubbed. + */ + +'use strict'; + +jest.mock('../../src/ui', () => { + const actual = jest.requireActual('../../src/ui'); + return { + ...actual, + confirm: jest.fn(), + spinner: jest.fn(), + }; +}); +jest.mock('../../src/scaffolds/prompt-inputs', () => ({ + promptMissingInputs: jest.fn(), +})); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ui = require('../../src/ui'); +const { promptMissingInputs } = require('../../src/scaffolds/prompt-inputs'); +const { runCli } = require('../../src/scaffolds/add'); + +const tempDirs = []; +let stdout; +let stderr; + +function makeProject() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-tooling-add-cli-')); + tempDirs.push(dir); + fs.writeFileSync( + path.join(dir, 'composer.json'), + JSON.stringify({ + name: 'acme/shop', + autoload: { 'psr-4': { 'Acme\\Shop\\': 'includes/' } }, + }), + 'utf8' + ); + return dir; +} + +beforeEach(() => { + stdout = []; + stderr = []; + jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + ui.confirm.mockReset(); + ui.spinner.mockReset(); + ui.spinner.mockImplementation(() => ({ + start: jest.fn(), + update: jest.fn(), + succeed: jest.fn(), + fail: jest.fn(), + })); + promptMissingInputs.mockReset(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('human report (non-interactive)', () => { + test('lists the written files, composer install, wiring and scaffolded tests', async () => { + const dir = makeProject(); + const code = await runCli([ + 'wp/cpt', + '--non-interactive', + '--slug', + 'product', + '--singular', + 'Product', + '--plural', + 'Products', + '--cwd', + dir, + ]); + + expect(code).toBe(0); + const out = stdout.join(''); + expect(out).toMatch(/^Scaffold: wp\/cpt, kind: template/); + expect(out).toMatch( + /Engine wrote:\n {2}\+ includes\/PostTypes\/Product\.php/ + ); + expect(out).toMatch( + /Install \(composer\):\n {4}composer require rtcamp\// + ); + expect(out).toMatch(/Wiring suggestions/); + expect(out).toMatch(/File: includes\/Modules\/PostTypes\.php/); + expect(out).toMatch( + /Insert near anchor: \/\/ scaffold:wp\/cpt:classes/ + ); + expect(out).toMatch( + /Tests scaffolded:\n {2}\+ tests\/PostTypes\/ProductTest\.php \(phpunit/ + ); + expect(out.trim().endsWith('Done.')).toBe(true); + }); + + test('reports files that already existed as skipped, with a warning', async () => { + const dir = makeProject(); + const args = [ + 'wp/cpt', + '--non-interactive', + '--slug', + 'product', + '--singular', + 'Product', + '--plural', + 'Products', + '--cwd', + dir, + ]; + await runCli(args); + stdout.length = 0; + + expect(await runCli(args)).toBe(0); + + const out = stdout.join(''); + expect(out).toMatch(/Engine wrote: \(no files\)/); + expect(out).toMatch( + /Engine skipped \(file already existed\):\n {2}= includes\/PostTypes\/Product\.php/ + ); + expect(out).toMatch( + /Warnings:\n {2}! file already exists, not overwritten/ + ); + }); + + test('warns about a supplied input the scaffold does not declare', async () => { + const dir = makeProject(); + await runCli([ + 'utility/logger', + '--non-interactive', + '--dry-run', + '--namspace', + 'Typo', + '--cwd', + dir, + ]); + const out = stdout.join(''); + expect(out).toMatch(/\(dry run\), kind: package/); + expect(out).toMatch(/! unknown input "namspace" supplied/); + }); + + test('prints the secrets the developer has to set', async () => { + const dir = makeProject(); + await runCli([ + 'ci/cd-wporg', + '--non-interactive', + '--dry-run', + '--plugin-slug', + 'acme-shop', + '--cwd', + dir, + ]); + const out = stdout.join(''); + expect(out).toMatch( + /Set secrets \(run these yourself; I never set secrets\):/ + ); + expect(out).toMatch( + /gh secret set WPORG_USERNAME\n {4}# WordPress\.org SVN username/ + ); + expect(out).toMatch(/gh secret set WPORG_PASSWORD/); + }); + + test('names the available scaffolds for an unknown id', async () => { + const dir = makeProject(); + expect( + await runCli(['wp/nope', '--non-interactive', '--cwd', dir]) + ).toBe(1); + const err = stderr.join(''); + expect(err).toMatch(/Error: No scaffold registered for slug: wp\/nope/); + expect(err).toMatch(/Available scaffolds:\n {2}\S+/); + }); +}); + +describe('interactive wizard', () => { + test('prompts for the missing inputs, confirms, then scaffolds under a spinner', async () => { + const dir = makeProject(); + promptMissingInputs.mockImplementation( + async ({ supplied, missing }) => { + expect(missing).toEqual( + expect.arrayContaining(['slug', 'singular', 'plural']) + ); + return { + ...supplied, + slug: 'event', + singular: 'Event', + plural: 'Events', + }; + } + ); + ui.confirm.mockResolvedValue(true); + + expect(await runCli(['wp/cpt', '--cwd', dir])).toBe(0); + + expect(ui.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringMatching( + /Scaffold wp\/cpt will create \d+ file/ + ), + }) + ); + const spin = ui.spinner.mock.results[0].value; + expect(spin.succeed).toHaveBeenCalledWith('Scaffolded wp/cpt'); + expect( + fs.existsSync(path.join(dir, 'includes', 'PostTypes', 'Event.php')) + ).toBe(true); + expect(stdout.join('')).toMatch(/\+ includes\/PostTypes\/Event\.php/); + }); + + test('declining the confirm cancels without writing', async () => { + const dir = makeProject(); + ui.confirm.mockResolvedValue(false); + + await expect( + runCli([ + 'wp/cpt', + '--slug', + 'event', + '--singular', + 'Event', + '--plural', + 'Events', + '--cwd', + dir, + ]) + ).rejects.toMatchObject({ name: 'CancelledError' }); + + expect(fs.existsSync(path.join(dir, 'includes'))).toBe(false); + expect(ui.spinner).not.toHaveBeenCalled(); + }); + + test('--dry-run skips the confirm and writes nothing', async () => { + const dir = makeProject(); + + expect( + await runCli([ + 'wp/cpt', + '--dry-run', + '--slug=event', + '--singular=Event', + '--plural=Events', + `--cwd=${dir}`, + ]) + ).toBe(0); + + expect(ui.confirm).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(dir, 'includes'))).toBe(false); + expect(stdout.join('')).toMatch(/Scaffold: wp\/cpt \(dry run\)/); + }); + + test('a failed write marks the spinner failed and exits 1', async () => { + const dir = makeProject(); + // A file where the PostTypes directory has to go. + fs.mkdirSync(path.join(dir, 'includes'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'includes', 'PostTypes'), 'x', 'utf8'); + ui.confirm.mockResolvedValue(true); + + const code = await runCli([ + 'wp/cpt', + '--slug', + 'event', + '--singular', + 'Event', + '--plural', + 'Events', + '--cwd', + dir, + ]); + + expect(code).toBe(1); + const spin = ui.spinner.mock.results[0].value; + expect(spin.fail).toHaveBeenCalledWith('Failed to scaffold wp/cpt'); + expect(stderr.join('')).toMatch(/^Error: /); + }); + + test('an unknown id fails in the discover step', async () => { + const dir = makeProject(); + expect(await runCli(['wp/nope', '--cwd', dir])).toBe(1); + expect(stderr.join('')).toMatch( + /No scaffold registered for slug: wp\/nope/ + ); + expect(promptMissingInputs).not.toHaveBeenCalled(); + }); +}); diff --git a/node-packages/wp-tooling/tests/scaffolds/features-cli-install.test.js b/node-packages/wp-tooling/tests/scaffolds/features-cli-install.test.js new file mode 100644 index 0000000..b28770a --- /dev/null +++ b/node-packages/wp-tooling/tests/scaffolds/features-cli-install.test.js @@ -0,0 +1,107 @@ +/** + * `wp-tooling features --enable` when npm runs and fails: the feature stays + * enabled, the warning carries npm's stderr, and the retry commands put + * runtime deps in `dependencies` and dev deps in `devDependencies`. + * + * Own file so child_process can be mocked before features.js captures + * execFileSync. + */ + +'use strict'; + +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + execFileSync: jest.fn(), +})); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { execFileSync } = require('child_process'); +const { runCli } = require('../../src/scaffolds/features'); + +const tempDirs = []; +let stdout; + +function projectWithFeature() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-tooling-fi-')); + tempDirs.push(dir); + const scaffold = path.join(dir, 'bin', 'scaffolds', 'setup', 'charts'); + fs.mkdirSync(path.join(scaffold, 'templates'), { recursive: true }); + fs.writeFileSync(path.join(scaffold, 'templates', 'c.js'), 'x\n', 'utf8'); + fs.writeFileSync( + path.join(scaffold, 'scaffold.json'), + JSON.stringify({ + slug: 'charts', + category: 'setup', + name: 'Charts', + description: 'Charting library.', + source: 'template', + files: [{ src: 'templates/c.js', dest: 'charts.js', raw: true }], + feature: { config_key: 'charts' }, + npm_dependencies: { 'chart-runtime': '^2.0.0' }, + npm_dev_dependencies: { 'chart-types': '^1.0.0' }, + }), + 'utf8' + ); + return dir; +} + +beforeEach(() => { + stdout = []; + jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + execFileSync.mockReset(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a failed install keeps the feature enabled and prints correct retry commands', async () => { + const dir = projectWithFeature(); + const err = new Error('Command failed: npm install'); + err.stderr = 'npm ERR! code E404\nnpm ERR! 404 Not Found - chart-runtime'; + execFileSync.mockImplementation(() => { + throw err; + }); + + expect(await runCli(['--enable', 'charts', '--cwd', dir])).toBe(0); + + const out = stdout.join(''); + expect(out).toMatch(/enabled: charts/); + expect(out).toMatch(/! enabled, but npm install failed:/); + expect(out).toMatch(/404 Not Found - chart-runtime/); + expect(out).toMatch(/\n {4}npm install chart-runtime@\^2\.0\.0\n/); + expect(out).toMatch(/\n {4}npm install --save-dev chart-types@\^1\.0\.0\n/); + expect(out).not.toMatch(/--save-dev chart-runtime/); + expect(fs.existsSync(path.join(dir, 'charts.js'))).toBe(true); +}); + +test('a successful install lists what it installed', async () => { + const dir = projectWithFeature(); + execFileSync.mockImplementation(() => Buffer.from('')); + + expect(await runCli(['--enable', 'charts', '--cwd', dir])).toBe(0); + + expect(execFileSync).toHaveBeenCalledWith( + 'npm', + ['install', '--save', 'chart-runtime@^2.0.0'], + expect.objectContaining({ cwd: path.resolve(dir) }) + ); + expect(execFileSync).toHaveBeenCalledWith( + 'npm', + ['install', '--save-dev', 'chart-types@^1.0.0'], + expect.objectContaining({ cwd: path.resolve(dir) }) + ); + const out = stdout.join(''); + expect(out).toMatch(/installed chart-runtime@\^2\.0\.0/); + expect(out).toMatch(/installed chart-types@\^1\.0\.0/); +}); diff --git a/node-packages/wp-tooling/tests/scaffolds/features-cli.test.js b/node-packages/wp-tooling/tests/scaffolds/features-cli.test.js new file mode 100644 index 0000000..5c45e0c --- /dev/null +++ b/node-packages/wp-tooling/tests/scaffolds/features-cli.test.js @@ -0,0 +1,382 @@ +/** + * `wp-tooling features` end to end: the CLI and the programmatic entry point + * (theme-elementary's init embeds runFeatures) against the bundled Tailwind + * feature, in throwaway projects. npm is never run (`--no-install` or + * `install: false`); the interactive flow runs against a mocked TTY kit. + */ + +'use strict'; + +jest.mock('../../src/ui', () => ({ + confirm: jest.fn(), + spinner: jest.fn(), +})); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ui = require('../../src/ui'); +const { runCli, runFeatures } = require('../../src/scaffolds/features'); + +const tempDirs = []; +let stdout; +let stderr; + +function makeProject({ packageJson = true } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-tooling-features-')); + tempDirs.push(dir); + if (packageJson) { + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'demo', devDependencies: {} }, null, '\t') + + '\n', + 'utf8' + ); + } + return dir; +} + +function readConfig(dir) { + return JSON.parse( + fs.readFileSync(path.join(dir, '.wp-tooling.json'), 'utf8') + ); +} + +// The developer-owned Tailwind entry the feature asks before deleting. +function writeEntryCss(dir) { + const css = path.join(dir, 'src', 'css', 'frontend', 'tailwind.css'); + fs.mkdirSync(path.dirname(css), { recursive: true }); + fs.writeFileSync(css, '@import "tailwindcss";\n', 'utf8'); + return css; +} + +function lastJson(chunks) { + return JSON.parse(chunks.join('').trim().split('\n').pop()); +} + +beforeEach(() => { + stdout = []; + stderr = []; + jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + ui.confirm.mockReset(); + ui.spinner.mockReset(); + ui.spinner.mockImplementation(() => ({ + start: jest.fn(), + update: jest.fn(), + succeed: jest.fn(), + fail: jest.fn(), + })); +}); + +afterEach(() => { + jest.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('runCli: help and argument errors', () => { + test('--help prints usage and exits 0', async () => { + expect(await runCli(['--help'])).toBe(0); + expect(stdout.join('')).toMatch(/Usage: wp-tooling features/); + }); + + test('an unknown argument exits 1 with the usage', async () => { + expect(await runCli(['--nope'])).toBe(1); + expect(stderr.join('')).toMatch(/Unexpected argument: --nope/); + expect(stdout.join('')).toMatch(/Usage: wp-tooling features/); + }); + + test('a flag missing its value exits 1 instead of swallowing the next flag', async () => { + expect(await runCli(['--cwd', '--json'])).toBe(1); + expect(stderr.join('')).toMatch(/--cwd/); + }); +}); + +describe('runCli: status', () => { + test('--json lists the bundled Tailwind feature as off', async () => { + const dir = makeProject(); + expect(await runCli(['--json', '--cwd', dir])).toBe(0); + const { features } = lastJson(stdout); + const tailwind = features.find((f) => f.id === 'setup/tailwind'); + expect(tailwind).toMatchObject({ + slug: 'tailwind', + configKey: 'tailwind', + enabled: false, + }); + }); + + test('--non-interactive prints a checklist', async () => { + const dir = makeProject(); + expect(await runCli(['--non-interactive', `--cwd=${dir}`])).toBe(0); + const out = stdout.join(''); + expect(out).toMatch(/Features \(\d+\):/); + expect(out).toMatch(/\[ \] setup\/tailwind/); + }); +}); + +describe('runCli: enable and disable', () => { + test('--enable --no-install --json writes files, sets the flag and reports deps', async () => { + const dir = makeProject(); + const code = await runCli([ + '--enable', + 'tailwind', + '--no-install', + '--json', + '--cwd', + dir, + ]); + expect(code).toBe(0); + const { changes } = lastJson(stdout); + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + id: 'tailwind', + action: 'enabled', + installed: false, + recorded: [], + }); + expect(changes[0].wrote).toContain('postcss.config.js'); + expect(changes[0].install.npmDev).toHaveProperty('tailwindcss'); + expect(fs.existsSync(path.join(dir, 'postcss.config.js'))).toBe(true); + expect(readConfig(dir).features.tailwind).toBe(true); + // Plain --no-install reports only: package.json is untouched. + expect( + JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) + .devDependencies + ).toEqual({}); + }); + + test('the human report suggests the install commands when nothing was installed', async () => { + const dir = makeProject(); + await runCli(['--enable=tailwind', '--no-install', '--cwd', dir]); + const out = stdout.join(''); + expect(out).toMatch(/enabled: tailwind\n/); + expect(out).toMatch(/\+ postcss\.config\.js/); + expect(out).toMatch(/npm install --save-dev tailwindcss@/); + expect(out).toMatch(/Done\./); + }); + + test('--record-deps records the deps in package.json and says so', async () => { + const dir = makeProject(); + await runCli([ + '--enable', + 'tailwind', + '--no-install', + '--record-deps', + '--cwd', + dir, + ]); + const pkg = JSON.parse( + fs.readFileSync(path.join(dir, 'package.json'), 'utf8') + ); + expect(Object.keys(pkg.devDependencies)).toEqual( + expect.arrayContaining(['tailwindcss', '@tailwindcss/postcss']) + ); + const out = stdout.join(''); + expect(out).toMatch(/recorded tailwindcss@.* in package\.json/); + expect(out).toMatch(/run `npm install` to install the recorded deps/); + }); + + test('--dry-run reports the change without writing anything', async () => { + const dir = makeProject(); + await runCli([ + '--enable', + 'tailwind', + '--dry-run', + '--no-install', + '--cwd', + dir, + ]); + expect(stdout.join('')).toMatch(/enabled: tailwind \(dry run\)/); + expect(fs.existsSync(path.join(dir, 'postcss.config.js'))).toBe(false); + expect(fs.existsSync(path.join(dir, '.wp-tooling.json'))).toBe(false); + }); + + test('--disable without --force keeps the developer-editable entry CSS', async () => { + const dir = makeProject(); + await runCli(['--enable', 'tailwind', '--no-install', '--cwd', dir]); + const css = writeEntryCss(dir); + stdout.length = 0; + + expect( + await runCli(['--disable', 'setup/tailwind', '--cwd', dir]) + ).toBe(0); + + const out = stdout.join(''); + expect(out).toMatch(/disabled: setup\/tailwind/); + expect(out).toMatch(/- postcss\.config\.js/); + expect(out).toMatch(/= kept .*tailwind\.css/); + expect(fs.existsSync(path.join(dir, 'postcss.config.js'))).toBe(false); + expect(fs.existsSync(css)).toBe(true); + expect(readConfig(dir).features.tailwind).toBeFalsy(); + }); + + test('--disable --force also removes the developer-editable files', async () => { + const dir = makeProject(); + await runCli(['--enable', 'tailwind', '--no-install', '--cwd', dir]); + const css = writeEntryCss(dir); + stdout.length = 0; + + await runCli([ + '--disable', + 'tailwind', + '--force', + '--json', + '--cwd', + dir, + ]); + + const { changes } = lastJson(stdout); + expect(changes[0].action).toBe('disabled'); + expect(changes[0].kept).toEqual([]); + expect(changes[0].removed.join(' ')).toMatch(/tailwind\.css/); + expect(fs.existsSync(css)).toBe(false); + }); + + test('an unknown feature id fails with ENOTFEATURE as JSON on stderr', async () => { + const dir = makeProject(); + expect( + await runCli([ + '--enable', + 'no-such-feature', + '--json', + '--cwd', + dir, + ]) + ).toBe(1); + expect(JSON.parse(stderr.join('').trim())).toMatchObject({ + code: 'ENOTFEATURE', + }); + }); + + test('an unknown feature id fails with a plain message without --json', async () => { + const dir = makeProject(); + expect( + await runCli(['--disable', 'no-such-feature', '--cwd', dir]) + ).toBe(1); + expect(stderr.join('')).toMatch( + /Error: No toggleable feature: no-such-feature/ + ); + }); +}); + +describe('runCli: interactive', () => { + test('applies a change the developer confirms, under a spinner', async () => { + const dir = makeProject(); + ui.confirm.mockImplementation(async ({ message }) => + /Tailwind/.test(message) + ); + + expect(await runCli(['--no-install', '--cwd', dir])).toBe(0); + + expect(ui.confirm).toHaveBeenCalledWith( + expect.objectContaining({ defaultValue: false }) + ); + const spin = ui.spinner.mock.results[0].value; + expect(spin.start).toHaveBeenCalled(); + expect(spin.succeed).toHaveBeenCalledWith('enabled setup/tailwind'); + expect(readConfig(dir).features.tailwind).toBe(true); + }); + + test('says "No changes." when every answer keeps the current state', async () => { + const dir = makeProject(); + ui.confirm.mockImplementation(async ({ defaultValue }) => defaultValue); + + expect(await runCli(['--cwd', dir])).toBe(0); + expect(stdout.join('')).toMatch(/No changes\./); + expect(ui.spinner).not.toHaveBeenCalled(); + }); + + test('asks before deleting a developer-editable file on disable', async () => { + const dir = makeProject(); + await runCli(['--enable', 'tailwind', '--no-install', '--cwd', dir]); + const css = writeEntryCss(dir); + const asked = []; + ui.confirm.mockImplementation(async ({ message, defaultValue }) => { + asked.push(message); + if (/Delete developer-editable file/.test(message)) { + return true; + } + // Flip Tailwind off; keep anything else as it is. + return /Tailwind/.test(message) ? false : defaultValue; + }); + + expect(await runCli(['--cwd', dir])).toBe(0); + + expect(asked.some((m) => /currently on/.test(m))).toBe(true); + expect( + asked.some((m) => /Delete developer-editable file/.test(m)) + ).toBe(true); + expect(fs.existsSync(css)).toBe(false); + expect(readConfig(dir).features.tailwind).toBeFalsy(); + }); + + test('a cancelled prompt propagates so the dispatcher can exit 130', async () => { + const dir = makeProject(); + const cancelled = new Error('cancelled'); + cancelled.name = 'CancelledError'; + ui.confirm.mockRejectedValue(cancelled); + + await expect(runCli(['--cwd', dir])).rejects.toBe(cancelled); + }); + + test('a failing change marks the spinner failed and exits 1', async () => { + const dir = makeProject(); + // A file where a directory has to go makes the gitignore write fail. + fs.mkdirSync(path.join(dir, '.gitignore')); + ui.confirm.mockImplementation(async ({ message }) => + /Tailwind/.test(message) + ); + + expect(await runCli(['--no-install', '--cwd', dir])).toBe(1); + + const spin = ui.spinner.mock.results[0].value; + expect(spin.fail).toHaveBeenCalledWith( + 'Failed to enable setup/tailwind' + ); + expect(stderr.join('')).toMatch(/^Error: /); + }); +}); + +describe('runFeatures (programmatic, as embedded by the theme init)', () => { + test('enable implies non-interactive and never prompts', async () => { + const dir = makeProject(); + expect( + await runFeatures({ + cwd: dir, + enable: ['tailwind'], + install: false, + }) + ).toBe(0); + expect(ui.confirm).not.toHaveBeenCalled(); + expect(readConfig(dir).features.tailwind).toBe(true); + }); + + test('install:false with record:true records deps, as init does inside npm install', async () => { + const dir = makeProject(); + await runFeatures({ + cwd: dir, + enable: ['tailwind'], + install: false, + record: true, + }); + const pkg = JSON.parse( + fs.readFileSync(path.join(dir, 'package.json'), 'utf8') + ); + expect(pkg.devDependencies).toHaveProperty('tailwindcss'); + }); + + test('with no changes requested it prints the status', async () => { + const dir = makeProject({ packageJson: false }); + expect(await runFeatures({ cwd: dir, nonInteractive: true })).toBe(0); + expect(stdout.join('')).toMatch(/setup\/tailwind/); + }); +}); diff --git a/node-packages/wp-tooling/tests/scaffolds/list-cli.test.js b/node-packages/wp-tooling/tests/scaffolds/list-cli.test.js new file mode 100644 index 0000000..c01e645 --- /dev/null +++ b/node-packages/wp-tooling/tests/scaffolds/list-cli.test.js @@ -0,0 +1,108 @@ +/** + * `wp-tooling list` paths the main suite does not reach: the space-separated + * flag forms, argument errors, the empty human table, and a registry that + * fails to load (plain and --json error output). + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { runCli, parseArgs } = require('../../src/scaffolds/list'); + +const tempDirs = []; +let stdout; +let stderr; + +function makeProject() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-tooling-list-cli-')); + tempDirs.push(dir); + return dir; +} + +// A project whose bin/scaffolds holds a manifest that fails validation, so the +// registry scan throws EBADSCAFFOLD. +function projectWithBrokenScaffold() { + const dir = makeProject(); + const scaffold = path.join(dir, 'bin', 'scaffolds', 'broken'); + fs.mkdirSync(scaffold, { recursive: true }); + fs.writeFileSync( + path.join(scaffold, 'scaffold.json'), + JSON.stringify({ slug: 'broken' }), + 'utf8' + ); + return dir; +} + +beforeEach(() => { + stdout = []; + stderr = []; + jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parses the space-separated forms of every value flag', () => { + expect( + parseArgs([ + '--cwd', + '/tmp/x', + '--category', + 'wp', + '--origin', + 'default', + '--cache-dir', + '/tmp/c', + '--refresh', + ]) + ).toMatchObject({ + cwd: '/tmp/x', + category: 'wp', + origin: 'default', + cacheDir: '/tmp/c', + refresh: true, + }); + expect(parseArgs(['--cache-dir=/tmp/d']).cacheDir).toBe('/tmp/d'); +}); + +test('an unexpected argument exits 1 and prints the usage', async () => { + expect(await runCli(['--wat'])).toBe(1); + expect(stderr.join('')).toMatch(/Unexpected argument: --wat/); + expect(stdout.join('')).toMatch(/Usage/); +}); + +test('a filter that matches nothing says so in the human table', async () => { + const dir = makeProject(); + expect(await runCli(['--category', 'no-such-category', '--cwd', dir])).toBe( + 0 + ); + expect(stdout.join('')).toMatch(/No scaffolds match the given filters\./); +}); + +test('a registry that fails to load exits 1 with the error', async () => { + const dir = projectWithBrokenScaffold(); + expect(await runCli(['--cwd', dir])).toBe(1); + expect(stderr.join('')).toMatch(/^Error: /); +}); + +test('with --json the load error is one JSON object on stderr', async () => { + const dir = projectWithBrokenScaffold(); + expect(await runCli(['--json', '--cwd', dir])).toBe(1); + expect(JSON.parse(stderr.join('').trim())).toMatchObject({ + code: 'EBADSCAFFOLD', + }); +});