From d6b5cb435b8681de8635d8857396e88fcbe1da3b Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sat, 20 Jun 2026 16:12:28 +0200 Subject: [PATCH] Add unit test support for plugins Plugin PHP compiles against host VitoDeploy classes (App\Plugins\*, App\SiteFeatures\Action, App\Models\*, the SSH facade) and the host's Tests\TestCase, so tests can't run standalone in this repo. Add a runner that stages each plugin and its tests into a checkout of vitodeploy/vito and runs the host's PHPUnit there. - scripts/test.mjs: stage plugin -> app/Vito/Plugins/// and its tests -> tests/Feature/Plugins///, run host PHPUnit scoped to that dir, clean up. Opt-in per plugin (no tests/ = skipped), required when present. Wired up as `npm test`. - .github/workflows/test.yml: required PR check (read-only, like validate); checks out the host at 4.x, composer install, runs changed plugins' tests. - Example tests for all three plugins + a hello-world starter template. - CONTRIBUTING.md "Test your plugin" section; DESIGN.md decision recorded. tests/ is already excluded from the published artifact, so test files never ship. --- .github/workflows/test.yml | 106 ++++++++ CONTRIBUTING.md | 37 ++- DESIGN.md | 16 ++ .../hello-world/tests/Feature/PluginTest.php | 30 +++ package.json | 1 + .../tests/Feature/EnableTest.php | 52 ++++ .../tests/Feature/PluginTest.php | 29 +++ .../tests/Feature/PluginTest.php | 26 ++ scripts/test.mjs | 230 ++++++++++++++++++ 9 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 examples/hello-world/tests/Feature/PluginTest.php create mode 100644 plugins/laravel-octane-plugin/tests/Feature/EnableTest.php create mode 100644 plugins/laravel-reverb-plugin/tests/Feature/PluginTest.php create mode 100644 plugins/tiny-file-manager-plugin/tests/Feature/PluginTest.php create mode 100644 scripts/test.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c5eef0c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,106 @@ +name: Test + +on: + pull_request: + branches: [main] + paths: + - "plugins/**" + - "scripts/**" + - ".github/workflows/test.yml" + +# Read-only token: this job checks out and EXECUTES PR-supplied plugin code +# inside the host app, so it must never have write access or secrets. (Mirrors +# the validate job's trust model — see validate.yml.) +permissions: + contents: read + +concurrency: + group: test-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + test: + name: Run plugin tests + runs-on: ubuntu-22.04 + steps: + - name: Checkout marketplace + uses: actions/checkout@v4 + with: + path: plugins-repo + fetch-depth: 0 + + # The host VitoDeploy app provides the classes plugins compile against + # (App\Plugins\*, App\SiteFeatures\Action, App\Models\*, the SSH facade) + # and the Tests\TestCase that auto-provisions a server/site. Plugin tests + # run INSIDE this checkout. + # + # Pinned to the host's current development line (4.x). Plugins declare + # min_vito_version 3.0.0, so the host API they bind to is stable across + # 3.x/4.x; tracking 4.x surfaces forward-compat breakage early. To also + # gate on an older line, add a matrix over `ref`. + - name: Checkout host VitoDeploy app + uses: actions/checkout@v4 + with: + repository: vitodeploy/vito + ref: 4.x + path: vito + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + tools: composer + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Cache host Composer packages + id: composer-cache + uses: actions/cache@v4 + with: + path: vito/vendor + key: vito-host-${{ hashFiles('vito/composer.lock') }} + restore-keys: | + vito-host- + + - name: Install host dependencies + working-directory: vito + run: composer install --prefer-dist --no-progress + + - name: Prepare host test environment + working-directory: vito + run: | + touch storage/database-test.sqlite + touch .env + php artisan key:generate + + # Only test plugins changed in this PR (matches validate.yml granularity). + # If the diff is tooling-only (scripts/**), test every plugin so a runner + # change can't silently break the suite. + - name: Determine changed plugins + id: changed + working-directory: plugins-repo + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + changed_plugins="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- plugins/ \ + | awk -F/ 'NF>1 && $1=="plugins" {print $2}' | sort -u)" + tooling="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- scripts/ | head -n1 || true)" + + if [ -n "$tooling" ] || [ -z "$changed_plugins" ]; then + echo "Tooling changed or no specific plugin diff; testing all plugins." + echo "slugs=" >> "$GITHUB_OUTPUT" + else + echo "Changed plugins:"; printf ' - %s\n' $changed_plugins + echo "slugs=$(printf '%s ' $changed_plugins)" >> "$GITHUB_OUTPUT" + fi + + - name: Run plugin tests + working-directory: plugins-repo + env: + VITO_PATH: ${{ github.workspace }}/vito + run: node scripts/test.mjs ${{ steps.changed.outputs.slugs }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75ebdfe..8a64014 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -144,7 +144,42 @@ keep your footprint minimal: - Declaring extra Composer `require` deps — plugins run inside the host app and use host classes; extra deps are flagged for host-compatibility review. -## 6. Open a pull request +## 6. Test your plugin (optional but encouraged) + +Your plugin's PHP compiles against classes that only exist in the **host +VitoDeploy app** (`App\Plugins\*`, `App\SiteFeatures\Action`, `App\Models\*`, +the `SSH` facade). So plugin tests can't run standalone here — they run **inside +a checkout of the host app**, where `Tests\TestCase` auto-provisions a +`$this->server` and `$this->site` for you and `SSH::fake()` intercepts remote +commands. + +Add tests under `plugins//tests/` (mirroring the host's +`tests/Feature` layout). Each test class: + +- is namespaced `Tests\Feature\Plugins\\\…`, +- extends `Tests\TestCase`, +- uses `Illuminate\Foundation\Testing\RefreshDatabase` if it touches the DB. + +The runner stages your plugin into the host at +`app/Vito/Plugins///`, copies your `tests/` into the host's +`tests/Feature/Plugins///`, runs the host's PHPUnit, then cleans +up. (`tests/` is never shipped in the published artifact.) + +```bash +# point at a local checkout of vitodeploy/vito with `composer install` run +VITO_PATH=/path/to/vito node scripts/test.mjs my-plugin # one plugin +VITO_PATH=/path/to/vito node scripts/test.mjs # all plugins +``` + +See the official plugins' `tests/` for patterns: asserting `boot()` registers a +site feature/type in config, faking SSH, and exception assertions on an Action's +validation. + +**CI runs your plugin's tests on every PR and a failure blocks merge.** A plugin +with no `tests/` directory is reported as skipped (not a failure) — tests are +opt-in per plugin, but when present they must pass. + +## 7. Open a pull request Push your branch and open a PR. Fill in the PR template. CI runs validation; a VitoDeploy maintainer reviews for safety and quality, then squash-merges. On diff --git a/DESIGN.md b/DESIGN.md index 25a7e85..9337df4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -261,6 +261,19 @@ no fork access. See SECURITY.md. `composer.json` via `jq`, never executes PR code): enforce one-plugin-per-PR, semver forward-bump, force PR title to ` `, ping plugin author. +### `test.yml` (on PR) — run plugin tests inside the host app +Plugin PHP compiles against host classes (`App\SiteFeatures\Action`, +`App\Models\Worker`, `App\Plugins\Register*`, the `SSH` facade) and the host's +`Tests\TestCase` (which auto-provisions a server/site), so tests can't run +standalone in this repo. Instead `scripts/test.mjs` checks out `vitodeploy/vito`, +stages each changed plugin into `app/Vito/Plugins///` and its +`tests/` into `tests/Feature/Plugins///`, runs the host's PHPUnit +scoped to that dir, and cleans up. Tests are **opt-in per plugin** (a plugin with +no `tests/` is skipped, not failed) but **required when present** — a failure +blocks merge. The job runs read-only (it executes PR code; no secrets, like +`validate`). `tests/` is already excluded from the published artifact, so test +files never ship. The host ref is pinned to `4.x`. + ### `publish.yml` (on push to main) — incremental, `O(changed)` 1. Skip unless `minisign.pub` is real and `MINISIGN_SECRET_KEY` is set. 2. Diff the merge → changed plugin dirs (or `workflow_dispatch` explicit list). @@ -319,6 +332,9 @@ Resolved: - **Publish granularity = incremental** (only changed plugins). ✓ - **v1 app-side = marketplace display + homepage link**; installer rewiring deferred. ✓ +- **Testing = run plugin tests inside a host `vitodeploy/vito` checkout** + (PHPUnit), staged by `scripts/test.mjs`; opt-in per plugin, required when + present, host ref pinned to `4.x`. ✓ Open (non-blocking; sensible defaults applied): 1. **Per-plugin `min_vito_version` enforcement** — advisory in v1 (metadata diff --git a/examples/hello-world/tests/Feature/PluginTest.php b/examples/hello-world/tests/Feature/PluginTest.php new file mode 100644 index 0000000..802460e --- /dev/null +++ b/examples/hello-world/tests/Feature/PluginTest.php @@ -0,0 +1,30 @@ +user / $this->server / $this->site from Tests\TestCase. + * + * Namespace your tests `Tests\Feature\Plugins\\\...` and extend + * Tests\TestCase. See the official plugins' tests/ for SSH-faking and + * worker/vhost assertions: + * https://github.com/vitodeploy/plugins/tree/main/plugins + */ +class PluginTest extends TestCase +{ + public function test_plugin_boots_without_error(): void + { + (new Plugin)->boot(); + + $this->assertSame('Hello World', (new Plugin)->getName()); + } +} diff --git a/package.json b/package.json index 5a3ea32..9a2c610 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "validate": "node scripts/validate.mjs", + "test": "node scripts/test.mjs", "pack": "node scripts/pack.mjs", "publish": "node scripts/publish.mjs" }, diff --git a/plugins/laravel-octane-plugin/tests/Feature/EnableTest.php b/plugins/laravel-octane-plugin/tests/Feature/EnableTest.php new file mode 100644 index 0000000..75a27f7 --- /dev/null +++ b/plugins/laravel-octane-plugin/tests/Feature/EnableTest.php @@ -0,0 +1,52 @@ +user, $this->server (Nginx, PHP, + * Supervisor, ...) and $this->site, so the plugin's Action can run against a + * realistic site with SSH faked. + * + * Note: a full Enable::handle() with a valid port also calls updateVHost(), + * which renders host vhost-block views that aren't present in the bare test + * site. Asserting the worker/type_data side effects of a successful enable + * therefore requires either seeding the site's vhost or stubbing the webserver + * — left to the plugin author. These tests cover the parts the Action owns + * outright: validation and the active() guard. + */ +class EnableTest extends TestCase +{ + use RefreshDatabase; + + public function test_enable_rejects_invalid_port(): void + { + SSH::fake(); + + $request = Request::create('/', 'POST', ['port' => 70000]); + $request->setLaravelSession(app('session.store')); + + $this->assertThrows(fn () => (new Enable($this->site))->handle($request)); + + $this->assertSame(0, Worker::query()->where('name', 'laravel-octane')->count()); + } + + public function test_action_is_active_when_octane_disabled(): void + { + $this->assertTrue((new Enable($this->site))->active()); + + $typeData = $this->site->type_data ?? []; + data_set($typeData, 'octane', true); + $this->site->type_data = $typeData; + $this->site->save(); + + $this->assertFalse((new Enable($this->site->refresh()))->active()); + } +} diff --git a/plugins/laravel-reverb-plugin/tests/Feature/PluginTest.php b/plugins/laravel-reverb-plugin/tests/Feature/PluginTest.php new file mode 100644 index 0000000..5709668 --- /dev/null +++ b/plugins/laravel-reverb-plugin/tests/Feature/PluginTest.php @@ -0,0 +1,29 @@ +boot(); + + $features = config('site.types.laravel.features'); + $this->assertIsArray($features); + $this->assertArrayHasKey('laravel-reverb', $features); + $this->assertSame('Laravel Reverb', $features['laravel-reverb']['label']); + + $type = config('site.types.'.LaravelReverb::id()); + $this->assertIsArray($type); + $this->assertSame(LaravelReverb::class, $type['handler']); + } +} diff --git a/plugins/tiny-file-manager-plugin/tests/Feature/PluginTest.php b/plugins/tiny-file-manager-plugin/tests/Feature/PluginTest.php new file mode 100644 index 0000000..02fff38 --- /dev/null +++ b/plugins/tiny-file-manager-plugin/tests/Feature/PluginTest.php @@ -0,0 +1,26 @@ +boot(); + + $type = config('site.types.'.TinyFileManager::id()); + + $this->assertIsArray($type); + $this->assertSame('Tiny File Manager', $type['label']); + $this->assertSame(TinyFileManager::class, $type['handler']); + $this->assertNotEmpty($type['form']); + } +} diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..0130e03 --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node +// Run a plugin's PHPUnit tests INSIDE a checkout of the host VitoDeploy app. +// +// Plugin code (Plugin.php, Actions/, SiteTypes/, ...) references classes that +// only exist in the host app (App\SiteFeatures\Action, App\Models\Worker, +// App\Plugins\Register*, SSH::fake(), the auto-provisioned $this->site/$this->server +// from the host's Tests\TestCase). There is no standalone autoloading here, so we +// can't run a plugin's tests in isolation — we stage the plugin + its tests into a +// real host checkout and run the host's PHPUnit there. +// +// For each target plugin that ships a tests/ directory we: +// 1. compute its host install path from the manifest namespace +// (App\Vito\Plugins\\\ -> app/Vito/Plugins///), +// 2. copy the plugin source there (minus tests/ and dev cruft), +// 3. copy the plugin's tests/ into the host's Feature suite at +// tests/Feature/Plugins///. The host's phpunit.xml discovers +// tests/Feature recursively (suffix Test.php), and the tests extend +// Tests\TestCase, so they inherit the auto-provisioned $this->site/$this->server. +// 4. run `./vendor/bin/phpunit` scoped to that directory, +// 5. always clean up both staged trees, even on failure. +// +// Plugins WITHOUT a tests/ directory are skipped (reported, not failed) — tests +// are opt-in per plugin, but when present they must pass (CI gates on the exit code). +// +// Usage: +// node scripts/test.mjs [slug...] --vito +// VITO_PATH=/path/to/vito node scripts/test.mjs +// +// Flags: +// --vito host VitoDeploy checkout (default: $VITO_PATH, else ../vito) +// --filter passed through to PHPUnit --filter +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { + expectedNamespace, + listPluginSlugs, + PACK_EXCLUDED_DIRS, + PACK_EXCLUDED_FILES, + pluginDir, + pluginsDir, + readManifest, +} from "./lib/paths.mjs"; + +const TESTS_DIRNAME = "tests"; + +function parseArgs(argv) { + const slugs = []; + let vito = process.env.VITO_PATH ?? path.resolve(pluginsDir, "..", "..", "vito"); + let filter = null; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--vito") { + vito = argv[(i += 1)]; + } else if (arg.startsWith("--vito=")) { + vito = arg.slice("--vito=".length); + } else if (arg === "--filter") { + filter = argv[(i += 1)]; + } else if (arg.startsWith("--filter=")) { + filter = arg.slice("--filter=".length); + } else if (!arg.startsWith("-")) { + slugs.push(arg); + } + } + return { slugs: slugs.length ? slugs : listPluginSlugs(), vito: vito ? path.resolve(vito) : null, filter }; +} + +// The host install path for a plugin, derived from the manifest namespace: +// App\Vito\Plugins\\\ -> app/Vito/Plugins/// +function hostSegments(manifest) { + const prefix = expectedNamespace(manifest); // "App\\Vito\\Plugins\\Vendor\\Name\\" + const parts = prefix.split("\\").filter(Boolean); // [App, Vito, Plugins, Vendor, Name] + return parts.slice(3); // [Vendor, Name] +} + +// Recursively copy a plugin source tree, skipping the same dev cruft pack.mjs +// excludes from artifacts (tests/, .git, node_modules, ...). The plugin's tests +// are staged separately into the host suite, not into the runtime tree. +function copyPluginSource(srcDir, destDir) { + fs.mkdirSync(destDir, { recursive: true }); + for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (PACK_EXCLUDED_DIRS.has(entry.name)) continue; + copyPluginSource(path.join(srcDir, entry.name), path.join(destDir, entry.name)); + } else if (entry.isFile()) { + if (PACK_EXCLUDED_FILES.has(entry.name)) continue; + fs.copyFileSync(path.join(srcDir, entry.name), path.join(destDir, entry.name)); + } + } +} + +// Copy the plugin's tests/ verbatim into the host's Feature suite. Plugin tests +// declare `namespace Tests\Feature\Plugins\\...;` and extend +// Tests\TestCase, matching the host's existing Feature-suite convention. +function copyTests(srcDir, destDir) { + fs.mkdirSync(destDir, { recursive: true }); + for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { + const from = path.join(srcDir, entry.name); + const to = path.join(destDir, entry.name); + if (entry.isDirectory()) { + copyTests(from, to); + } else if (entry.isFile()) { + fs.copyFileSync(from, to); + } + } +} + +function rmrf(target) { + fs.rmSync(target, { recursive: true, force: true }); +} + +// Remove now-empty parent dirs we created (e.g. app/Vito/Plugins/) up to +// — but not including — a stop directory, so staging leaves no residue. +function pruneEmptyUp(dir, stopAt) { + let current = dir; + while (current.startsWith(stopAt) && current !== stopAt) { + try { + if (fs.readdirSync(current).length > 0) break; + fs.rmdirSync(current); + } catch { + break; + } + current = path.dirname(current); + } +} + +function runPlugin(slug, vito, filter) { + const dir = pluginDir(slug); + const testsDir = path.join(dir, TESTS_DIRNAME); + if (!fs.existsSync(testsDir)) { + return { slug, status: "skipped", reason: "no tests/ directory" }; + } + + const manifest = readManifest(dir); + const [vendor, name] = hostSegments(manifest); + if (!vendor || !name) { + return { slug, status: "error", reason: `cannot derive host path from namespace '${expectedNamespace(manifest)}'` }; + } + + const hostPluginRoot = path.join(vito, "app", "Vito", "Plugins"); + const hostSuiteRoot = path.join(vito, "tests", "Feature", "Plugins"); + const stagedPlugin = path.join(hostPluginRoot, vendor, name); + const stagedTests = path.join(hostSuiteRoot, vendor, name); + const phpunitBin = path.join(vito, "vendor", "bin", "phpunit"); + + if (!fs.existsSync(phpunitBin)) { + return { slug, status: "error", reason: `PHPUnit not found at ${phpunitBin} — run 'composer install' in the host checkout` }; + } + + // Refuse to clobber a plugin already present in the host checkout (e.g. a real + // local install); staging must never destroy the developer's tree. + for (const staged of [stagedPlugin, stagedTests]) { + if (fs.existsSync(staged)) { + return { slug, status: "error", reason: `${staged} already exists in the host checkout; remove it before staging` }; + } + } + + try { + copyPluginSource(dir, stagedPlugin); + copyTests(testsDir, stagedTests); + + // Point PHPUnit at the staged dir directly. The host phpunit.xml's + // whitelist still applies; passing a path overrides the testsuite selection. + const args = [path.relative(vito, stagedTests)]; + if (filter) args.push("--filter", filter); + + const result = spawnSync(phpunitBin, args, { cwd: vito, stdio: "inherit", env: process.env }); + if (result.error) { + return { slug, status: "error", reason: result.error.message }; + } + return { slug, status: result.status === 0 ? "passed" : "failed", code: result.status }; + } finally { + rmrf(stagedPlugin); + rmrf(stagedTests); + pruneEmptyUp(path.dirname(stagedPlugin), hostPluginRoot); + pruneEmptyUp(path.dirname(stagedTests), hostSuiteRoot); + } +} + +function main() { + const { slugs, vito, filter } = parseArgs(process.argv.slice(2)); + + if (!vito || !fs.existsSync(vito)) { + console.error( + `::error::host VitoDeploy checkout not found at '${vito ?? ""}'.\n` + + "Pass --vito or set VITO_PATH to a checkout of vitodeploy/vito with 'composer install' run.", + ); + process.exit(2); + } + + if (slugs.length === 0) { + console.log("No plugins to test."); + return; + } + + let failed = 0; + let ran = 0; + for (const slug of slugs) { + console.log(`::group::test ${slug}`); + const result = runPlugin(slug, vito, filter); + console.log("::endgroup::"); + switch (result.status) { + case "passed": + ran += 1; + console.log(`✓ ${slug}`); + break; + case "skipped": + console.log(`- ${slug} (${result.reason})`); + break; + case "failed": + ran += 1; + failed += 1; + console.log(`::error::[${slug}] tests failed (exit ${result.code})`); + console.log(`✗ ${slug}`); + break; + default: + failed += 1; + console.log(`::error::[${slug}] ${result.reason}`); + console.log(`✗ ${slug}`); + } + } + + if (failed > 0) { + console.error(`\n${failed} plugin(s) failed tests.`); + process.exit(1); + } + console.log(`\n${ran} plugin(s) tested, all passing.`); +} + +main();