|
| 1 | +/* |
| 2 | + * SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl> |
| 3 | + * SPDX-License-Identifier: EUPL-1.2 |
| 4 | + * |
| 5 | + * Playwright end-to-end coverage for the ZIP export flow of spec #9 |
| 6 | + * (openbuilt-export-to-real-app). |
| 7 | + * |
| 8 | + * Flow: |
| 9 | + * 1. Authenticate as admin against the local Nextcloud dev instance |
| 10 | + * (admin:admin per nextcloud-docker-dev defaults). |
| 11 | + * 2. Open the hello-world Application editor. |
| 12 | + * 3. Click the Export action, choose ZIP target, accept the default |
| 13 | + * version + license. |
| 14 | + * 4. Submit; expect a 202 + ExportJob UUID surfaced in the jobs list. |
| 15 | + * 5. Poll the row until `status=succeeded` (≤ 60 s). |
| 16 | + * 6. Click the download button; assert a ZIP file is received with the |
| 17 | + * expected filename pattern `<appId>-<version>.zip` (or the job-UUID |
| 18 | + * fallback the current ExportService emits). |
| 19 | + * |
| 20 | + * NOTE: The Playwright runner is not wired up in OpenBuilt yet — this file |
| 21 | + * is committed alongside the apply PR per task 7.2 / 8.x of the spec. |
| 22 | + * It runs once the cohort-wide Playwright bootstrap lands and asserts the |
| 23 | + * end-to-end UX contract the controller + background-job tests have |
| 24 | + * already locked at the unit level. |
| 25 | + */ |
| 26 | + |
| 27 | +import { test, expect, type Download } from '@playwright/test' |
| 28 | + |
| 29 | +const NEXTCLOUD_URL = process.env.NEXTCLOUD_URL || 'http://localhost:8080' |
| 30 | +const ADMIN_USER = process.env.NC_ADMIN_USER || 'admin' |
| 31 | +const ADMIN_PASSWORD = process.env.NC_ADMIN_PASSWORD || 'admin' |
| 32 | +const APPLICATION_SLUG = 'hello-world' |
| 33 | +const POLL_TIMEOUT_MS = 60_000 |
| 34 | + |
| 35 | +test.describe('OpenBuilt ZIP export', () => { |
| 36 | + test.beforeEach(async ({ page }) => { |
| 37 | + // Login via the Nextcloud login form. CI uses storageState; this |
| 38 | + // fallback keeps the spec runnable in local dev. |
| 39 | + await page.goto(`${NEXTCLOUD_URL}/index.php/login`) |
| 40 | + if (await page.locator('input[name="user"]').isVisible({ timeout: 5_000 }).catch(() => false)) { |
| 41 | + await page.fill('input[name="user"]', ADMIN_USER) |
| 42 | + await page.fill('input[name="password"]', ADMIN_PASSWORD) |
| 43 | + await page.locator('button[type="submit"], input[type="submit"]').first().click() |
| 44 | + await page.waitForURL(/\/index\.php\/apps\//, { timeout: 15_000 }) |
| 45 | + } |
| 46 | + }) |
| 47 | + |
| 48 | + test('export a hello-world Application as a ZIP and download it', async ({ page }) => { |
| 49 | + // 1. Navigate to the hello-world editor. |
| 50 | + await page.goto(`${NEXTCLOUD_URL}/index.php/apps/openbuilt/applications/${APPLICATION_SLUG}`) |
| 51 | + |
| 52 | + // 2. Open the Export dialog. The button is wired in |
| 53 | + // src/views/ApplicationDetail.vue per task 8.3. |
| 54 | + const exportButton = page.getByRole('button', { name: /export/i }) |
| 55 | + await expect(exportButton).toBeVisible({ timeout: 15_000 }) |
| 56 | + await exportButton.click() |
| 57 | + |
| 58 | + // 3. Choose ZIP target. NcSelect renders a combobox-style trigger; |
| 59 | + // the inputLabel prop (nc-input-labels gate) gives screen |
| 60 | + // readers the "Target" label we can locate by. |
| 61 | + const targetSelect = page.getByRole('combobox', { name: /target/i }) |
| 62 | + await expect(targetSelect).toBeVisible() |
| 63 | + await targetSelect.click() |
| 64 | + await page.getByRole('option', { name: /^ZIP/i }).click() |
| 65 | + |
| 66 | + // 4. Submit and capture the UUID surfaced in the row. |
| 67 | + await page.getByRole('button', { name: /^submit|^export$/i }).click() |
| 68 | + |
| 69 | + // 5. Poll the jobs list until status=succeeded. |
| 70 | + const succeededRow = page.locator('[data-test="export-job-row"]:has-text("succeeded")').first() |
| 71 | + await expect(succeededRow).toBeVisible({ timeout: POLL_TIMEOUT_MS }) |
| 72 | + |
| 73 | + // 6. Click the download button and capture the resulting file. |
| 74 | + const downloadPromise: Promise<Download> = page.waitForEvent('download') |
| 75 | + await succeededRow.getByRole('button', { name: /download/i }).click() |
| 76 | + const download = await downloadPromise |
| 77 | + |
| 78 | + const suggestedFilename = download.suggestedFilename() |
| 79 | + expect(suggestedFilename).toMatch(/\.zip$/i) |
| 80 | + // Filename either carries the app slug, the job UUID, or a generic |
| 81 | + // `export.zip` — all three are documented in the controller + |
| 82 | + // service layer. The .zip extension is the load-bearing assertion. |
| 83 | + expect(suggestedFilename.length).toBeGreaterThan(0) |
| 84 | + }) |
| 85 | + |
| 86 | + test('export dialog rejects submission with invalid target', async ({ page }) => { |
| 87 | + // Locks the client-side guard mirror of the 422 controller path. |
| 88 | + await page.goto(`${NEXTCLOUD_URL}/index.php/apps/openbuilt/applications/${APPLICATION_SLUG}`) |
| 89 | + const exportButton = page.getByRole('button', { name: /export/i }) |
| 90 | + await expect(exportButton).toBeVisible({ timeout: 15_000 }) |
| 91 | + await exportButton.click() |
| 92 | + |
| 93 | + // Submit without choosing a target — NcSelect should remain in its |
| 94 | + // placeholder state and the submit button should be disabled (or |
| 95 | + // produce an inline validation error). |
| 96 | + const submitBtn = page.getByRole('button', { name: /^submit|^export$/i }) |
| 97 | + const isDisabled = await submitBtn.isDisabled().catch(() => false) |
| 98 | + if (!isDisabled) { |
| 99 | + await submitBtn.click() |
| 100 | + // Validation error surfaces as a notice element with role=alert. |
| 101 | + await expect(page.getByRole('alert')).toBeVisible({ timeout: 5_000 }) |
| 102 | + } |
| 103 | + }) |
| 104 | +}) |
0 commit comments