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
28 changes: 28 additions & 0 deletions .ddev/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@ corepack_enable: false
# ext-gmp is required by composer.json (prod Dockerfile installs it too).
webimage_extra_packages:
- php8.4-gmp
# Shared libraries for Playwright's headless Chromium, so `ddev npm run test:e2e`
# runs the browser in this container (Node lives here, not on the host). The
# browser binary itself is installed by `ddev npm run test:e2e:install`.
- fonts-liberation
- libasound2t64
- libatk-bridge2.0-0t64
- libatk1.0-0t64
- libatspi2.0-0t64
- libcairo2
- libcups2t64
- libdbus-1-3
- libdrm2
- libgbm1
- libglib2.0-0t64
- libnspr4
- libnss3
- libpango-1.0-0
- libx11-6
- libxcb1
- libxcomposite1
- libxdamage1
- libxext6
- libxfixes3
- libxkbcommon0
- libxrandr2

# Postgres + redis + mail overrides. These are real container env vars, so they
# take precedence over .env (Laravel's Dotenv does not overwrite existing env).
Expand All @@ -39,6 +64,9 @@ web_environment:
- MAIL_MAILER=smtp
- MAIL_HOST=localhost
- MAIL_PORT=1025
# Inside the mounted project, so the Playwright browser survives `ddev restart`
# (the container's home directory does not). `npm ci` clears it with node_modules.
- PLAYWRIGHT_BROWSERS_PATH=/var/www/html/node_modules/.cache/ms-playwright

# The test suite (RefreshDatabase) runs against a separate `db_test` database so
# its migrate:fresh / sequence churn never touches dev data. Postgres has no
Expand Down
96 changes: 96 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,99 @@ jobs:

- name: Run static analysis
run: composer analyze -- --no-progress

e2e:
# The Playwright suite in tests/e2e, minus the @live specs (they skip without
# PROXMOX_* credentials, which CI doesn't have).
runs-on: ubuntu-latest

services:
postgres:
image: postgres:17
env:
POSTGRES_DB: convoy
POSTGRES_USER: convoy_user
POSTGRES_PASSWORD: YzLa2BCBwDGWVkpG
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U convoy_user -d convoy"
--health-interval=5s
--health-timeout=5s
--health-retries=20

redis:
image: redis:7.0-alpine
ports:
- 6379:6379
options: >-
--health-cmd="redis-cli ping"
--health-interval=5s
--health-timeout=5s
--health-retries=20

env:
E2E_BASE_URL: http://127.0.0.1:8000

steps:
- uses: actions/checkout@v4

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: gmp, pcntl, redis, pdo_pgsql, pgsql, bcmath, intl
# The upload spec sends 16 MiB chunks; PHP's default post_max_size would shrink them.
ini-values: post_max_size=100M, upload_max_filesize=100M
coverage: none
tools: composer:v2

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'

- name: Create environment file
run: |
cp .env.ci .env
sed -i 's/^DB_HOST=.*/DB_HOST=127.0.0.1/' .env
sed -i 's/^REDIS_HOST=.*/REDIS_HOST=127.0.0.1/' .env
sed -i 's/^REDIS_PASSWORD=.*/REDIS_PASSWORD=/' .env
sed -i "s#^APP_URL=.*#APP_URL=$E2E_BASE_URL#" .env
# .env.ci has no trailing newline, so start a fresh line before appending.
printf '\nIMAGE_REGISTRY_URL=http://127.0.0.1:4174/registry.json\n' >> .env

- name: Install Composer dependencies
run: composer install --prefer-dist --no-interaction --no-progress

- name: Install NPM dependencies
run: npm ci

- name: Build frontend assets
run: npm run build

- name: Run database migrations
run: php artisan migrate --force

- name: Install Playwright's Chromium
run: npx playwright install --with-deps --only-shell chromium

- name: Start the panel
run: |
PHP_CLI_SERVER_WORKERS=4 php artisan serve --host=127.0.0.1 --port=8000 > storage/logs/serve.log 2>&1 &
timeout 30 sh -c 'until curl -sf -o /dev/null "$E2E_BASE_URL/auth/login"; do sleep 1; done'

