Skip to content

Commit d6b5cb4

Browse files
committed
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/<Vendor>/<Name>/ and its tests -> tests/Feature/Plugins/<Vendor>/<Name>/, 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.
1 parent a0e0243 commit d6b5cb4

9 files changed

Lines changed: 526 additions & 1 deletion

File tree

.github/workflows/test.yml

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
name: Test
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
paths:
7+
- "plugins/**"
8+
- "scripts/**"
9+
- ".github/workflows/test.yml"
10+
11+
# Read-only token: this job checks out and EXECUTES PR-supplied plugin code
12+
# inside the host app, so it must never have write access or secrets. (Mirrors
13+
# the validate job's trust model — see validate.yml.)
14+
permissions:
15+
contents: read
16+
17+
concurrency:
18+
group: test-${{ github.event.pull_request.number }}
19+
cancel-in-progress: true
20+
21+
jobs:
22+
test:
23+
name: Run plugin tests
24+
runs-on: ubuntu-22.04
25+
steps:
26+
- name: Checkout marketplace
27+
uses: actions/checkout@v4
28+
with:
29+
path: plugins-repo
30+
fetch-depth: 0
31+
32+
# The host VitoDeploy app provides the classes plugins compile against
33+
# (App\Plugins\*, App\SiteFeatures\Action, App\Models\*, the SSH facade)
34+
# and the Tests\TestCase that auto-provisions a server/site. Plugin tests
35+
# run INSIDE this checkout.
36+
#
37+
# Pinned to the host's current development line (4.x). Plugins declare
38+
# min_vito_version 3.0.0, so the host API they bind to is stable across
39+
# 3.x/4.x; tracking 4.x surfaces forward-compat breakage early. To also
40+
# gate on an older line, add a matrix over `ref`.
41+
- name: Checkout host VitoDeploy app
42+
uses: actions/checkout@v4
43+
with:
44+
repository: vitodeploy/vito
45+
ref: 4.x
46+
path: vito
47+
48+
- name: Setup PHP
49+
uses: shivammathur/setup-php@v2
50+
with:
51+
php-version: "8.4"
52+
tools: composer
53+
54+
- name: Setup Node
55+
uses: actions/setup-node@v4
56+
with:
57+
node-version: "20"
58+
59+
- name: Cache host Composer packages
60+
id: composer-cache
61+
uses: actions/cache@v4
62+
with:
63+
path: vito/vendor
64+
key: vito-host-${{ hashFiles('vito/composer.lock') }}
65+
restore-keys: |
66+
vito-host-
67+
68+
- name: Install host dependencies
69+
working-directory: vito
70+
run: composer install --prefer-dist --no-progress
71+
72+
- name: Prepare host test environment
73+
working-directory: vito
74+
run: |
75+
touch storage/database-test.sqlite
76+
touch .env
77+
php artisan key:generate
78+
79+
# Only test plugins changed in this PR (matches validate.yml granularity).
80+
# If the diff is tooling-only (scripts/**), test every plugin so a runner
81+
# change can't silently break the suite.
82+
- name: Determine changed plugins
83+
id: changed
84+
working-directory: plugins-repo
85+
env:
86+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
87+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
88+
run: |
89+
set -euo pipefail
90+
changed_plugins="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- plugins/ \
91+
| awk -F/ 'NF>1 && $1=="plugins" {print $2}' | sort -u)"
92+
tooling="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- scripts/ | head -n1 || true)"
93+
94+
if [ -n "$tooling" ] || [ -z "$changed_plugins" ]; then
95+
echo "Tooling changed or no specific plugin diff; testing all plugins."
96+
echo "slugs=" >> "$GITHUB_OUTPUT"
97+
else
98+
echo "Changed plugins:"; printf ' - %s\n' $changed_plugins
99+
echo "slugs=$(printf '%s ' $changed_plugins)" >> "$GITHUB_OUTPUT"
100+
fi
101+
102+
- name: Run plugin tests
103+
working-directory: plugins-repo
104+
env:
105+
VITO_PATH: ${{ github.workspace }}/vito
106+
run: node scripts/test.mjs ${{ steps.changed.outputs.slugs }}

