Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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 }}
37 changes: 36 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<my-plugin>/tests/` (mirroring the host's
`tests/Feature` layout). Each test class:

- is namespaced `Tests\Feature\Plugins\<Vendor>\<Name>\…`,
- 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/<Vendor>/<Name>/`, copies your `tests/` into the host's
`tests/Feature/Plugins/<Vendor>/<Name>/`, 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
Expand Down
16 changes: 16 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name> <version>`, 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/<Vendor>/<Name>/` and its
`tests/` into `tests/Feature/Plugins/<Vendor>/<Name>/`, 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).
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions examples/hello-world/tests/Feature/PluginTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Tests\Feature\Plugins\YourVendor\HelloWorld;

use App\Vito\Plugins\YourVendor\HelloWorld\Plugin;
use Tests\TestCase;

/**
* Starter test template.
*
* Plugin tests run inside a checkout of the host VitoDeploy app — `npm test`
* (scripts/test.mjs) stages this plugin and its tests into the host and runs the
* host's Pest. That gives you the real host classes (App\Plugins\*,
* App\SiteFeatures\Action, App\Models\*, the SSH facade) plus the
* auto-provisioned $this->user / $this->server / $this->site from Tests\TestCase.
*
* Namespace your tests `Tests\Feature\Plugins\<Vendor>\<Name>\...` 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());
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
52 changes: 52 additions & 0 deletions plugins/laravel-octane-plugin/tests/Feature/EnableTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace Tests\Feature\Plugins\Vitodeploy\LaravelOctanePlugin;

use App\Facades\SSH;
use App\Models\Worker;
use App\Vito\Plugins\Vitodeploy\LaravelOctanePlugin\Actions\Enable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;

/**
* Tests run inside a checkout of the host VitoDeploy app (see scripts/test.mjs).
* Tests\TestCase auto-provisions $this->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());
}
}
29 changes: 29 additions & 0 deletions plugins/laravel-reverb-plugin/tests/Feature/PluginTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Tests\Feature\Plugins\Vitodeploy\LaravelReverbPlugin;

use App\Vito\Plugins\Vitodeploy\LaravelReverbPlugin\Plugin;
use App\Vito\Plugins\Vitodeploy\LaravelReverbPlugin\SiteTypes\LaravelReverb;
use Tests\TestCase;

/**
* boot() registers a site feature, its enable/disable actions, a views
* namespace, and the laravel-reverb site type into the host's runtime config.
* Asserting against that config proves the plugin wires itself into Vito.
*/
class PluginTest extends TestCase
{
public function test_boot_registers_site_feature_and_type(): void
{
(new Plugin)->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']);
}
}
26 changes: 26 additions & 0 deletions plugins/tiny-file-manager-plugin/tests/Feature/PluginTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Tests\Feature\Plugins\Vitodeploy\TinyFileManagerPlugin;

use App\Vito\Plugins\Vitodeploy\TinyFileManagerPlugin\Plugin;
use App\Vito\Plugins\Vitodeploy\TinyFileManagerPlugin\TinyFileManager;
use Tests\TestCase;

/**
* boot() registers a views namespace and the tiny-file-manager site type (with
* its create form) into the host's runtime config.
*/
class PluginTest extends TestCase
{
public function test_boot_registers_site_type(): void
{
(new Plugin)->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']);
}
}
Loading
Loading