diff --git a/README.md b/README.md index 4bba3a09..3f207341 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,33 @@ Features are defined in [`openspec/specs/`](openspec/specs/) and tracked via the - **Textarea manifest editor** — JSON-only for v1 (visual editor lives in chain spec #5) - **Seeded `hello-world` Application** exercising `index`, `detail`, and `form` page types out of the box +### Visual designer (Design tab + Raw JSON fallback) + +Each Application's editor exposes two tabs (`openbuilt-page-editor` v1.1): + +- **Design** (default) — type-aware sub-editors for the six manifest page types + (`index`, `detail`, `form`, `dashboard`, `chat`, `logs`, `settings`, `files`, + `custom`). The Design tab carries an inline-mark validator that flags ADR-024 + schema errors on the offending field instead of dumping a side-panel summary, + plus an undo/redo stack scoped to the in-flight manifest. +- **Raw JSON** — the integrator fallback. Drops you into the manifest's bare + JSON when a manifest shape isn't yet supported by a Design-tab sub-editor, or + when you want to bulk-edit. Edits round-trip losslessly through `parse → + stringify → parse` (Vitest spec `tests/composables/manifestRoundTrip.spec.js`) + so external authoring tools — git diffs, IDE auto-format — stay coherent + with the Design tab. + +Run the canonical-shape validator locally: + +```bash +npm run check:manifest +``` + +The script (`scripts/check-manifest.js`) validates the OpenBuilt shell +manifest plus the wizard seed against +`@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` (the +ADR-024 canonical schema) and exits non-zero on any structural drift. + ### Chained follow-on specs - `nextcloud-vue-in-memory-manifest` (in `nextcloud-vue/`) — `useAppManifest` overload accepting an in-memory manifest object - `openregister-runtime-schema-api` (in `openregister/`) — runtime schema-creation API 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/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: '