CONTRIBUTING.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,42 @@ keep your footprint minimal:
144144
- Declaring extra Composer `require` deps — plugins run inside the host app and
145145
use host classes; extra deps are flagged for host-compatibility review.
146146
147-
## 6. Open a pull request
147+
## 6. Test your plugin (optional but encouraged)
148+
149+
Your plugin's PHP compiles against classes that only exist in the **host
150+
VitoDeploy app** (`App\Plugins\*`, `App\SiteFeatures\Action`, `App\Models\*`,
151+
the `SSH` facade). So plugin tests can't run standalone here — they run **inside
152+
a checkout of the host app**, where `Tests\TestCase` auto-provisions a
153+
`$this->server` and `$this->site` for you and `SSH::fake()` intercepts remote
154+
commands.
155+
156+
Add tests under `plugins/<my-plugin>/tests/` (mirroring the host's
157+
`tests/Feature` layout). Each test class:
158+
159+
- is namespaced `Tests\Feature\Plugins\<Vendor>\<Name>\…`,
160+
- extends `Tests\TestCase`,
161+
- uses `Illuminate\Foundation\Testing\RefreshDatabase` if it touches the DB.
162+
163+
The runner stages your plugin into the host at
164+
`app/Vito/Plugins/<Vendor>/<Name>/`, copies your `tests/` into the host's
165+
`tests/Feature/Plugins/<Vendor>/<Name>/`, runs the host's PHPUnit, then cleans
166+
up. (`tests/` is never shipped in the published artifact.)
167+
168+
```bash
169+
# point at a local checkout of vitodeploy/vito with `composer install` run
170+
VITO_PATH=/path/to/vito node scripts/test.mjs my-plugin # one plugin
171+
VITO_PATH=/path/to/vito node scripts/test.mjs # all plugins
172+
```
173+
174+
See the official plugins' `tests/` for patterns: asserting `boot()` registers a
175+
site feature/type in config, faking SSH, and exception assertions on an Action's
176+
validation.
177+
178+
**CI runs your plugin's tests on every PR and a failure blocks merge.** A plugin
179+
with no `tests/` directory is reported as skipped (not a failure) — tests are
180+
opt-in per plugin, but when present they must pass.
181+
182+
## 7. Open a pull request
148183
149184
Push your branch and open a PR. Fill in the PR template. CI runs validation; a
150185
VitoDeploy maintainer reviews for safety and quality, then squash-merges. On

DESIGN.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,19 @@ no fork access. See SECURITY.md.
261261
`composer.json` via `jq`, never executes PR code): enforce one-plugin-per-PR,
262262
semver forward-bump, force PR title to `<name> <version>`, ping plugin author.
263263

264+
### `test.yml` (on PR) — run plugin tests inside the host app
265+
Plugin PHP compiles against host classes (`App\SiteFeatures\Action`,
266+
`App\Models\Worker`, `App\Plugins\Register*`, the `SSH` facade) and the host's
267+
`Tests\TestCase` (which auto-provisions a server/site), so tests can't run
268+
standalone in this repo. Instead `scripts/test.mjs` checks out `vitodeploy/vito`,
269+
stages each changed plugin into `app/Vito/Plugins/<Vendor>/<Name>/` and its
270+
`tests/` into `tests/Feature/Plugins/<Vendor>/<Name>/`, runs the host's PHPUnit
271+
scoped to that dir, and cleans up. Tests are **opt-in per plugin** (a plugin with
272+
no `tests/` is skipped, not failed) but **required when present** — a failure
273+
blocks merge. The job runs read-only (it executes PR code; no secrets, like
274+
`validate`). `tests/` is already excluded from the published artifact, so test
275+
files never ship. The host ref is pinned to `4.x`.
276+
264277
### `publish.yml` (on push to main) — incremental, `O(changed)`
265278
1. Skip unless `minisign.pub` is real and `MINISIGN_SECRET_KEY` is set.
266279
2. Diff the merge → changed plugin dirs (or `workflow_dispatch` explicit list).
@@ -319,6 +332,9 @@ Resolved:
319332
- **Publish granularity = incremental** (only changed plugins). ✓
320333
- **v1 app-side = marketplace display + homepage link**; installer rewiring
321334
deferred. ✓
335+
- **Testing = run plugin tests inside a host `vitodeploy/vito` checkout**
336+
(PHPUnit), staged by `scripts/test.mjs`; opt-in per plugin, required when
337+
present, host ref pinned to `4.x`. ✓
322338

