From 8bff560891a0bd260248518da6ac57295b9ad751 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 17 May 2026 08:50:45 +0200 Subject: [PATCH 1/3] fix(e2e+bootstrap): clean URLs, shared session, manifest validator, lifecycle schema test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives openbuilt#79 (Playwright debt) and openbuilt#10 tasks 4.3 / 5.2 (bootstrap verification) in one branch. ## #79 — Playwright spec fixes - `/index.php/apps/openbuilt/{path}` was being rewritten by NC32 to `/apps/openbuilt/` (dropping the trailing path). Vue Router never saw `/applications` etc., so every page-nav locator timed out. Use clean URLs throughout. API calls (`/index.php/apps/openbuilt/api/...`) are unaffected and stay as-is. - Remove the per-spec `loginAsAdmin` / `beforeEach` form login from schema-designer, template-gallery, and page-designer. globalSetup (added in PR #78) already writes a shared storageState for every spec — the duplicate per-spec login was racing the brute-force throttle and timing out when the page wasn't on /login. - version-rollback navigates to `/apps/openbuilt/applications` (not the dashboard) so the ApplicationCards are present. - rbac-403 navigates to `/applications` to find the empty-state copy + an outsider user is auto-provisioned in dev (occ user:add). Net Playwright on dev container: 24 failed → fewer (full re-run in flight; spec listing surface dropped from 0% to ~30%+ passing). ## #10 — Bootstrap verification (4.3 + 5.2) - `scripts/check-manifest.js` + `npm run check:manifest` validate the openbuilt shell manifest and the wizard seed against the canonical `@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` schema. The wizard seed's `{registerSlug}` placeholder is substituted with a syntactically-valid slug before validation so the structural shape is what's being asserted. - `tests/scripts/check-manifest.spec.js` (4 Vitest cases) cover the happy path, hand-rolled validity, missing-required-property failure path, and the `{registerSlug}` placeholder substitution. - `tests/Unit/ApplicationVersionLifecycleSchemaTest.php` (5 PHPUnit cases) asserts the declarative state machine in `lib/Settings/openbuilt_register.json` is well-formed: initial state is `draft`, the three states are draft/published/archived, the three transitions are publish/archive/reopen with the right from→to pairs, the publish transition declares the `upsert_relation` that maintains BuiltAppRoute, and disallowed transitions (draft→archived etc.) are absent. Task 4.4 (visual smoke on fresh docker compose up) and the container-bound end-to-end transition test for 5.2 remain — they need a real fresh container which is out of scope for this PR. --- package.json | 1 + scripts/check-manifest.js | 110 ++++++++ .../ApplicationVersionLifecycleSchemaTest.php | 236 ++++++++++++++++++ tests/e2e/application-editor.spec.ts | 2 +- tests/e2e/applicationCard.spec.ts | 8 +- tests/e2e/applicationDetailOverview.spec.ts | 8 +- tests/e2e/bootstrap-openbuilt.e2e.spec.ts | 2 +- tests/e2e/builder-host.spec.ts | 8 +- tests/e2e/export-zip.spec.ts | 4 +- tests/e2e/iconUpload.spec.ts | 8 +- tests/e2e/page-designer.spec.ts | 27 +- tests/e2e/rbac-403.spec.ts | 4 +- tests/e2e/schema-designer.spec.ts | 22 +- tests/e2e/template-gallery.spec.ts | 31 +-- tests/e2e/version-rollback.spec.ts | 2 +- tests/e2e/versionRouting.spec.ts | 4 +- tests/scripts/check-manifest.spec.js | 114 +++++++++ 17 files changed, 514 insertions(+), 77 deletions(-) create mode 100644 scripts/check-manifest.js create mode 100644 tests/Unit/ApplicationVersionLifecycleSchemaTest.php create mode 100644 tests/scripts/check-manifest.spec.js diff --git a/package.json b/package.json index 0098d481..1e70d5cf 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint-fix": "npm run lint -- --fix", "stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css", "stylelint-fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css --fix", + "check:manifest": "node scripts/check-manifest.js", "test": "vitest run", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/scripts/check-manifest.js b/scripts/check-manifest.js new file mode 100644 index 00000000..7c47272b --- /dev/null +++ b/scripts/check-manifest.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. + +/** + * check-manifest — validate openbuilt manifests against the canonical + * @conduction/nextcloud-vue ADR-024 schema. + * + * Implements openbuilt#10 task 4.3 — "Run npm run check:manifest on the + * seeded hello-world manifest blob in tests; passes against the canonical + * schema pinned in package.json." + * + * Defaults to validating: + * - src/manifest.json (the OpenBuilt shell manifest) + * - lib/Resources/wizard/default-manifest.json (the wizard seed) + * + * Pass alternate paths as CLI args. The wizard seed carries the literal + * `{registerSlug}` placeholder string in `pages[].config.register`, so + * it's validated through a wrapper that swaps the token to a syntactically + * valid slug before validation runs. We're checking the schema-shape, not + * the placeholder substitution itself. + * + * Exits 0 when every input passes; 1 otherwise. + */ + +const fs = require('node:fs') +const path = require('node:path') +const Ajv = require('ajv/dist/2020').default + +const SCHEMA_PATH = path.resolve( + __dirname, + '../node_modules/@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json', +) + +const DEFAULT_TARGETS = [ + 'src/manifest.json', + 'lib/Resources/wizard/default-manifest.json', +] + +function loadJson(filePath) { + const raw = fs.readFileSync(filePath, 'utf-8') + return JSON.parse(raw) +} + +/** + * Replace token placeholders in a manifest with syntactically valid values + * so the schema validator can run against the structural shape. + * + * @param {object} manifest The manifest payload. + * @returns {object} The manifest with tokens substituted. + */ +function substituteTokens(manifest) { + if (!manifest || !Array.isArray(manifest.pages)) return manifest + return { + ...manifest, + pages: manifest.pages.map((page) => { + if (!page || typeof page !== 'object' || !page.config) return page + const config = { ...page.config } + if (config.register === '{registerSlug}') { + config.register = 'openbuilt-validator-placeholder' + } + return { ...page, config } + }), + } +} + +function main() { + const args = process.argv.slice(2) + const targets = args.length > 0 ? args : DEFAULT_TARGETS + const repoRoot = path.resolve(__dirname, '..') + + const schema = loadJson(SCHEMA_PATH) + const ajv = new Ajv({ allErrors: true, strict: false }) + const validate = ajv.compile(schema) + + let allPassed = true + for (const target of targets) { + const abs = path.isAbsolute(target) ? target : path.join(repoRoot, target) + if (!fs.existsSync(abs)) { + console.error(`SKIP ${target} (not found)`) + continue + } + + let manifest + try { + manifest = loadJson(abs) + } catch (err) { + console.error(`FAIL ${target} — JSON parse error: ${err.message}`) + allPassed = false + continue + } + + const candidate = substituteTokens(manifest) + const valid = validate(candidate) + if (valid) { + console.log(`PASS ${target}`) + continue + } + + allPassed = false + console.error(`FAIL ${target}`) + for (const err of validate.errors || []) { + console.error(` ${err.instancePath || '(root)'} ${err.message}`) + } + } + + process.exit(allPassed ? 0 : 1) +} + +main() diff --git a/tests/Unit/ApplicationVersionLifecycleSchemaTest.php b/tests/Unit/ApplicationVersionLifecycleSchemaTest.php new file mode 100644 index 00000000..9240b432 --- /dev/null +++ b/tests/Unit/ApplicationVersionLifecycleSchemaTest.php @@ -0,0 +1,236 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Tests\Unit; + +use PHPUnit\Framework\TestCase; + +/** + * Tests the declarative lifecycle of ApplicationVersion (openbuilt#10). + */ +class ApplicationVersionLifecycleSchemaTest extends TestCase +{ + /** + * Decoded register-seed payload, lazily loaded. + * + * @var array|null + */ + private static ?array $registerSeed = null; + + /** + * Load + cache the canonical register seed. + * + * @return array + */ + private function registerSeed(): array + { + if (self::$registerSeed === null) { + $path = __DIR__.'/../../lib/Settings/openbuilt_register.json'; + self::assertFileExists($path, 'register seed file must be present'); + $raw = file_get_contents($path); + $decoded = json_decode($raw, true); + self::assertIsArray($decoded, 'register seed must be a JSON object'); + self::$registerSeed = $decoded; + } + + return self::$registerSeed; + }//end registerSeed() + + /** + * Pull the ApplicationVersion schema block out of the register seed. + * + * @return array + */ + private function applicationVersionSchema(): array + { + $seed = $this->registerSeed(); + // The seed is OpenAPI-shaped; schemas live under components.schemas + // and the version schema is keyed `ApplicationVersion` (PascalCase). + self::assertArrayHasKey('components', $seed); + self::assertArrayHasKey('schemas', $seed['components']); + $schemas = $seed['components']['schemas']; + self::assertIsArray($schemas); + self::assertArrayHasKey( + 'ApplicationVersion', + $schemas, + 'register seed must define an ApplicationVersion schema' + ); + $schema = $schemas['ApplicationVersion']; + self::assertIsArray($schema); + return $schema; + }//end applicationVersionSchema() + + /** + * Pull out the lifecycle declaration. + * + * @return array + */ + private function lifecycle(): array + { + $schema = $this->applicationVersionSchema(); + // `x-openregister-lifecycle` sits at the schema level (sibling + // of `properties`), not inside the properties map. + self::assertArrayHasKey( + 'x-openregister-lifecycle', + $schema, + 'ApplicationVersion must declare x-openregister-lifecycle' + ); + $lifecycle = $schema['x-openregister-lifecycle']; + self::assertIsArray($lifecycle, 'lifecycle block must be an object'); + return $lifecycle; + }//end lifecycle() + + /** + * REQ-OBV-LC-1 — initial state is draft. + * + * @return void + */ + public function testInitialStateIsDraft(): void + { + $lifecycle = $this->lifecycle(); + self::assertSame('status', $lifecycle['field'] ?? null); + self::assertSame('draft', $lifecycle['initial'] ?? null); + }//end testInitialStateIsDraft() + + /** + * REQ-OBV-LC-2 — three named states exist: draft, published, archived. + * + * @return void + */ + public function testStateSetIsDraftPublishedArchived(): void + { + $lifecycle = $this->lifecycle(); + self::assertArrayHasKey('states', $lifecycle); + self::assertIsArray($lifecycle['states']); + + $expected = ['draft', 'published', 'archived']; + $actual = array_keys($lifecycle['states']); + sort($expected); + sort($actual); + self::assertSame($expected, $actual); + }//end testStateSetIsDraftPublishedArchived() + + /** + * REQ-OBV-LC-3 — three transitions: publish, archive, reopen. + * + * @return void + */ + public function testThreeTransitionsAreDeclared(): void + { + $lifecycle = $this->lifecycle(); + $transitions = ($lifecycle['transitions'] ?? []); + self::assertIsArray($transitions); + self::assertCount(3, $transitions, 'expected exactly 3 transitions (publish/archive/reopen)'); + + $byName = []; + foreach ($transitions as $t) { + self::assertIsArray($t); + self::assertArrayHasKey('name', $t); + $byName[$t['name']] = $t; + } + + self::assertSame(['publish', 'archive', 'reopen'], array_keys($byName)); + + self::assertSame('draft', $byName['publish']['from']); + self::assertSame('published', $byName['publish']['to']); + + self::assertSame('published', $byName['archive']['from']); + self::assertSame('archived', $byName['archive']['to']); + + self::assertSame('archived', $byName['reopen']['from']); + self::assertSame('draft', $byName['reopen']['to']); + }//end testThreeTransitionsAreDeclared() + + /** + * REQ-OBV-LC-4 — publish fires the upsert_relation that keeps + * BuiltAppRoute in sync. This is the declarative replacement for + * the old ApplicationVersionSnapshotListener (per ADR-031). + * + * @return void + */ + public function testPublishUpsertsBuiltAppRoute(): void + { + $lifecycle = $this->lifecycle(); + $transition = null; + foreach ($lifecycle['transitions'] as $t) { + if (($t['name'] ?? null) === 'publish') { + $transition = $t; + break; + } + } + + self::assertIsArray($transition, 'publish transition must exist'); + self::assertArrayHasKey('on_transition', $transition); + self::assertArrayHasKey('upsert_relation', $transition['on_transition']); + + $upsert = $transition['on_transition']['upsert_relation']; + self::assertSame('openbuilt/built-app-route', $upsert['schema'] ?? null); + // The slug-keyed match ensures the route survives republishes + // (one row per Application slug). + self::assertArrayHasKey('match', $upsert); + self::assertArrayHasKey('slug', $upsert['match']); + self::assertArrayHasKey('payload', $upsert); + self::assertArrayHasKey('slug', $upsert['payload']); + self::assertArrayHasKey('applicationUuid', $upsert['payload']); + }//end testPublishUpsertsBuiltAppRoute() + + /** + * Sanity guard — a disallowed transition (e.g. draft → archived + * directly) is NOT declared. OR's TransitionEngine rejects undefined + * transitions; the test catches accidental schema drift that would + * widen the state machine. + * + * @return void + */ + public function testDisallowedTransitionIsAbsent(): void + { + $lifecycle = $this->lifecycle(); + $pairs = array_map( + static fn (array $t): string => sprintf('%s->%s', ($t['from'] ?? '?'), ($t['to'] ?? '?')), + $lifecycle['transitions'] + ); + + self::assertNotContains('draft->archived', $pairs); + self::assertNotContains('published->draft', $pairs); + self::assertNotContains('archived->published', $pairs); + }//end testDisallowedTransitionIsAbsent() +}//end class diff --git a/tests/e2e/application-editor.spec.ts b/tests/e2e/application-editor.spec.ts index df8db427..47c924d8 100644 --- a/tests/e2e/application-editor.spec.ts +++ b/tests/e2e/application-editor.spec.ts @@ -18,7 +18,7 @@ import { test, expect } from '@playwright/test' */ test.describe('ApplicationEditor — textarea round-trip', () => { test('loads, edits hello-world manifest, saves successfully', async ({ page, request }) => { - await page.goto('/index.php/apps/openbuilt/applications') + await page.goto('/apps/openbuilt/applications') // The editor lists Applications down the left rail and selects the // first one (hello-world) on mount. The textarea binds to the diff --git a/tests/e2e/applicationCard.spec.ts b/tests/e2e/applicationCard.spec.ts index bedd0979..b54c17bd 100644 --- a/tests/e2e/applicationCard.spec.ts +++ b/tests/e2e/applicationCard.spec.ts @@ -26,7 +26,7 @@ import { test, expect } from '@playwright/test' test.describe('ApplicationCard — icon + productionVersion fields (spec A / spec C)', () => { test('index page renders ApplicationCards with icon elements', async ({ page }) => { - await page.goto('/index.php/apps/openbuilt/applications') + await page.goto('/apps/openbuilt/applications') // Wait for the SPA to hydrate and the Applications list to appear. // The list renders one card per Application. The seeded hello-world @@ -46,7 +46,7 @@ test.describe('ApplicationCard — icon + productionVersion fields (spec A / spe }) test('hello-world ApplicationCard shows a status badge (not raw "Live" chip)', async ({ page }) => { - await page.goto('/index.php/apps/openbuilt/applications') + await page.goto('/apps/openbuilt/applications') // Wait for at least the seeded hello-world card. await expect( @@ -66,7 +66,7 @@ test.describe('ApplicationCard — icon + productionVersion fields (spec A / spe }) test('hello-world ApplicationCard status badge is one of the known values', async ({ page }) => { - await page.goto('/index.php/apps/openbuilt/applications') + await page.goto('/apps/openbuilt/applications') // Find the card for hello-world specifically. const helloCard = page.locator('[data-slug="hello-world"], .ob-app-card').first() @@ -85,7 +85,7 @@ test.describe('ApplicationCard — icon + productionVersion fields (spec A / spe }) test('hello-world ApplicationCard version chip shows semver or — placeholder', async ({ page }) => { - await page.goto('/index.php/apps/openbuilt/applications') + await page.goto('/apps/openbuilt/applications') const helloCard = page.locator('[data-slug="hello-world"], .ob-app-card').first() await expect(helloCard).toBeVisible({ timeout: 15_000 }) diff --git a/tests/e2e/applicationDetailOverview.spec.ts b/tests/e2e/applicationDetailOverview.spec.ts index 6393b0e8..3c13b297 100644 --- a/tests/e2e/applicationDetailOverview.spec.ts +++ b/tests/e2e/applicationDetailOverview.spec.ts @@ -57,7 +57,7 @@ test.describe('Application detail — maintainer dashboard (REQ-OBADO-001..012)' test.skip(apps.length === 0, 'hello-world Application not seeded') const objectId = apps[0].uuid || apps[0].id - await page.goto(`${BASE}/index.php/apps/openbuilt/applications/${objectId}`) + await page.goto(`${BASE}/apps/openbuilt/applications/${objectId}`) await page.waitForSelector('.ob-detail-header', { timeout: 15_000 }) // REQ-OBADO-001 — hero, controls, KPIs, activity, widgets all present. @@ -77,7 +77,7 @@ test.describe('Application detail — maintainer dashboard (REQ-OBADO-001..012)' test.skip(apps.length === 0, 'app not seeded') const objectId = apps[0].uuid || apps[0].id - await page.goto(`${BASE}/index.php/apps/openbuilt/applications/${objectId}`) + await page.goto(`${BASE}/apps/openbuilt/applications/${objectId}`) await page.waitForSelector('.ob-detail-header__pill', { timeout: 15_000 }) const pills = page.locator('.ob-detail-header__pill') @@ -97,7 +97,7 @@ test.describe('Application detail — maintainer dashboard (REQ-OBADO-001..012)' test.skip(apps.length === 0, 'app not seeded') const objectId = apps[0].uuid || apps[0].id - await page.goto(`${BASE}/index.php/apps/openbuilt/applications/${objectId}`) + await page.goto(`${BASE}/apps/openbuilt/applications/${objectId}`) await page.waitForSelector('.ob-detail-header__window-btn', { timeout: 15_000 }) // Click 30d and assert the URL fragment / network call reflects the change. @@ -120,7 +120,7 @@ test.describe('Application detail — maintainer dashboard (REQ-OBADO-001..012)' test.skip(apps.length === 0, 'app not seeded') const objectId = apps[0].uuid || apps[0].id - await page.goto(`${BASE}/index.php/apps/openbuilt/applications/${objectId}`) + await page.goto(`${BASE}/apps/openbuilt/applications/${objectId}`) await page.waitForSelector('.ob-detail-header__pill', { timeout: 15_000 }) const pills = page.locator('.ob-detail-header__pill-group') diff --git a/tests/e2e/bootstrap-openbuilt.e2e.spec.ts b/tests/e2e/bootstrap-openbuilt.e2e.spec.ts index 3cbe7472..5872a2d5 100644 --- a/tests/e2e/bootstrap-openbuilt.e2e.spec.ts +++ b/tests/e2e/bootstrap-openbuilt.e2e.spec.ts @@ -18,7 +18,7 @@ import { test, expect } from '@playwright/test' */ test.describe('bootstrap-openbuilt hello-world', () => { test('renders the three seeded hello-message objects on the index page', async ({ page }) => { - await page.goto('/index.php/apps/openbuilt/builder/hello-world') + await page.goto('/apps/openbuilt/builder/hello-world') // The SPA needs a moment to fetch the manifest and resolve the index page. // The hello-world manifest's index page lists `hello-message` objects with diff --git a/tests/e2e/builder-host.spec.ts b/tests/e2e/builder-host.spec.ts index cec7e1c2..fd35c143 100644 --- a/tests/e2e/builder-host.spec.ts +++ b/tests/e2e/builder-host.spec.ts @@ -15,9 +15,9 @@ import { test, expect } from '@playwright/test' */ test.describe('BuilderHost — hello-world journey', () => { test('loads /builder/hello-world and renders the seeded index page', async ({ page }) => { - await page.goto('/index.php/apps/openbuilt/builder/hello-world') + await page.goto('/apps/openbuilt/builder/hello-world') - await expect(page).toHaveURL(/\/index\.php\/apps\/openbuilt\/builder\/hello-world/) + await expect(page).toHaveURL(/\/apps\/openbuilt\/builder\/hello-world/) // The hello-world manifest's index page lists hello-message objects. // The three seeded titles must all be visible before this passes. @@ -35,7 +35,7 @@ test.describe('BuilderHost — hello-world journey', () => { }) test('navigates to a hello-message detail page', async ({ page }) => { - await page.goto('/index.php/apps/openbuilt/builder/hello-world') + await page.goto('/apps/openbuilt/builder/hello-world') // Click the first seeded message — the manifest defines the detail // page at /messages/:id so the inner router forwards us there. @@ -58,7 +58,7 @@ test.describe('BuilderHost — hello-world journey', () => { // The manifest declares a form page at /messages/new. Hit it directly // to skip menu/CTA discovery (DOM may be in flux until the page-editor // spec lands). - await page.goto('/index.php/apps/openbuilt/builder/hello-world/messages/new') + await page.goto('/apps/openbuilt/builder/hello-world/messages/new') await expect(page).toHaveURL(/\/builder\/hello-world\/messages\/new/) diff --git a/tests/e2e/export-zip.spec.ts b/tests/e2e/export-zip.spec.ts index d7efb0f0..b2d10c5e 100644 --- a/tests/e2e/export-zip.spec.ts +++ b/tests/e2e/export-zip.spec.ts @@ -48,7 +48,7 @@ test.describe('OpenBuilt ZIP export', () => { test('export a hello-world Application as a ZIP and download it', async ({ page }) => { // 1. Navigate to the hello-world editor. - await page.goto(`${NEXTCLOUD_URL}/index.php/apps/openbuilt/applications/${APPLICATION_SLUG}`) + await page.goto(`${NEXTCLOUD_URL}/apps/openbuilt/applications/${APPLICATION_SLUG}`) // 2. Open the Export dialog. The button is wired in // src/views/ApplicationDetail.vue per task 8.3. @@ -86,7 +86,7 @@ test.describe('OpenBuilt ZIP export', () => { test('export dialog rejects submission with invalid target', async ({ page }) => { // Locks the client-side guard mirror of the 422 controller path. - await page.goto(`${NEXTCLOUD_URL}/index.php/apps/openbuilt/applications/${APPLICATION_SLUG}`) + await page.goto(`${NEXTCLOUD_URL}/apps/openbuilt/applications/${APPLICATION_SLUG}`) const exportButton = page.getByRole('button', { name: /export/i }) await expect(exportButton).toBeVisible({ timeout: 15_000 }) await exportButton.click() diff --git a/tests/e2e/iconUpload.spec.ts b/tests/e2e/iconUpload.spec.ts index 038cfede..88a6d243 100644 --- a/tests/e2e/iconUpload.spec.ts +++ b/tests/e2e/iconUpload.spec.ts @@ -46,11 +46,11 @@ test.describe('Icon upload on Application detail page (spec A task 7.5)', () => // Land on the Virtual apps index — the ApplicationCards live at // `/applications`, not at the app root (which redirects to the // Dashboard widget page). - await page.goto('/index.php/apps/openbuilt/applications') + await page.goto('/apps/openbuilt/applications') await expect(page.locator('.ob-app-card, [data-testid*="app-card"]').first()).toBeVisible({ timeout: 15_000 }) // Navigate to the hello-world detail page. The card links to - // /builder/hello-world or /index.php/apps/openbuilt#/applications/{uuid}. + // /builder/hello-world or /apps/openbuilt#/applications/{uuid}. // We use the ApplicationCard click as the nav trigger. const helloCard = page.locator(`[data-slug="${HELLO_WORLD_SLUG}"]`).first() .or(page.locator('.ob-app-card').first()) @@ -87,7 +87,7 @@ test.describe('Icon upload on Application detail page (spec A task 7.5)', () => // Land on the Virtual apps index — the ApplicationCards live at // `/applications`, not at the app root (which redirects to the // Dashboard widget page). - await page.goto('/index.php/apps/openbuilt/applications') + await page.goto('/apps/openbuilt/applications') await expect(page.locator('.ob-app-card, [data-testid*="app-card"]').first()).toBeVisible({ timeout: 15_000 }) // Open the hello-world detail. @@ -135,7 +135,7 @@ test.describe('Icon upload on Application detail page (spec A task 7.5)', () => // IconUploadSection.vue (spec A task 5.1 sub-task). The button // must clear the Application's icon.ref and reset the preview src. // Enable once the remove-button affordance is deployed. - await page.goto('/index.php/apps/openbuilt') + await page.goto('/apps/openbuilt') const helloCard = page.locator('.ob-app-card').first() await helloCard.click() const removeBtn = page.getByRole('button', { name: /remove.*icon|clear.*icon/i }).first() diff --git a/tests/e2e/page-designer.spec.ts b/tests/e2e/page-designer.spec.ts index 2b30c04a..a6912b74 100644 --- a/tests/e2e/page-designer.spec.ts +++ b/tests/e2e/page-designer.spec.ts @@ -17,34 +17,21 @@ * `npm run test:e2e:install` once before invoking `npm run test:e2e`. */ -import { test, expect, type Page } from '@playwright/test' +import { test, expect } from '@playwright/test' const ADMIN_USER = process.env.NC_ADMIN_USER ?? 'admin' const ADMIN_PASS = process.env.NC_ADMIN_PASS ?? 'admin' -/** - * Drive the Nextcloud login form. `httpCredentials` only covers HTTP basic - * auth on direct fetches — the UI itself requires a session cookie that we - * can only obtain via the login form. - */ -async function loginAsAdmin(page: Page): Promise { - await page.goto('/index.php/login') - await page.locator('input[name="user"]').fill(ADMIN_USER) - await page.locator('input[name="password"]').fill(ADMIN_PASS) - await page.locator('button[type="submit"]').first().click() - // Wait for the global header that only renders on authenticated pages. - await page.waitForSelector('#header, header.header', { timeout: 20_000 }) -} +// Auth: globalSetup populates storageState; per-spec form login is gone. +void ADMIN_USER +void ADMIN_PASS test.describe('openbuilt page designer', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page) - }) test('REQ-OBPD-002 + REQ-OBPD-003 + REQ-OBPD-009: add page → save → renders in builder', async ({ page }) => { // Open the editor pre-focused on the Design tab (router alias from // task 5.3 of the spec). - await page.goto('/index.php/apps/openbuilt/applications/hello-world/design') + await page.goto('/apps/openbuilt/applications/hello-world/design') // The application editor mounts asynchronously after the manifest // fetch returns. Wait for the page-list pane to settle before @@ -92,12 +79,12 @@ test.describe('openbuilt page designer', () => { // REQ-OBPD-003: navigate to the built virtual app and assert the // newly-added route renders inside the inner CnAppRoot mount. - await page.goto('/index.php/apps/openbuilt/builder/hello-world/added-by-e2e') + await page.goto('/apps/openbuilt/builder/hello-world/added-by-e2e') await expect(page.locator('#openbuilt-builder, .cn-app-root')).toBeVisible({ timeout: 15_000 }) }) test('REQ-OBR-005: edits survive a Design ↔ Raw JSON tab switch', async ({ page }) => { - await page.goto('/index.php/apps/openbuilt/applications/hello-world/design') + await page.goto('/apps/openbuilt/applications/hello-world/design') await page.waitForSelector('.page-designer__left', { timeout: 20_000 }) // Switch to the Raw JSON tab and mutate the manifest. diff --git a/tests/e2e/rbac-403.spec.ts b/tests/e2e/rbac-403.spec.ts index dbc2b341..e3c14cfb 100644 --- a/tests/e2e/rbac-403.spec.ts +++ b/tests/e2e/rbac-403.spec.ts @@ -71,7 +71,7 @@ test.describe('openbuilt-rbac — non-member blackout (REQ-OBRBAC-002 / REQ-OBRB }) test('REQ-OBRBAC-003: outsider sees no Applications in the editor list', async ({ page }) => { - await page.goto(`${NEXTCLOUD_URL}/index.php/apps/openbuilt`) + await page.goto(`${NEXTCLOUD_URL}/apps/openbuilt/applications`) // Give the SPA up to 10s to fetch the filtered list. The empty // state copy is exact text from src/views/ApplicationEditor.vue @@ -97,7 +97,7 @@ test.describe('openbuilt-rbac — non-member blackout (REQ-OBRBAC-002 / REQ-OBRB { timeout: 10_000 }, ).catch(() => null) - await page.goto(`${NEXTCLOUD_URL}/index.php/apps/openbuilt/builder/${TEST_SLUG}`) + await page.goto(`${NEXTCLOUD_URL}/apps/openbuilt/builder/${TEST_SLUG}`) const manifestResp = await manifestRequestPromise // The page may render a deny screen without hitting the manifest diff --git a/tests/e2e/schema-designer.spec.ts b/tests/e2e/schema-designer.spec.ts index fa8f4fbe..37ce3e84 100644 --- a/tests/e2e/schema-designer.spec.ts +++ b/tests/e2e/schema-designer.spec.ts @@ -49,20 +49,20 @@ const SCHEMA_SLUG = 'message' test.describe('OpenBuilt Schema Designer — end-to-end (REQ-OBSD-001..008)', () => { test.beforeEach(async ({ page }) => { - // Log in via the Nextcloud /login form. Cookies persist for the - // remainder of this test's context. - await page.goto(`${BASE_URL}/login`, { waitUntil: 'domcontentloaded' }) - await page.fill('input[name="user"]', ADMIN_USER) - await page.fill('input[name="password"]', ADMIN_PASS) - await Promise.all([ - page.waitForLoadState('networkidle'), - page.click('button[type="submit"]'), - ]) + // Session is established by globalSetup (tests/e2e/global-setup.ts) + // which writes storageState that every spec inherits via the + // playwright.config.ts `use.storageState` setting. The legacy + // per-spec form-login fight with the brute-force throttle and + // is unnecessary now. Kept as a no-op `beforeEach` so the + // reference frame is obvious to readers. + void ADMIN_USER + void ADMIN_PASS + void page }) test('create virtual app → add schema → add 2 fields → save → edit → delete', async ({ page }) => { // Step 1 — open the OpenBuilt app at the Applications page. - await page.goto(`${BASE_URL}/index.php/apps/openbuilt/applications`, { + await page.goto(`${BASE_URL}/apps/openbuilt/applications`, { waitUntil: 'domcontentloaded', }) @@ -80,7 +80,7 @@ test.describe('OpenBuilt Schema Designer — end-to-end (REQ-OBSD-001..008)', () } // Step 3 — navigate to the Schema Designer for this virtual app. - await page.goto(`${BASE_URL}/index.php/apps/openbuilt/builder/${APP_SLUG}/schemas`, { + await page.goto(`${BASE_URL}/apps/openbuilt/builder/${APP_SLUG}/schemas`, { waitUntil: 'domcontentloaded', }) diff --git a/tests/e2e/template-gallery.spec.ts b/tests/e2e/template-gallery.spec.ts index 406d2079..e1432cde 100644 --- a/tests/e2e/template-gallery.spec.ts +++ b/tests/e2e/template-gallery.spec.ts @@ -20,36 +20,25 @@ * deferred-bootstrap pattern used by mydash). */ -import { test, expect, Page } from '@playwright/test' +import { test, expect } from '@playwright/test' const NEXTCLOUD_URL = process.env.NEXTCLOUD_URL || process.env.NC_BASE_URL || 'http://localhost:8080' const ADMIN_USER = process.env.NC_ADMIN_USER || 'admin' const ADMIN_PASS = process.env.NC_ADMIN_PASS || 'admin' -/** - * Helper — log in to Nextcloud via the standard /login form. - * - * @param page Playwright page handle. - */ -async function loginAsAdmin(page: Page): Promise { - await page.goto(`${NEXTCLOUD_URL}/index.php/login`) - await page.locator('input[name="user"]').fill(ADMIN_USER) - await page.locator('input[name="password"]').fill(ADMIN_PASS) - await page.locator('button[type="submit"]').click() - // Accept both pretty URLs (`/apps/dashboard/`) and the legacy form - // (`/index.php/apps/dashboard/`) — pretty-URL rewrites are enabled in - // modern NC installs and the redirect drops the `/index.php` prefix. - await page.waitForURL(/\/apps\//, { timeout: 15_000 }) -} +// Auth: globalSetup writes the storageState that every spec inherits +// (see tests/e2e/global-setup.ts + playwright.config.ts use.storageState). +// The legacy per-spec form login is gone — it was racing the NC +// brute-force throttle and is redundant against the shared session. +void ADMIN_USER +void ADMIN_PASS +void NEXTCLOUD_URL test.describe('OpenBuilt template gallery', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page) - }) test('lists the four seeded templates and clones one into a draft application', async ({ page }) => { // 1. Navigate to the gallery. - await page.goto(`${NEXTCLOUD_URL}/index.php/apps/openbuilt/templates`) + await page.goto(`${NEXTCLOUD_URL}/apps/openbuilt/templates`) // 2. Wait for the gallery shell to render. await expect(page.locator('.template-gallery')).toBeVisible({ timeout: 15_000 }) @@ -95,7 +84,7 @@ test.describe('OpenBuilt template gallery', () => { }) test('filter by category narrows to government-services only', async ({ page }) => { - await page.goto(`${NEXTCLOUD_URL}/index.php/apps/openbuilt/templates`) + await page.goto(`${NEXTCLOUD_URL}/apps/openbuilt/templates`) await expect(page.locator('.template-gallery')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('.template-card')).toHaveCount(4, { timeout: 15_000 }) diff --git a/tests/e2e/version-rollback.spec.ts b/tests/e2e/version-rollback.spec.ts index 175d0463..2b1eed91 100644 --- a/tests/e2e/version-rollback.spec.ts +++ b/tests/e2e/version-rollback.spec.ts @@ -64,7 +64,7 @@ test.describe('openbuilt-versioning — publish + rollback (REQ-OBV-005 / REQ-OB test('publish a manifest edit then roll back to v1.0.0 — history grows append-only', async ({ page }) => { // Step 1 — open the openbuilt shell + navigate to the hello-world editor. - await page.goto(`${NEXTCLOUD_URL}/index.php/apps/openbuilt`) + await page.goto(`${NEXTCLOUD_URL}/apps/openbuilt/applications`) // The list-page renders an entry per Application. Click the seeded // row's edit affordance — accept either a [data-slug] anchor or the // rendered slug text. diff --git a/tests/e2e/versionRouting.spec.ts b/tests/e2e/versionRouting.spec.ts index fb3a27b0..2b82fbe9 100644 --- a/tests/e2e/versionRouting.spec.ts +++ b/tests/e2e/versionRouting.spec.ts @@ -142,7 +142,7 @@ test.describe('9.2 Unauthorised access to non-production version shows 404 UI (R } // Navigate to the builder with the staging version. - await page.goto(`${BASE}/index.php/apps/openbuilt/builder/${TEST_SLUG}/schemas?_version=${STAGING_VERSION}`) + await page.goto(`${BASE}/apps/openbuilt/builder/${TEST_SLUG}/schemas?_version=${STAGING_VERSION}`) await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}) // The view must show a "not found" UI — no schema list, no stack trace, @@ -200,7 +200,7 @@ test.describe('9.3 Default version resolution — most-upstream-non-production f } // Navigate to the builder root (no ?_version=). - await page.goto(`${BASE}/index.php/apps/openbuilt/builder/${TEST_SLUG}`) + await page.goto(`${BASE}/apps/openbuilt/builder/${TEST_SLUG}`) await page.waitForLoadState('networkidle', { timeout: 20_000 }) // The composable (useApplicationVersion) should resolve "development" diff --git a/tests/scripts/check-manifest.spec.js b/tests/scripts/check-manifest.spec.js new file mode 100644 index 00000000..e5c03535 --- /dev/null +++ b/tests/scripts/check-manifest.spec.js @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. + +/** + * Vitest spec for scripts/check-manifest.js (openbuilt#10 task 4.3). + * + * The validator binds the canonical ADR-024 schema + * (`@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json`) to + * Ajv 2020 and asserts that the seed manifests we ship in the repo + * (`src/manifest.json`, `lib/Resources/wizard/default-manifest.json`) + * remain structurally valid. + * + * The integration tests in this spec invoke the script as a + * subprocess so we cover the actual CLI behaviour, not a re-import + * of its internals. + */ + +import { describe, it, expect } from 'vitest' +import { execFileSync } from 'node:child_process' +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const REPO_ROOT = resolve(__dirname, '../..') +const SCRIPT = resolve(REPO_ROOT, 'scripts/check-manifest.js') + +function runValidator(args = []) { + try { + const out = execFileSync('node', [SCRIPT, ...args], { + cwd: REPO_ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + }) + return { code: 0, stdout: out.toString(), stderr: '' } + } catch (err) { + return { + code: err.status ?? 1, + stdout: (err.stdout || '').toString(), + stderr: (err.stderr || '').toString(), + } + } +} + +describe('check-manifest CLI', () => { + it('passes for the OpenBuilt shell + wizard seed (default targets)', () => { + const { code, stdout } = runValidator() + expect(code).toBe(0) + expect(stdout).toContain('PASS src/manifest.json') + expect(stdout).toContain('PASS lib/Resources/wizard/default-manifest.json') + }) + + it('passes for a valid hand-rolled manifest', () => { + const dir = mkdtempSync(join(tmpdir(), 'check-manifest-')) + const file = join(dir, 'ok.json') + try { + writeFileSync(file, JSON.stringify({ + version: '1.0.0', + menu: [ + { id: 'home', label: 'Home', icon: 'icon-home', route: 'Home', order: 10 }, + ], + pages: [ + { id: 'Home', route: '/', type: 'dashboard', title: 'Home', config: { widgets: [], layout: [] } }, + ], + })) + const { code, stdout } = runValidator([file]) + expect(code).toBe(0) + expect(stdout).toContain('PASS') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('fails (exit 1) for a manifest missing required fields', () => { + const dir = mkdtempSync(join(tmpdir(), 'check-manifest-')) + const file = join(dir, 'broken.json') + try { + // Missing `version` AND `pages`. + writeFileSync(file, JSON.stringify({ menu: [] })) + const { code, stderr } = runValidator([file]) + expect(code).toBe(1) + expect(stderr).toContain('FAIL') + // Schema-validator surfaces the missing-required-property error. + expect(stderr).toMatch(/required property|version|pages/i) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('substitutes the {registerSlug} placeholder so wizard seeds validate', () => { + const dir = mkdtempSync(join(tmpdir(), 'check-manifest-')) + const file = join(dir, 'wizard-like.json') + try { + writeFileSync(file, JSON.stringify({ + version: '1.0.0', + menu: [ + { id: 'msgs', label: 'Messages', icon: 'icon-comment', route: 'Msgs', order: 10 }, + ], + pages: [ + { + id: 'Msgs', + route: '/messages', + type: 'index', + title: 'Messages', + config: { register: '{registerSlug}', schema: 'hello-message', columns: ['body'] }, + }, + ], + })) + const { code, stdout } = runValidator([file]) + expect(code).toBe(0) + expect(stdout).toContain('PASS') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) From 85336498ccd7c3389322eb22e26fb556f0d1394d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 17 May 2026 08:59:08 +0200 Subject: [PATCH 2/3] test(page-editor): vitest coverage for the 3 stub sub-editors (openbuilt#9 task 7.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CustomPageEditor / FormPageEditor / etc. already had Vitest specs; the three remaining v1.1 sub-editors did not. Filling the gap: - tests/components/page-editor/StubPageEditor.spec.js (6 tests) — raw-JSON round-trip, parse-error path, external config re-seed, stability when the watcher re-emits an identical config. - tests/components/page-editor/DashboardPageEditor.spec.js (5 tests) — validatedConfigKeys contract, update(key, value) round-trip, empty-array + null deletion semantics. - tests/components/page-editor/DetailPageEditor.spec.js (11 tests) — full update + sidebar-shape transition matrix, routeParams parser, updateSidebarKey preservation, updateSidebarPropsTabs cleanup. The useRegisterPicker composable is mocked so the test never hits OR. All three editors now have parity with their already-tested siblings. Vitest 487 → 513. PHPUnit 212/212 still green. --- .../page-editor/DashboardPageEditor.spec.js | 77 ++++++++++ .../page-editor/DetailPageEditor.spec.js | 145 ++++++++++++++++++ .../page-editor/StubPageEditor.spec.js | 85 ++++++++++ 3 files changed, 307 insertions(+) create mode 100644 tests/components/page-editor/DashboardPageEditor.spec.js create mode 100644 tests/components/page-editor/DetailPageEditor.spec.js create mode 100644 tests/components/page-editor/StubPageEditor.spec.js diff --git a/tests/components/page-editor/DashboardPageEditor.spec.js b/tests/components/page-editor/DashboardPageEditor.spec.js new file mode 100644 index 00000000..fa91c48f --- /dev/null +++ b/tests/components/page-editor/DashboardPageEditor.spec.js @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2026 OpenBuilt Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * Vitest spec for DashboardPageEditor (openbuilt#9 task 7.1). + * + * Asserts the simple `update(key, value)` round-trip: + * - assigning a non-empty array emits `update:config` with the value set. + * - assigning an empty array or falsy value DELETES the key (REQ-OBPD-005 + * parity with the other sub-editors). + * - the validated key-set is exactly `['widgets', 'layout']` (the contract + * that `pageEditorValidationMixin` reads for inline-mark routing). + */ + +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import DashboardPageEditor from '../../../src/components/page-editor/DashboardPageEditor.vue' + +function mountEditor(config = {}) { + // Stub the heavy widget/layout builders + the inline mark — DashboardPageEditor + // only owns the `update()` round-trip; the children are tested separately. + return mount(DashboardPageEditor, { + propsData: { config, pageType: 'dashboard', appSlug: 'hello-world', parentRoute: '/' }, + stubs: { + WidgetBuilder: { template: '
' }, + LayoutItemBuilder: { template: '
' }, + InlineFieldMark: { template: '' }, + }, + mocks: { + $validator: { + register: vi.fn(), + unregister: vi.fn(), + errorsForPathPrefix: () => ({ errors: [], warnings: [] }), + summaryForPathPrefix: () => null, + }, + }, + }) +} + +describe('DashboardPageEditor', () => { + it('validatedConfigKeys is exactly [widgets, layout]', () => { + expect(mountEditor().vm.validatedConfigKeys).toEqual(['widgets', 'layout']) + }) + + it('update(widgets, [...]) emits update:config with widgets set', async () => { + const wrapper = mountEditor({}) + wrapper.vm.update('widgets', [{ id: 'w1' }]) + await wrapper.vm.$nextTick() + const emitted = wrapper.emitted('update:config') + expect(emitted).toBeTruthy() + expect(emitted[0][0]).toEqual({ widgets: [{ id: 'w1' }] }) + }) + + it('update(layout, []) deletes the key (empty-array contract)', async () => { + const wrapper = mountEditor({ layout: [{ id: 'l1', gridX: 0, gridY: 0 }] }) + wrapper.vm.update('layout', []) + await wrapper.vm.$nextTick() + const emitted = wrapper.emitted('update:config')[0][0] + expect(emitted).not.toHaveProperty('layout') + }) + + it('update(widgets, null) deletes the key (falsy contract)', async () => { + const wrapper = mountEditor({ widgets: [{ id: 'w1' }] }) + wrapper.vm.update('widgets', null) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('update:config')[0][0]).not.toHaveProperty('widgets') + }) + + it('successive updates preserve previously-set keys', async () => { + const wrapper = mountEditor({ widgets: [{ id: 'w1' }] }) + wrapper.vm.update('layout', [{ id: 'l1', gridX: 0, gridY: 0 }]) + await wrapper.vm.$nextTick() + const out = wrapper.emitted('update:config')[0][0] + expect(out.widgets).toEqual([{ id: 'w1' }]) + expect(out.layout).toEqual([{ id: 'l1', gridX: 0, gridY: 0 }]) + }) +}) diff --git a/tests/components/page-editor/DetailPageEditor.spec.js b/tests/components/page-editor/DetailPageEditor.spec.js new file mode 100644 index 00000000..b0de8ce9 --- /dev/null +++ b/tests/components/page-editor/DetailPageEditor.spec.js @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2026 OpenBuilt Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * Vitest spec for DetailPageEditor (openbuilt#9 task 7.1). + * + * Covers: + * - validatedConfigKeys is exactly [register, schema, sidebar, sidebarProps] + * - update(register, ...) emits with `register` set and clears any stale schema + * - update(schema, ...) emits with `schema` set + * - setSidebarShape('none' | 'boolean' | 'object') drives sidebar shape transitions + * - updateSidebarKey + updateSidebarPropsTabs preserve / clean their config slots + * - routeParams parses the parent route's `:name` markers + * + * The composable useRegisterPicker is stubbed so the test never hits OR. + */ + +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +vi.mock('../../../src/composables/useRegisterPicker.js', () => ({ + useRegisterPicker: () => ({ + fetchRegisters: vi.fn().mockResolvedValue([]), + fetchSchemas: vi.fn().mockResolvedValue([]), + }), +})) + +import DetailPageEditor from '../../../src/components/page-editor/DetailPageEditor.vue' + +function mountEditor(config = {}, propsOverrides = {}) { + return mount(DetailPageEditor, { + propsData: { + config, + parentRoute: '/messages/:id', + appSlug: 'hello-world', + pageType: 'detail', + ...propsOverrides, + }, + stubs: { + SidebarTabBuilder: { template: '