Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
110 changes: 110 additions & 0 deletions scripts/check-manifest.js
Original file line number Diff line number Diff line change
@@ -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()
236 changes: 236 additions & 0 deletions tests/Unit/ApplicationVersionLifecycleSchemaTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
<?php

/**
* Unit test for the ApplicationVersion lifecycle declared in
* `lib/Settings/openbuilt_register.json` (openbuilt#10 task 5.2).
*
* Per ADR-031 (schema-declarative business logic), the
* `applicationVersion` schema carries an `x-openregister-lifecycle`
* block describing the `draft → published → archived → draft (reopen)`
* state machine. OR's TransitionEngine executes the transitions at
* runtime; this test asserts the *contract* is well-formed:
*
* - initial state is `draft`
* - the three named states are exactly draft/published/archived
* - the three transitions are publish (draft→published),
* archive (published→archived), reopen (archived→draft)
* - the publish transition declares an `upsert_relation` action
* targeting `openbuilt/built-app-route` (BuiltAppRoute upkeep)
*
* A real end-to-end transition test requires booted Nextcloud +
* OpenRegister with a Postgres / MySQL backend (see openbuilt#10
* task 5.2 note "Requires container-bound NC bootstrap"). That
* integration scope is tracked separately; this test guards the
* declarative contract that anchors it.
*
* SPDX-License-Identifier: EUPL-1.2
* SPDX-FileCopyrightText: 2026 Conduction B.V.
*
* @category Test
* @package OCA\OpenBuilt\Tests\Unit
*
* @author Conduction Development Team <dev@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @version GIT: <git-id>
*
* @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<string, mixed>|null
*/
private static ?array $registerSeed = null;

/**
* Load + cache the canonical register seed.
*
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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
Loading
Loading