- name: Run end-to-end tests
run: npm run test:e2e

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
tests/e2e/.report
tests/e2e/.results
storage/logs
retention-days: 7
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,8 @@ lang/php_*.json
*.tmp.mjs
*.tmp.php
.tanstack/

# Playwright
/tests/e2e/.auth
/tests/e2e/.results
/tests/e2e/.report
50 changes: 50 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,56 @@ ddev snapshot --name pre-migration
ddev snapshot restore pre-migration
```

## End-to-end tests (Playwright)

The Playwright suite lives in `tests/e2e/` and runs **inside the ddev web container**, like the rest
of Node. Its headless Chromium runs there too: `.ddev/config.yaml` installs the libraries it needs
through `webimage_extra_packages`, and `PLAYWRIGHT_BROWSERS_PATH` keeps the browser under
`node_modules/.cache` so it survives `ddev restart`. Nothing is installed on the host.

One-time setup (and again after `npm ci`, which clears the browser):

```bash
ddev restart # picks up the Chromium libraries
ddev npm run test:e2e:install # downloads headless Chromium into the container
```

Add this to the panel's `.env` so the catalogue specs read the fixture instead of the live CDN.
`tests/e2e/fixtures/registry.json` is served on that port while the suite runs:

```dotenv
IMAGE_REGISTRY_URL=http://127.0.0.1:4174/registry.json
```

Then:

```bash
ddev npm run test:e2e # everything except @live
ddev npm run test:e2e -- catalogue # one spec file
ddev npm run test:e2e -- --grep @live # needs PROXMOX_* exported, see below
```

- **Base URL.** It is `E2E_BASE_URL`, then ddev's `DDEV_PRIMARY_URL`, so it is the project you
run it in. Outside ddev it falls back to `https://convoy-pr168.ddev.site`. Certificate errors are
ignored because the container doesn't trust mkcert's CA.
- **Fixtures.** Global setup runs `php artisan db:seed --class=E2eSeeder` before every run. It
resets three accounts: `admin@e2e.test` (Superadmin), `user@e2e.test` (no role) and
`guest@e2e.test` (guest), all with the password `password`. It also switches guest accounts off,
deletes the `E2E …` roles and the images an earlier run imported or uploaded, and recreates the
`E2E Uploads` image. A run therefore works on a fresh `migrate:fresh` and on a reused database
alike. It is also why the runner must sit where `php artisan` can reach the panel's database. Set
`E2E_SKIP_SEED=1` to point the suite at a panel you've seeded yourself.
- **Isolation.** The specs share one database and one global guest switch, so they run with one
worker.
- **`@live` specs.** Specs that need a real node are in `tests/e2e/live/`, tagged `@live`. They skip
unless `PROXMOX_FQDN`, `PROXMOX_TOKEN_ID` and `PROXMOX_TOKEN_SECRET` are set in the runner's
environment, not just in `.env` (see Live End-to-End Testing).
- **Reports.** Traces and screenshots of failures land in `tests/e2e/.results`, and the HTML report
in `tests/e2e/.report`. Both are gitignored.

CI runs the same suite (minus `@live`) in the `e2e` job of `.github/workflows/tests.yml`, against
`php artisan serve`.

## Running PHP, Composer, Artisan, and npm

There is no host-side `php` / `composer` / `node`; they run inside the ddev web container:
Expand Down
11 changes: 11 additions & 0 deletions app/Models/ImageDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ public function getRouteKeyName(): string
return 'uuid';
}

/**
* `{image_version}` under `{image_definition}` is resolved through a relation
* named after the parameter (see ImageGroup), and here that is `versions()`.
*/
protected function childRouteBindingRelationshipName($childType): string
{
return $childType === 'image_version'
? 'versions'
: parent::childRouteBindingRelationshipName($childType);
}

/**
* @return BelongsTo<ImageGroup, $this>
*/
Expand Down
12 changes: 12 additions & 0 deletions app/Models/ImageGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ public function getRouteKeyName(): string
return 'uuid';
}

/**
* The admin API is registered with scopeBindings(), so `{image_definition}`
* under `{image_group}` is resolved through a relation named after the
* parameter -- `imageDefinitions()`, which is `definitions()` here.
*/
protected function childRouteBindingRelationshipName($childType): string
{
return $childType === 'image_definition'
? 'definitions'
: parent::childRouteBindingRelationshipName($childType);
}

