From cbd031e7f15cf6e84bb300a55c20c0daf665b24d Mon Sep 17 00:00:00 2001 From: Eric Wang <37554696+ericwang401@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:11:23 +0000 Subject: [PATCH 1/3] fix(images): resolve nested definitions and versions through their relations The admin API is registered with scopeBindings(), which resolves a child route parameter through a parent relation named after it: `{image_definition}` under `{image_group}` through ImageGroup::imageDefinitions(), and `{image_version}` under `{image_definition}` through ImageDefinition::imageVersions(). Neither exists -- the relations are definitions() and versions() -- so every route below /image-groups/{group}/images/{definition} answered 500, taking the Versions dialog, image edit and version publishing with it. Map those two parameters onto the existing relations, and cover the routes over HTTP, including a child requested under the wrong parent still 404ing. --- app/Models/ImageDefinition.php | 11 +++ app/Models/ImageGroup.php | 12 +++ .../Feature/Images/ImageRouteBindingTest.php | 84 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 tests/Feature/Images/ImageRouteBindingTest.php diff --git a/app/Models/ImageDefinition.php b/app/Models/ImageDefinition.php index 89a30f80ec9..00e30040cdf 100644 --- a/app/Models/ImageDefinition.php +++ b/app/Models/ImageDefinition.php @@ -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 */ diff --git a/app/Models/ImageGroup.php b/app/Models/ImageGroup.php index 55973979f73..ac8e3ce39e4 100644 --- a/app/Models/ImageGroup.php +++ b/app/Models/ImageGroup.php @@ -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 */ diff --git a/tests/Feature/Images/ImageRouteBindingTest.php b/tests/Feature/Images/ImageRouteBindingTest.php new file mode 100644 index 00000000000..ed4459ee6df --- /dev/null +++ b/tests/Feature/Images/ImageRouteBindingTest.php @@ -0,0 +1,84 @@ + $name]); + $definition = ImageDefinition::create([ + 'image_group_id' => $group->id, + 'name' => "{$name} 1", + 'ostype' => 'l26', + ]); + $version = $definition->versions()->create([ + 'version' => '1.0.0', + 'disks' => [[ + 'slot' => 'scsi0', + 'role' => ImageDiskRole::SYSTEM->value, + 'url' => 'https://example.invalid/disk.qcow2', + 'path' => null, + 'sha256' => str_repeat('b', 64), + 'size' => 1024, + 'virtual_size' => 2048, + 'format' => 'qcow2', + ]], + ]); + + return [$group, $definition, $version]; +} + +it('shows a definition through its group', function () { + [$group, $definition] = imageBindingChain('Debian'); + + $this->actingAs(admin()) + ->getJson("/api/admin/image-groups/{$group->uuid}/images/{$definition->uuid}") + ->assertOk() + ->assertJsonPath('data.uuid', $definition->uuid); +}); + +it('lists and shows a definition’s versions', function () { + [$group, $definition, $version] = imageBindingChain('Debian'); + $base = "/api/admin/image-groups/{$group->uuid}/images/{$definition->uuid}/versions"; + + $this->actingAs(admin()) + ->getJson($base) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.uuid', $version->uuid); + + $this->actingAs(admin()) + ->getJson("{$base}/{$version->uuid}") + ->assertOk() + ->assertJsonPath('data.uuid', $version->uuid); +}); + +it('does not find a definition under another group', function () { + [$group] = imageBindingChain('Debian'); + [, $elsewhere] = imageBindingChain('Ubuntu'); + + $this->actingAs(admin()) + ->getJson("/api/admin/image-groups/{$group->uuid}/images/{$elsewhere->uuid}") + ->assertNotFound(); +}); + +it('does not find a version under another definition', function () { + [$group, $definition] = imageBindingChain('Debian'); + [, , $elsewhere] = imageBindingChain('Ubuntu'); + + $this->actingAs(admin()) + ->getJson("/api/admin/image-groups/{$group->uuid}/images/{$definition->uuid}/versions/{$elsewhere->uuid}") + ->assertNotFound(); +}); From dd8a431c4873c9ea5bde7c17d37d4702c215804b Mon Sep 17 00:00:00 2001 From: Eric Wang <37554696+ericwang401@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:05:33 +0000 Subject: [PATCH 2/3] test(e2e): add a Playwright suite for the v5 admin workstreams Runs inside the ddev web container (`ddev npm run test:e2e`), with headless Chromium's libraries added to the web image and the browser kept under node_modules/.cache. Global setup seeds E2eSeeder, which resets a superadmin, a plain user and a guest, and clears what earlier runs imported or uploaded, so a run works the same on a fresh migrate as on a reused database. Covers, without a Proxmox node: - catalogue import from a local registry.json fixture served on :4174 (one version per import, re-import adds nothing, Windows Server 2025 gets a v2.0 TPM) - chunked upload of a 48 MiB image in three chunks, and resuming an interrupted one from the server's offset - a limited admin role: only its nav items, 403s elsewhere, and no role authoring for a non-superadmin - the guest-accounts switch locking out a signed-in guest Live-node specs (adoption, migration, sharing) are tagged @live and skip without PROXMOX_* in the runner's environment. A new `e2e` CI job runs the rest against `php artisan serve`. --- .ddev/config.yaml | 28 +++++ .github/workflows/tests.yml | 95 ++++++++++++++++ .gitignore | 5 + AGENTS.md | 50 +++++++++ database/seeders/E2eSeeder.php | 149 +++++++++++++++++++++++++ package-lock.json | 66 +++++++++++- package.json | 8 +- playwright.config.ts | 53 +++++++++ tests/e2e/auth.setup.ts | 11 ++ tests/e2e/catalogue.spec.ts | 107 ++++++++++++++++++ tests/e2e/fixtures/registry.json | 110 +++++++++++++++++++ tests/e2e/global-setup.ts | 25 +++++ tests/e2e/guests.spec.ts | 88 +++++++++++++++ tests/e2e/live/live-node.spec.ts | 49 +++++++++ tests/e2e/roles.spec.ts | 150 ++++++++++++++++++++++++++ tests/e2e/support/api.ts | 91 ++++++++++++++++ tests/e2e/support/auth.ts | 11 ++ tests/e2e/support/registry-server.mjs | 18 ++++ tests/e2e/support/registry.ts | 4 + tests/e2e/support/users.ts | 20 ++++ tests/e2e/tsconfig.json | 15 +++ tests/e2e/upload.spec.ts | 119 ++++++++++++++++++++ 22 files changed, 1269 insertions(+), 3 deletions(-) create mode 100644 database/seeders/E2eSeeder.php create mode 100644 playwright.config.ts create mode 100644 tests/e2e/auth.setup.ts create mode 100644 tests/e2e/catalogue.spec.ts create mode 100644 tests/e2e/fixtures/registry.json create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/guests.spec.ts create mode 100644 tests/e2e/live/live-node.spec.ts create mode 100644 tests/e2e/roles.spec.ts create mode 100644 tests/e2e/support/api.ts create mode 100644 tests/e2e/support/auth.ts create mode 100644 tests/e2e/support/registry-server.mjs create mode 100644 tests/e2e/support/registry.ts create mode 100644 tests/e2e/support/users.ts create mode 100644 tests/e2e/tsconfig.json create mode 100644 tests/e2e/upload.spec.ts diff --git a/.ddev/config.yaml b/.ddev/config.yaml index 8ae8ac62f10..2d77fe0fc6c 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -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). @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 39f2ffff3b8..238b6c2b889 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -88,3 +88,98 @@ 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 + echo 'IMAGE_REGISTRY_URL=http://127.0.0.1:4174/registry.json' >> .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 diff --git a/.gitignore b/.gitignore index e8637674bb1..535608c83dd 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,8 @@ lang/php_*.json *.tmp.mjs *.tmp.php .tanstack/ + +# Playwright +/tests/e2e/.auth +/tests/e2e/.results +/tests/e2e/.report diff --git a/AGENTS.md b/AGENTS.md index c151d808366..6b8122bbbaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/database/seeders/E2eSeeder.php b/database/seeders/E2eSeeder.php new file mode 100644 index 00000000000..90f5c78d3fb --- /dev/null +++ b/database/seeders/E2eSeeder.php @@ -0,0 +1,149 @@ +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 $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'))); + } +} diff --git a/package-lock.json b/package-lock.json index 519cbb6c1aa..cde9761454c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "panel", + "name": "html", "lockfileVersion": 3, "requires": true, "packages": { @@ -50,8 +50,10 @@ "zustand": "^5.0.0" }, "devDependencies": { + "@playwright/test": "^1.64.1", "@trivago/prettier-plugin-sort-imports": "^5.2.0", "@types/byte-size": "^8.1.2", + "@types/node": "^22.20.5", "@types/novnc__novnc": "^1.6.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -988,6 +990,22 @@ "integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==", "license": "MPL-2.0" }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3108,6 +3126,16 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/novnc__novnc": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@types/novnc__novnc/-/novnc__novnc-1.6.0.tgz", @@ -4602,6 +4630,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -5190,6 +5247,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/unplugin": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", diff --git a/package.json b/package.json index 384bea09aa0..e369b44c7f2 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,10 @@ "prebuild": "npm run types:generate", "build": "vite build", "dev": "vite --host 0.0.0.0", - "tc": "tsc --noEmit --project ./", - "mail": "npm --prefix emails ci && npm --prefix emails run build" + "tc": "tsc --noEmit --project ./ && tsc --noEmit --project ./tests/e2e", + "mail": "npm --prefix emails ci && npm --prefix emails run build", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install --only-shell chromium" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -56,8 +58,10 @@ "zustand": "^5.0.0" }, "devDependencies": { + "@playwright/test": "^1.64.1", "@trivago/prettier-plugin-sort-imports": "^5.2.0", "@types/byte-size": "^8.1.2", + "@types/node": "^22.20.5", "@types/novnc__novnc": "^1.6.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000000..4ecd1f222b4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,53 @@ +import { defineConfig, devices } from '@playwright/test' + +import { REGISTRY_PORT } from './tests/e2e/support/registry' + +/** + * End-to-end suite. Runs inside the ddev web container (`ddev npm run test:e2e`) + * against the panel that container serves; see AGENTS.md for the one-time setup. + */ +const baseURL = + process.env.E2E_BASE_URL ?? + process.env.DDEV_PRIMARY_URL ?? + 'https://convoy-pr168.ddev.site' + +export default defineConfig({ + testDir: './tests/e2e', + outputDir: './tests/e2e/.results', + globalSetup: './tests/e2e/global-setup.ts', + // The specs share one database and one global guest switch, so they run one at a time. + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI + ? [ + ['github'], + ['html', { open: 'never', outputFolder: 'tests/e2e/.report' }], + ] + : [ + ['list'], + ['html', { open: 'never', outputFolder: 'tests/e2e/.report' }], + ], + use: { + baseURL, + // ddev's certificates are signed by mkcert's local CA, which the container's Chromium doesn't trust. + ignoreHTTPSErrors: true, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + // The image catalogue fixture, read by the panel through IMAGE_REGISTRY_URL. + webServer: { + command: 'node tests/e2e/support/registry-server.mjs', + port: REGISTRY_PORT, + reuseExistingServer: !process.env.CI, + }, + projects: [ + { name: 'setup', testMatch: /auth\.setup\.ts/ }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + dependencies: ['setup'], + }, + ], +}) diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts new file mode 100644 index 00000000000..8a6ea4f2a80 --- /dev/null +++ b/tests/e2e/auth.setup.ts @@ -0,0 +1,11 @@ +import { test as setup } from '@playwright/test' + +import { signIn } from './support/auth' +import { storageState } from './support/users' + +for (const account of ['superadmin', 'user'] as const) { + setup(`sign in as ${account}`, async ({ page }) => { + await signIn(page, account) + await page.context().storageState({ path: storageState(account) }) + }) +} diff --git a/tests/e2e/catalogue.spec.ts b/tests/e2e/catalogue.spec.ts new file mode 100644 index 00000000000..a90215c38b5 --- /dev/null +++ b/tests/e2e/catalogue.spec.ts @@ -0,0 +1,107 @@ +import { Page, expect, test } from '@playwright/test' + +import { api, getData } from './support/api' +import { REGISTRY_URL } from './support/registry' +import { storageState } from './support/users' + +test.use({ storageState: storageState('superadmin') }) +test.describe.configure({ mode: 'serial' }) + +interface ImportResult { + slug: string + imageGroupUuid: string + imageDefinitionUuid: string + version: string + created: boolean +} + +const definitionUrl = (result: ImportResult) => + `/api/admin/image-groups/${result.imageGroupUuid}/images/${result.imageDefinitionUuid}` + +const definitionOf = (page: Page, result: ImportResult) => + getData<{ hardware: Record }>(page, definitionUrl(result)) + +const versionsOf = (page: Page, result: ImportResult) => + getData(page, `${definitionUrl(result)}/versions`) + +const openCatalogue = async (page: Page) => { + await page.goto('/admin/images') + await page.getByRole('button', { name: 'Browse catalogue' }).first().click() + + const sheet = page.getByRole('dialog') + await expect(sheet.getByText('Convoy E2E Catalogue')).toBeVisible() + + return sheet +} + +const row = (sheet: ReturnType, display: string) => + sheet.locator('[data-slot="item"]').filter({ hasText: display }) + +/** Clicks Import on a catalogue row and returns what the panel made of it. */ +const importFromSheet = async (page: Page, display: string) => { + const sheet = await openCatalogue(page) + + const response = page.waitForResponse( + r => + r.url().endsWith('/api/admin/images/registry/imports') && + r.request().method() === 'POST' + ) + await row(sheet, display).getByRole('button', { name: 'Import' }).click() + + const [result] = (await (await response).json()).data as ImportResult[] + + return { sheet, result } +} + +test('reads the fixture catalogue, not the CDN', async ({ page }) => { + const catalogue = await getData<{ url: string }>( + page, + '/api/admin/images/registry?refresh=1' + ) + + expect( + catalogue.url, + `Set IMAGE_REGISTRY_URL=${REGISTRY_URL} in the panel's environment (see AGENTS.md)` + ).toBe(REGISTRY_URL) +}) + +test('importing an entry once creates one version', async ({ page }) => { + const { sheet, result } = await importFromSheet(page, 'Debian 13') + + expect(result.created).toBe(true) + await expect( + page.getByText(`Imported build ${result.version}`) + ).toBeVisible() + + const debian = row(sheet, 'Debian 13') + await expect(debian.getByText('Imported', { exact: true })).toBeVisible() + await expect(debian.getByRole('button', { name: 'Import' })).toBeDisabled() + + expect(await versionsOf(page, result)).toHaveLength(1) +}) + +test('importing the same build again adds nothing', async ({ page }) => { + const response = await api(page).post( + '/api/admin/images/registry/imports', + { + templates: ['debian-13-amd64'], + } + ) + expect(response.ok()).toBe(true) + + const [result] = (await response.json()).data as ImportResult[] + expect(result.created).toBe(false) + expect(await versionsOf(page, result)).toHaveLength(1) + + const sheet = await openCatalogue(page) + await expect( + row(sheet, 'Debian 13').getByRole('button', { name: 'Import' }) + ).toBeDisabled() +}) + +test('Windows Server 2025 is imported with a v2.0 TPM', async ({ page }) => { + const { result } = await importFromSheet(page, 'Windows Server 2025') + expect(result.created).toBe(true) + + expect((await definitionOf(page, result)).hardware.tpm).toBe('v2.0') +}) diff --git a/tests/e2e/fixtures/registry.json b/tests/e2e/fixtures/registry.json new file mode 100644 index 00000000000..6d86ebcbdb5 --- /dev/null +++ b/tests/e2e/fixtures/registry.json @@ -0,0 +1,110 @@ +{ + "schema_version": "2", + "name": "Convoy E2E Catalogue", + "description": "Fixture served to the panel by the Playwright suite", + "generated_at": "2026-09-04T10:25:51.917Z", + "groups": [ + { + "id": "debian", + "display_name": "Debian", + "description": null, + "templates": [ + { + "name": "debian-13-amd64", + "display": "Debian 13", + "arch": "amd64", + "suggested_vmid": 6102, + "built_at": "2026-09-04T05:28:06Z", + "disks": [ + { + "slot": "scsi0", + "role": "system", + "format": "qcow2", + "file": "debian-13-amd64-1111111111111111111111111111111111111111111111111111111111111111.qcow2", + "url": "https://images.e2e.invalid/templates/debian/debian-13-amd64/1111111111111111111111111111111111111111111111111111111111111111.qcow2", + "sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "size": 901424128, + "virtual_size": "5G", + "options": { + "discard": "on", + "ssd": 1 + } + } + ], + "hardware": { + "agent": 1, + "bios": "seabios", + "ciuser": "root", + "cpu": "host", + "machine": "q35", + "net_model": "virtio", + "ostype": "l26", + "scsihw": "virtio-scsi-single", + "serial0": "socket" + }, + "minimum": { + "cores": 1, + "memory": 2048 + } + } + ] + }, + { + "id": "windows-server", + "display_name": "Windows Server", + "description": null, + "templates": [ + { + "name": "windows-server-2025-amd64", + "display": "Windows Server 2025 Datacenter", + "arch": "amd64", + "suggested_vmid": 2002, + "built_at": "2026-08-04T16:53:31Z", + "disks": [ + { + "slot": "scsi0", + "role": "system", + "format": "qcow2", + "file": "windows-server-2025-amd64-2222222222222222222222222222222222222222222222222222222222222222.qcow2", + "url": "https://images.e2e.invalid/templates/windows-server/windows-server-2025-amd64/2222222222222222222222222222222222222222222222222222222222222222.qcow2", + "sha256": "2222222222222222222222222222222222222222222222222222222222222222", + "size": 7900000000, + "virtual_size": "32G", + "options": { + "discard": "on" + } + }, + { + "slot": "efidisk0", + "role": "efivars", + "format": "raw", + "file": "windows-server-2025-amd64-3333333333333333333333333333333333333333333333333333333333333333.efivars.raw", + "url": "https://images.e2e.invalid/templates/windows-server/windows-server-2025-amd64/3333333333333333333333333333333333333333333333333333333333333333.efivars.raw", + "sha256": "3333333333333333333333333333333333333333333333333333333333333333", + "size": 540672, + "options": { + "efitype": "4m", + "pre-enrolled-keys": 1, + "ms-cert": "2023k" + } + } + ], + "hardware": { + "agent": 1, + "bios": "ovmf", + "cpu": "host", + "machine": "q35", + "net_model": "virtio", + "ostype": "win11", + "scsihw": "virtio-scsi-single", + "tpm": "v2.0" + }, + "minimum": { + "cores": 2, + "memory": 4096 + } + } + ] + } + ] +} diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 00000000000..86bcaa9bd30 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,25 @@ +import { execFileSync } from 'node:child_process' + +/** + * Resets the accounts, settings and images the specs rely on. Runs `artisan` + * directly, which is why the suite runs where the panel does (the ddev web + * container, or the CI runner). Set E2E_SKIP_SEED=1 to point the suite at a + * panel you've seeded yourself. + */ +const globalSetup = () => { + if (process.env.E2E_SKIP_SEED) return + + execFileSync( + 'php', + [ + 'artisan', + 'db:seed', + '--class=E2eSeeder', + '--force', + '--no-interaction', + ], + { stdio: 'inherit' } + ) +} + +export default globalSetup diff --git a/tests/e2e/guests.spec.ts b/tests/e2e/guests.spec.ts new file mode 100644 index 00000000000..6f55043c7a4 --- /dev/null +++ b/tests/e2e/guests.spec.ts @@ -0,0 +1,88 @@ +import { Browser, Page, expect, test } from '@playwright/test' + +import { api } from './support/api' +import { signIn } from './support/auth' +import { PASSWORD, storageState, users } from './support/users' + +test.use({ storageState: storageState('superadmin') }) + +const LOCKED_OUT = + 'This account cannot sign in. Contact the person who shared a server with you.' + +/** Flips Admin → Settings → Sharing → Guest accounts, through the form. */ +const setGuestAccounts = async (page: Page, allowed: boolean) => { + await page.goto('/admin/settings/permissions') + + const toggle = page.getByRole('switch', { + name: 'Let customers share with new people', + }) + await expect(toggle).toBeVisible() + + if ((await toggle.getAttribute('aria-checked')) !== String(allowed)) { + await toggle.click() + } + await expect(toggle).toHaveAttribute('aria-checked', String(allowed)) + + await page.getByRole('button', { name: 'Save changes' }).click() + await expect(page.getByText('Sharing settings updated')).toBeVisible() +} + +const guestSession = async (browser: Browser) => { + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }) + return context.newPage() +} + +test('a guest cannot sign in while guest accounts are off', async ({ + page, + browser, +}) => { + await setGuestAccounts(page, false) + + const guest = await guestSession(browser) + await guest.goto('/auth/login') + await guest.getByLabel('Email').fill(users.guest) + await guest.getByLabel('Password').fill(PASSWORD) + await guest.getByRole('button', { name: 'Sign in', exact: true }).click() + + await expect( + guest.getByText('Invalid email or password').first() + ).toBeVisible() + await expect(guest).toHaveURL(/\/auth\/login/) +}) + +test('turning guest accounts off signs a signed-in guest out', async ({ + page, + browser, +}) => { + await setGuestAccounts(page, true) + + const guest = await guestSession(browser) + await signIn(guest, 'guest') + expect((await api(guest).get('/api/client/user')).status()).toBe(200) + + await setGuestAccounts(page, false) + + // The session cookie is still there; every request it makes is refused. + const refused = await api(guest).get('/api/client/user') + expect(refused.status()).toBe(403) + expect((await refused.json()).message).toBe(LOCKED_OUT) + + await guest.reload() + await expect(guest).toHaveURL(/\/auth\/login/) +}) + +test('a standard account is unaffected by the switch', async ({ + page, + browser, +}) => { + await setGuestAccounts(page, false) + + const user = await browser.newContext({ + storageState: storageState('user'), + }) + const userPage = await user.newPage() + expect((await api(userPage).get('/api/client/user')).status()).toBe(200) + await user.close() +}) diff --git a/tests/e2e/live/live-node.spec.ts b/tests/e2e/live/live-node.spec.ts new file mode 100644 index 00000000000..5e4e345492f --- /dev/null +++ b/tests/e2e/live/live-node.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test' + +import { storageState } from '../support/users' + +/** + * Specs that need a real Proxmox node: seed one with + * `ddev artisan db:seed --class=DevNodeSeeder`, export the same PROXMOX_* + * variables to the runner, and run `ddev npm run test:e2e -- --grep @live`. + * The panel reads those credentials from .env, but the runner can't see them + * unless they are exported, so this skips without them rather than failing + * against a panel with no node. + */ +const hasLiveNode = Boolean( + process.env.PROXMOX_FQDN && + process.env.PROXMOX_TOKEN_ID && + process.env.PROXMOX_TOKEN_SECRET +) + +test.describe('against a live Proxmox node', { tag: '@live' }, () => { + test.skip( + !hasLiveNode, + 'PROXMOX_FQDN, PROXMOX_TOKEN_ID and PROXMOX_TOKEN_SECRET are not set' + ) + test.use({ storageState: storageState('superadmin') }) + + test('the seeded node is reachable from the panel', async ({ page }) => { + await page.goto('/admin/nodes') + await expect( + page + .getByRole('link', { + name: process.env.PROXMOX_NODE_NAME ?? /.+/, + }) + .first() + ).toBeVisible() + }) + + // TODO: Admin → Unmanaged Guests lists a VM created on the node outside Convoy; + // adopting it assigns an owner and it shows up on that user's dashboard. + test.fixme('adopts an unmanaged guest', async () => {}) + + // TODO: Admin → Servers → a server → Migration offers the other cluster members; + // migrating moves the guest, and a failed migration leaves no guest behind on the destination. + test.fixme('migrates a server to another node in its cluster', async () => {}) + + // TODO: the owner shares a server with an unknown email (creating a guest, with the + // switch on) and with the plain user as a sub-user; each sees exactly the permissions granted, + // and turning guest accounts off takes the guest's access away but keeps the share. + test.fixme('shares a server with a sub-user and a guest', async () => {}) +}) diff --git a/tests/e2e/roles.spec.ts b/tests/e2e/roles.spec.ts new file mode 100644 index 00000000000..aa9361d4572 --- /dev/null +++ b/tests/e2e/roles.spec.ts @@ -0,0 +1,150 @@ +import { Page, expect, test } from '@playwright/test' + +import { api, assignRole } from './support/api' +import { ROLE_PREFIX, storageState, users } from './support/users' + +test.describe.configure({ mode: 'serial' }) + +/** What the limited role grants, and the admin nav items that should follow from it. */ +const PERMISSIONS = [ + 'overview.read', + 'image-groups.read', + 'users.read', + 'users.manage', +] +const VISIBLE = ['Dashboard', 'Images', 'Users', 'Roles'] +const HIDDEN = [ + 'Locations', + 'Nodes', + 'Servers', + 'ISOs', + 'API Tokens', + 'Audit Log', + 'Settings', +] + +const ROLE_NAME = `${ROLE_PREFIX}Image support ${Date.now()}` + +const adminNav = (page: Page) => page.getByRole('navigation').first() + +test.describe('as the superadmin', () => { + test.use({ storageState: storageState('superadmin') }) + + test('creates a limited role and assigns it to the plain user', async ({ + page, + }) => { + await page.goto('/admin/roles') + await page.getByRole('button', { name: 'Add role' }).click() + + const dialog = page.getByRole('dialog') + await dialog.getByLabel('Name').fill(ROLE_NAME) + for (const permission of PERMISSIONS) { + await dialog + .locator(`label[for="permission-${permission}"]`) + .click() + } + + const created = page.waitForResponse( + r => + r.request().method() === 'POST' && + r.url().endsWith('/api/admin/admin-roles') + ) + await dialog.getByRole('button', { name: 'Add role' }).click() + + const response = await created + expect(response.status()).toBe(201) + const body = await response.json() + const role = body.data ?? body + await expect(page.getByText('Role created')).toBeVisible() + + const assigned = await assignRole(page, users.user, role.id) + expect(assigned.ok(), await assigned.text()).toBe(true) + }) +}) + +test.describe('as the plain user holding the limited role', () => { + test.use({ storageState: storageState('user') }) + + test('sees only the admin areas the role grants', async ({ page }) => { + await page.goto('/admin') + await expect(page).toHaveURL(/\/admin/) + + const nav = adminNav(page) + for (const item of VISIBLE) { + await expect( + nav.getByRole('link', { name: item, exact: true }) + ).toBeVisible() + } + for (const item of HIDDEN) { + await expect( + nav.getByRole('link', { name: item, exact: true }) + ).toHaveCount(0) + } + }) + + test('can read what the role grants and gets 403 everywhere else', async ({ + page, + }) => { + await page.goto('/admin') + const client = api(page) + + expect((await client.get('/api/admin/image-groups')).status()).toBe(200) + expect((await client.get('/api/admin/users')).status()).toBe(200) + + for (const url of [ + '/api/admin/nodes', + '/api/admin/locations', + '/api/admin/servers', + '/api/admin/settings/permissions', + ]) { + expect((await client.get(url)).status(), url).toBe(403) + } + + // image-groups.read, not .manage + expect( + ( + await client.post('/api/admin/image-groups', { name: 'Nope' }) + ).status() + ).toBe(403) + }) + + test('cannot author roles', async ({ page }) => { + await page.goto('/admin/roles') + await expect(page.getByText(ROLE_NAME)).toBeVisible() + await expect( + page.getByRole('button', { name: 'Add role' }) + ).toHaveCount(0) + + const response = await api(page).post('/api/admin/admin-roles', { + name: `${ROLE_PREFIX}Self-made`, + description: null, + permissions: ['settings.manage'], + }) + expect(response.status()).toBe(403) + expect((await response.json()).message).toBe( + 'Only a full administrator can author roles.' + ) + }) +}) + +test.describe('as the plain user once the role is taken away', () => { + test.use({ storageState: storageState('user') }) + + test('is kept out of the admin area', async ({ page, browser }) => { + const admin = await browser.newContext({ + storageState: storageState('superadmin'), + }) + const adminPage = await admin.newPage() + await adminPage.goto('/admin') + + const revoked = await assignRole(adminPage, users.user, null) + expect(revoked.ok(), await revoked.text()).toBe(true) + await admin.close() + + await page.goto('/admin') + await expect(page).not.toHaveURL(/\/admin/) + expect((await api(page).get('/api/admin/image-groups')).status()).toBe( + 403 + ) + }) +}) diff --git a/tests/e2e/support/api.ts b/tests/e2e/support/api.ts new file mode 100644 index 00000000000..8b3af066c9b --- /dev/null +++ b/tests/e2e/support/api.ts @@ -0,0 +1,91 @@ +import { APIResponse, Page } from '@playwright/test' + +type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + +/** + * Calls the panel's JSON API with the page's session, the way the SPA's axios + * instance does: same cookies, the XSRF token echoed back as a header. + */ +const call = async ( + page: Page, + method: Method, + url: string, + data?: unknown +): Promise => { + const cookies = await page.context().cookies() + const xsrf = cookies.find(cookie => cookie.name === 'XSRF-TOKEN') + + return page.request.fetch(url, { + method, + data, + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + ...(xsrf ? { 'X-XSRF-TOKEN': decodeURIComponent(xsrf.value) } : {}), + }, + }) +} + +export const api = (page: Page) => ({ + get: (url: string) => call(page, 'GET', url), + post: (url: string, data?: unknown) => call(page, 'POST', url, data), + put: (url: string, data?: unknown) => call(page, 'PUT', url, data), + delete: (url: string) => call(page, 'DELETE', url), +}) + +/** `GET` and unwrap `data`, failing loudly on anything but 2xx. */ +export const getData = async (page: Page, url: string): Promise => { + const response = await api(page).get(url) + + if (!response.ok()) { + throw new Error( + `GET ${url} answered ${response.status()}: ${await response.text()}` + ) + } + + return (await response.json()).data +} + +export interface AdminUser { + id: number + name: string + email: string +} + +/** Looks an account up through the admin users list (`{ items, pagination }`). */ +export const findUser = async ( + page: Page, + email: string +): Promise => { + const url = `/api/admin/users?filter[email]=${encodeURIComponent(email)}` + const response = await api(page).get(url) + + if (!response.ok()) { + throw new Error( + `GET ${url} answered ${response.status()}: ${await response.text()}` + ) + } + + const { items } = await response.json() + const [user] = Array.isArray(items) ? items : items.data + + if (!user) + throw new Error(`No account for ${email}; did the E2eSeeder run?`) + + return user +} + +/** Sets an account's admin role (`null` takes it away), the way the user form does. */ +export const assignRole = async ( + page: Page, + email: string, + roleId: number | null +) => { + const user = await findUser(page, email) + + return api(page).put(`/api/admin/users/${user.id}`, { + name: user.name, + email: user.email, + admin_role_id: roleId, + }) +} diff --git a/tests/e2e/support/auth.ts b/tests/e2e/support/auth.ts new file mode 100644 index 00000000000..6c3d09c52d7 --- /dev/null +++ b/tests/e2e/support/auth.ts @@ -0,0 +1,11 @@ +import { Page, expect } from '@playwright/test' + +import { Account, PASSWORD, users } from './users' + +export const signIn = async (page: Page, account: Account) => { + await page.goto('/auth/login') + await page.getByLabel('Email').fill(users[account]) + await page.getByLabel('Password').fill(PASSWORD) + await page.getByRole('button', { name: 'Sign in', exact: true }).click() + await expect(page).not.toHaveURL(/\/auth\//) +} diff --git a/tests/e2e/support/registry-server.mjs b/tests/e2e/support/registry-server.mjs new file mode 100644 index 00000000000..a3d00995e29 --- /dev/null +++ b/tests/e2e/support/registry-server.mjs @@ -0,0 +1,18 @@ +// Serves the image catalogue fixture to the panel, so catalogue specs never +// depend on the live CDN. Started by Playwright's `webServer`. +import { readFile } from 'node:fs/promises' +import { createServer } from 'node:http' + +const port = Number(process.env.E2E_REGISTRY_PORT ?? 4174) +const fixture = new URL('../fixtures/registry.json', import.meta.url) + +createServer(async (request, response) => { + if (request.url !== '/registry.json') { + response.writeHead(404).end() + return + } + + response + .writeHead(200, { 'Content-Type': 'application/json' }) + .end(await readFile(fixture)) +}).listen(port, '127.0.0.1') diff --git a/tests/e2e/support/registry.ts b/tests/e2e/support/registry.ts new file mode 100644 index 00000000000..ece39607c90 --- /dev/null +++ b/tests/e2e/support/registry.ts @@ -0,0 +1,4 @@ +/** Where `registry-server.mjs` serves `fixtures/registry.json`. Point IMAGE_REGISTRY_URL here. */ +export const REGISTRY_PORT = Number(process.env.E2E_REGISTRY_PORT ?? 4174) + +export const REGISTRY_URL = `http://127.0.0.1:${REGISTRY_PORT}/registry.json` diff --git a/tests/e2e/support/users.ts b/tests/e2e/support/users.ts new file mode 100644 index 00000000000..4a41f887a2c --- /dev/null +++ b/tests/e2e/support/users.ts @@ -0,0 +1,20 @@ +/** Mirrors database/seeders/E2eSeeder.php. */ +export const PASSWORD = 'password' + +export const users = { + superadmin: 'admin@e2e.test', + user: 'user@e2e.test', + guest: 'guest@e2e.test', +} as const + +export type Account = keyof typeof users + +export const storageState = (account: Account) => + `tests/e2e/.auth/${account}.json` + +/** Prefix the seeder clears, so roles a run creates don't pile up. */ +export const ROLE_PREFIX = 'E2E ' + +export const UPLOAD_GROUP = 'E2E Uploads' + +export const UPLOAD_IMAGE = 'E2E Upload Target' diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 00000000000..0a2fa0fff4c --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["node"] + }, + "include": ["./**/*.ts", "../../playwright.config.ts"] +} diff --git a/tests/e2e/upload.spec.ts b/tests/e2e/upload.spec.ts new file mode 100644 index 00000000000..12e7a1dc11b --- /dev/null +++ b/tests/e2e/upload.spec.ts @@ -0,0 +1,119 @@ +import { Page, Request, expect, test } from '@playwright/test' +import { writeFile } from 'node:fs/promises' + +import { UPLOAD_GROUP, UPLOAD_IMAGE, storageState } from './support/users' + +test.use({ storageState: storageState('superadmin') }) + +const MiB = 1024 * 1024 +const SIZE = 48 * MiB +/** The panel's default `ARTIFACTS_UPLOAD_CHUNK_BYTES`, so 48 MiB is three chunks. */ +const CHUNK = 16 * MiB + +const isChunk = (request: Request) => + request.method() === 'PUT' && + /\/api\/admin\/images\/uploads\/[^/]+$/.test(request.url()) + +const isFinalize = (request: Request) => + request.method() === 'POST' && + /\/api\/admin\/images\/uploads\/[^/]+\/finalize$/.test(request.url()) + +/** A disk image of `SIZE` bytes. `fill` keeps each test's file (and its sha256) distinct. */ +const diskImage = async (path: string, fill: number) => { + await writeFile(path, Buffer.alloc(SIZE, fill)) + return path +} + +/** Records the `Upload-Offset` of every chunk the page sends, whatever happens to it. */ +const recordOffsets = (page: Page) => { + const offsets: number[] = [] + page.on('request', request => { + if (isChunk(request)) + offsets.push(Number(request.headers()['upload-offset'])) + }) + return offsets +} + +const openVersions = async (page: Page) => { + await page.goto('/admin/images') + await page.getByText(UPLOAD_GROUP, { exact: true }).click() + + await page + .getByRole('dialog') + .locator('div') + .filter({ has: page.getByRole('heading', { name: UPLOAD_IMAGE }) }) + .getByRole('button', { name: 'Open image actions' }) + .last() + .click() + await page.getByRole('menuitem', { name: 'Versions' }).click() + + return page.getByRole('dialog') +} + +test('a 48 MiB image goes up in three chunks, then is finalized', async ({ + page, +}, testInfo) => { + const offsets = recordOffsets(page) + const dialog = await openVersions(page) + + const opened = page.waitForResponse( + r => + r.request().method() === 'POST' && + r.url().endsWith('/api/admin/images/uploads') + ) + const finalized = page.waitForResponse(r => isFinalize(r.request())) + + await dialog + .locator('input[type="file"]') + .setInputFiles(await diskImage(testInfo.outputPath('disk.raw'), 1)) + + expect((await (await opened).json()).chunk_size).toBe(CHUNK) + + const result = await finalized + expect(result.ok()).toBe(true) + expect(await result.json()).toMatchObject({ size: SIZE, format: 'raw' }) + + expect(offsets).toEqual([0, CHUNK, 2 * CHUNK]) + await expect(dialog.getByText(/Hosted by Convoy/)).toBeVisible() +}) + +test('an interrupted upload resumes from the offset the server holds', async ({ + page, +}, testInfo) => { + const offsets = recordOffsets(page) + + // Drop the second chunk on the floor: the server keeps the first and never sees the second. + let dropped = false + await page.route(/\/api\/admin\/images\/uploads\/[^/]+$/, async route => { + if (isChunk(route.request()) && !dropped && offsets.length === 2) { + dropped = true + return route.abort('connectionreset') + } + return route.fallback() + }) + + const dialog = await openVersions(page) + await dialog + .locator('input[type="file"]') + .setInputFiles(await diskImage(testInfo.outputPath('disk.raw'), 2)) + + const resume = dialog.getByRole('button', { name: 'Resume' }) + await expect(resume).toBeVisible() + expect(offsets).toEqual([0, CHUNK]) + + const probed = page.waitForResponse( + r => + r.request().method() === 'GET' && + /\/api\/admin\/images\/uploads\/[^/]+$/.test(r.url()) + ) + const finalized = page.waitForResponse(r => isFinalize(r.request())) + + await resume.click() + + expect((await (await probed).json()).offset).toBe(CHUNK) + expect((await finalized).ok()).toBe(true) + + // The dropped chunk is sent again from the server's offset, not restarted from zero. + expect(offsets).toEqual([0, CHUNK, CHUNK, 2 * CHUNK]) + await expect(dialog.getByText(/Hosted by Convoy/)).toBeVisible() +}) From 1b9e46008cb2c7cff005789255c9ff9e3947eb5f Mon Sep 17 00:00:00 2001 From: Eric Wang <37554696+ericwang401@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:33:43 +0000 Subject: [PATCH 3/3] fix(ci): append the e2e registry URL on its own line .env.ci ends without a newline, so echoing IMAGE_REGISTRY_URL onto the copied file joined it to PHP_XDEBUG_MODE and Dotenv refused to parse it. --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 238b6c2b889..bfdd8252ae1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -148,7 +148,8 @@ jobs: 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 - echo 'IMAGE_REGISTRY_URL=http://127.0.0.1:4174/registry.json' >> .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