323339
Open (non-blocking; sensible defaults applied):
324340
1. **Per-plugin `min_vito_version` enforcement** — advisory in v1 (metadata
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace Tests\Feature\Plugins\YourVendor\HelloWorld;
4+
5+
use App\Vito\Plugins\YourVendor\HelloWorld\Plugin;
6+
use Tests\TestCase;
7+
8+
/**
9+
* Starter test template.
10+
*
11+
* Plugin tests run inside a checkout of the host VitoDeploy app — `npm test`
12+
* (scripts/test.mjs) stages this plugin and its tests into the host and runs the
13+
* host's Pest. That gives you the real host classes (App\Plugins\*,
14+
* App\SiteFeatures\Action, App\Models\*, the SSH facade) plus the
15+
* auto-provisioned $this->user / $this->server / $this->site from Tests\TestCase.
16+
*
17+
* Namespace your tests `Tests\Feature\Plugins\<Vendor>\<Name>\...` and extend
18+
* Tests\TestCase. See the official plugins' tests/ for SSH-faking and
19+
* worker/vhost assertions:
20+
* https://github.com/vitodeploy/plugins/tree/main/plugins
21+
*/
22+
class PluginTest extends TestCase
23+
{
24+
public function test_plugin_boots_without_error(): void
25+
{
26+
(new Plugin)->boot();
27+
28+
$this->assertSame('Hello World', (new Plugin)->getName());
29+
}
30+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
},
1010
"scripts": {
1111
"validate": "node scripts/validate.mjs",
12+
"test": "node scripts/test.mjs",
1213
"pack": "node scripts/pack.mjs",
1314
"publish": "node scripts/publish.mjs"
1415
},
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace Tests\Feature\Plugins\Vitodeploy\LaravelOctanePlugin;
4+
5+
use App\Facades\SSH;
6+
use App\Models\Worker;
7+
use App\Vito\Plugins\Vitodeploy\LaravelOctanePlugin\Actions\Enable;
8+
use Illuminate\Foundation\Testing\RefreshDatabase;
9+
use Illuminate\Http\Request;
10+
use Tests\TestCase;
11+
12+
/**
13+
* Tests run inside a checkout of the host VitoDeploy app (see scripts/test.mjs).
14+
* Tests\TestCase auto-provisions $this->user, $this->server (Nginx, PHP,
15+
* Supervisor, ...) and $this->site, so the plugin's Action can run against a
16+
* realistic site with SSH faked.
17+
*
18+
* Note: a full Enable::handle() with a valid port also calls updateVHost(),
19+
* which renders host vhost-block views that aren't present in the bare test
20+
* site. Asserting the worker/type_data side effects of a successful enable
21+
* therefore requires either seeding the site's vhost or stubbing the webserver
22+
* — left to the plugin author. These tests cover the parts the Action owns
23+
* outright: validation and the active() guard.
24+
*/
25+
class EnableTest extends TestCase
26+
{
27+
use RefreshDatabase;
28+
29+
public function test_enable_rejects_invalid_port(): void
30+
{
31+
SSH::fake();
32+
33+
$request = Request::create('/', 'POST', ['port' => 70000]);
34+
$request->setLaravelSession(app('session.store'));
35+
36+
$this->assertThrows(fn () => (new Enable($this->site))->handle($request));
37+
38+
$this->assertSame(0, Worker::query()->where('name', 'laravel-octane')->count());
39+
}
40+
41+
public function test_action_is_active_when_octane_disabled(): void
42+
{
43+
$this->assertTrue((new Enable($this->site))->active());
44+
45+
$typeData = $this->site->type_data ?? [];
46+
data_set($typeData, 'octane', true);
47+
$this->site->type_data = $typeData;
48+
$this->site->save();
49+
50+
$this->assertFalse((new Enable($this->site->refresh()))->active());
51+
}
52+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace Tests\Feature\Plugins\Vitodeploy\LaravelReverbPlugin;
4+
5+
use App\Vito\Plugins\Vitodeploy\LaravelReverbPlugin\Plugin;
6+
use App\Vito\Plugins\Vitodeploy\LaravelReverbPlugin\SiteTypes\LaravelReverb;
7+
use Tests\TestCase;
8+
9+
/**
10+
* boot() registers a site feature, its enable/disable actions, a views
11+
* namespace, and the laravel-reverb site type into the host's runtime config.
12+
* Asserting against that config proves the plugin wires itself into Vito.
13+
*/
14+
class PluginTest extends TestCase
15+
{
16+
public function test_boot_registers_site_feature_and_type(): void
17+
{
18+
(new Plugin)->boot();
19+
20+
$features = config('site.types.laravel.features');
21+
$this->assertIsArray($features);
22+
$this->assertArrayHasKey('laravel-reverb', $features);
23+
$this->assertSame('Laravel Reverb', $features['laravel-reverb']['label']);
24+
25+
$type = config('site.types.'.LaravelReverb::id());
26+
$this->assertIsArray($type);
27+
$this->assertSame(LaravelReverb::class, $type['handler']);
28+
}
29+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace Tests\Feature\Plugins\Vitodeploy\TinyFileManagerPlugin;
4+
5+
use App\Vito\Plugins\Vitodeploy\TinyFileManagerPlugin\Plugin;
6+
use App\Vito\Plugins\Vitodeploy\TinyFileManagerPlugin\TinyFileManager;
7+
use Tests\TestCase;
8+
9+
/**
10+
* boot() registers a views namespace and the tiny-file-manager site type (with
11+
* its create form) into the host's runtime config.
12+
*/
13+
class PluginTest extends TestCase
14+
{
15+
public function test_boot_registers_site_type(): void
16+
{
17+
(new Plugin)->boot();
18+
19+
$type = config('site.types.'.TinyFileManager::id());
20+
21+
$this->assertIsArray($type);
22+
$this->assertSame('Tiny File Manager', $type['label']);
23+
$this->assertSame(TinyFileManager::class, $type['handler']);
24+
$this->assertNotEmpty($type['form']);
25+
}
26+
}

0 commit comments

Comments
 (0)