/**
* @return HasMany<ImageDefinition, $this>
*/
Expand Down
149 changes: 149 additions & 0 deletions database/seeders/E2eSeeder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

namespace Database\Seeders;

use App\Enums\User\UserType;
use App\Models\AdminRole;
use App\Models\ImageDefinition;
use App\Models\ImageGroup;
use App\Models\ImageUpload;
use App\Models\User;
use App\Settings\PermissionSettings;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;

/**
* Puts the database into the state the Playwright suite (`tests/e2e`) starts from.
*
* Run by the suite's global setup before every run, so it resets rather than
* adds: the three accounts get their password, type and role back, guest
* accounts are switched off, and whatever a previous run imported or uploaded
* is deleted. The account details are mirrored in `tests/e2e/support/users.ts`.
*
* ddev artisan db:seed --class=E2eSeeder
*/
class E2eSeeder extends Seeder
{
public const PASSWORD = 'password';

public const SUPERADMIN_EMAIL = 'admin@e2e.test';

public const USER_EMAIL = 'user@e2e.test';

public const GUEST_EMAIL = 'guest@e2e.test';

/** Roles a run creates are named with this prefix, so the next run can find them. */
public const ROLE_PREFIX = 'E2E ';

public const UPLOAD_GROUP = 'E2E Uploads';

public const UPLOAD_IMAGE = 'E2E Upload Target';

public function run(): void
{
if (app()->isProduction()) {
$this->command->error('E2eSeeder resets accounts and images. Refusing to run in production.');

return;
}

$this->seedAccounts();
$this->resetSettings();
$this->resetRoles();
$this->resetImages();

$this->command->info('Seeded the e2e accounts and fixtures.');
}

private function seedAccounts(): void
{
$this->account(self::SUPERADMIN_EMAIL, 'E2E Superadmin', [
'type' => UserType::STANDARD,
'admin_role_id' => AdminRole::superadmin()->id,
]);

$this->account(self::USER_EMAIL, 'E2E User', [
'type' => UserType::STANDARD,
'admin_role_id' => null,
]);

$this->account(self::GUEST_EMAIL, 'E2E Guest', [
'type' => UserType::GUEST,
'admin_role_id' => null,
]);
}

/**
* @param array<string, mixed> $attributes
*/
private function account(string $email, string $name, array $attributes): void
{
$user = User::query()->firstOrNew(['email' => $email]);

$user->forceFill([
'name' => $name,
'password' => Hash::make(self::PASSWORD),
'email_verified_at' => $user->email_verified_at ?? now(),
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
...$attributes,
])->save();
}

private function resetSettings(): void
{
$settings = app(PermissionSettings::class);
$settings->allow_guest_accounts = false;
$settings->save();
}

private function resetRoles(): void
{
// users.admin_role_id is nullOnDelete, so anyone left on one of these drops back to no role.
AdminRole::query()
->where('is_system', false)
->where('name', 'like', self::ROLE_PREFIX.'%')
->delete();
}

private function resetImages(): void
{
$catalogue = json_decode(
(string) file_get_contents(base_path('tests/e2e/fixtures/registry.json')),
true,
);

$groups = collect($catalogue['groups'] ?? []);

// Definitions and versions cascade with their group. An import adopts a group
// by name as well as by slug, so both are cleared.
ImageGroup::query()
->whereIn('registry_slug', $groups->pluck('id'))
->orWhereIn('name', $groups->pluck('display_name')->push(self::UPLOAD_GROUP))
->get()
->each->delete();

ImageUpload::query()
->whereIn('user_id', User::query()->where('email', self::SUPERADMIN_EMAIL)->select('id'))
->get()
->each->delete();

$group = ImageGroup::query()->create([
'name' => self::UPLOAD_GROUP,
'is_admin_only' => false,
]);

ImageDefinition::query()->create([
'image_group_id' => $group->id,
'name' => self::UPLOAD_IMAGE,
'ostype' => 'l26',
'hardware' => [],
'is_admin_only' => false,
]);

// The catalogue is cached by URL; drop it so an edited fixture is read fresh.
Cache::forget('images.registry.'.sha1((string) config('convoy.registry.url')));
}
}
Loading
Loading