Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit cb6b01c

Browse files
authored
Playwright + bootstrap verification + page-editor v1.1 tests (closes openbuilt#79, partially closes #9 #10) (#81)
* fix(e2e+bootstrap): clean URLs, shared session, manifest validator, lifecycle schema test 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. * test(page-editor): vitest coverage for the 3 stub sub-editors (openbuilt#9 task 7.1) 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. * test+docs(page-editor): manifest round-trip Vitest + README Visual-designer section (openbuilt#9 tasks 7.2 + 8.1) ## Task 7.2 — manifest round-trip Vitest tests/composables/manifestRoundTrip.spec.js asserts that the canonical manifests (OpenBuilt shell + wizard seed) survive a JSON.parse → JSON.stringify → JSON.parse cycle without losing information AND still validate against the ADR-024 schema after the round-trip. Strict bytewise equality is too brittle (key ordering varies between authors), so the test checks deep-equal + schema-still-valid + idempotency-on-second-round-trip. Three additional cases cover synthetic manifests with nested config blocks (sort, columns) to make sure the editor never flattens or coerces. Note: `lib/Resources/template/src/manifest.json` is a Mustache scaffold (`{{appId}}` etc.) and is intentionally NOT in the validator target list — it's rendered at export time, not consumed as a runtime manifest. ## Task 8.1 — README Visual-designer section New "Visual designer (Design tab + Raw JSON fallback)" section above the chained-spec list explains: - Design tab is the default; type-aware sub-editors per page type; carries inline-mark validator + undo/redo - Raw JSON tab is the integrator fallback; edits round-trip losslessly through parse → stringify → parse (covered by the spec above) - How to run `npm run check:manifest` locally to validate manifest shapes against the canonical schema Vitest 513 → 521. PHPUnit still 212/212 green.
1 parent ce64707 commit cb6b01c

22 files changed

Lines changed: 993 additions & 77 deletions

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,33 @@ Features are defined in [`openspec/specs/`](openspec/specs/) and tracked via the
3737
- **Textarea manifest editor** — JSON-only for v1 (visual editor lives in chain spec #5)
3838
- **Seeded `hello-world` Application** exercising `index`, `detail`, and `form` page types out of the box
3939

40+
### Visual designer (Design tab + Raw JSON fallback)
41+
42+
Each Application's editor exposes two tabs (`openbuilt-page-editor` v1.1):
43+
44+
- **Design** (default) — type-aware sub-editors for the six manifest page types
45+
(`index`, `detail`, `form`, `dashboard`, `chat`, `logs`, `settings`, `files`,
46+
`custom`). The Design tab carries an inline-mark validator that flags ADR-024
47+
schema errors on the offending field instead of dumping a side-panel summary,
48+
plus an undo/redo stack scoped to the in-flight manifest.
49+
- **Raw JSON** — the integrator fallback. Drops you into the manifest's bare
50+
JSON when a manifest shape isn't yet supported by a Design-tab sub-editor, or
51+
when you want to bulk-edit. Edits round-trip losslessly through `parse →
52+
stringify → parse` (Vitest spec `tests/composables/manifestRoundTrip.spec.js`)
53+
so external authoring tools — git diffs, IDE auto-format — stay coherent
54+
with the Design tab.
55+
56+
Run the canonical-shape validator locally:
57+
58+
```bash
59+
npm run check:manifest
60+
```
61+
62+
The script (`scripts/check-manifest.js`) validates the OpenBuilt shell
63+
manifest plus the wizard seed against
64+
`@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` (the
65+
ADR-024 canonical schema) and exits non-zero on any structural drift.
66+
4067
### Chained follow-on specs
4168
- `nextcloud-vue-in-memory-manifest` (in `nextcloud-vue/`) — `useAppManifest` overload accepting an in-memory manifest object
4269
- `openregister-runtime-schema-api` (in `openregister/`) — runtime schema-creation API

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"lint-fix": "npm run lint -- --fix",
1515
"stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css",
1616
"stylelint-fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css --fix",
17+
"check:manifest": "node scripts/check-manifest.js",
1718
"test": "vitest run",
1819
"test:e2e": "playwright test",
1920
"test:e2e:ui": "playwright test --ui",

scripts/check-manifest.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/env node
2+
// SPDX-License-Identifier: EUPL-1.2
3+
// SPDX-FileCopyrightText: 2026 Conduction B.V.
4+
5+
/**
6+
* check-manifest — validate openbuilt manifests against the canonical
7+
* @conduction/nextcloud-vue ADR-024 schema.
8+
*
9+
* Implements openbuilt#10 task 4.3 — "Run npm run check:manifest on the
10+
* seeded hello-world manifest blob in tests; passes against the canonical
11+
* schema pinned in package.json."
12+
*
13+
* Defaults to validating:
14+
* - src/manifest.json (the OpenBuilt shell manifest)
15+
* - lib/Resources/wizard/default-manifest.json (the wizard seed)
16+
*
17+
* Pass alternate paths as CLI args. The wizard seed carries the literal
18+
* `{registerSlug}` placeholder string in `pages[].config.register`, so
19+
* it's validated through a wrapper that swaps the token to a syntactically
20+
* valid slug before validation runs. We're checking the schema-shape, not
21+
* the placeholder substitution itself.
22+
*
23+
* Exits 0 when every input passes; 1 otherwise.
24+
*/
25+
26+
const fs = require('node:fs')
27+
const path = require('node:path')
28+
const Ajv = require('ajv/dist/2020').default
29+
30+
const SCHEMA_PATH = path.resolve(
31+
__dirname,
32+
'../node_modules/@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json',
33+
)
34+
35+
const DEFAULT_TARGETS = [
36+
'src/manifest.json',
37+
'lib/Resources/wizard/default-manifest.json',
38+
]
39+
40+
function loadJson(filePath) {
41+
const raw = fs.readFileSync(filePath, 'utf-8')
42+
return JSON.parse(raw)
43+
}
44+
45+
/**
46+
* Replace token placeholders in a manifest with syntactically valid values
47+
* so the schema validator can run against the structural shape.
48+
*
49+
* @param {object} manifest The manifest payload.
50+
* @returns {object} The manifest with tokens substituted.
51+
*/
52+
function substituteTokens(manifest) {
53+
if (!manifest || !Array.isArray(manifest.pages)) return manifest
54+
return {
55+
...manifest,
56+
pages: manifest.pages.map((page) => {
57+
if (!page || typeof page !== 'object' || !page.config) return page
58+
const config = { ...page.config }
59+
if (config.register === '{registerSlug}') {
60+
config.register = 'openbuilt-validator-placeholder'
61+
}
62+
return { ...page, config }
63+
}),
64+
}
65+
}
66+
67+
function main() {
68+
const args = process.argv.slice(2)
69+
const targets = args.length > 0 ? args : DEFAULT_TARGETS
70+
const repoRoot = path.resolve(__dirname, '..')
71+
72+
const schema = loadJson(SCHEMA_PATH)
73+
const ajv = new Ajv({ allErrors: true, strict: false })
74+
const validate = ajv.compile(schema)
75+
76+
let allPassed = true
77+
for (const target of targets) {
78+
const abs = path.isAbsolute(target) ? target : path.join(repoRoot, target)
79+
if (!fs.existsSync(abs)) {
80+
console.error(`SKIP ${target} (not found)`)
81+
continue
82+
}
83+
84+
let manifest
85+
try {
86+
manifest = loadJson(abs)
87+
} catch (err) {
88+
console.error(`FAIL ${target} — JSON parse error: ${err.message}`)
89+
allPassed = false
90+
continue
91+
}
92+
93+
const candidate = substituteTokens(manifest)
94+
const valid = validate(candidate)
95+
if (valid) {
96+
console.log(`PASS ${target}`)
97+
continue
98+
}
99+
100+
allPassed = false
101+
console.error(`FAIL ${target}`)
102+
for (const err of validate.errors || []) {
103+
console.error(` ${err.instancePath || '(root)'} ${err.message}`)
104+
}
105+
}
106+
107+
process.exit(allPassed ? 0 : 1)
108+
}
109+
110+
main()
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
<?php
2+
3+
/**
4+
* Unit test for the ApplicationVersion lifecycle declared in
5+
* `lib/Settings/openbuilt_register.json` (openbuilt#10 task 5.2).
6+
*
7+
* Per ADR-031 (schema-declarative business logic), the
8+
* `applicationVersion` schema carries an `x-openregister-lifecycle`
9+
* block describing the `draft → published → archived → draft (reopen)`
10+
* state machine. OR's TransitionEngine executes the transitions at
11+
* runtime; this test asserts the *contract* is well-formed:
12+
*
13+
* - initial state is `draft`
14+
* - the three named states are exactly draft/published/archived
15+
* - the three transitions are publish (draft→published),
16+
* archive (published→archived), reopen (archived→draft)
17+
* - the publish transition declares an `upsert_relation` action
18+
* targeting `openbuilt/built-app-route` (BuiltAppRoute upkeep)
19+
*
20+
* A real end-to-end transition test requires booted Nextcloud +
21+
* OpenRegister with a Postgres / MySQL backend (see openbuilt#10
22+
* task 5.2 note "Requires container-bound NC bootstrap"). That
23+
* integration scope is tracked separately; this test guards the
24+
* declarative contract that anchors it.
25+
*
26+
* SPDX-License-Identifier: EUPL-1.2
27+
* SPDX-FileCopyrightText: 2026 Conduction B.V.
28+
*
29+
* @category Test
30+
* @package OCA\OpenBuilt\Tests\Unit
31+
*
32+
* @author Conduction Development Team <dev@conduction.nl>
33+
* @copyright 2026 Conduction B.V.
34+
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
35+
*
36+
* @version GIT: <git-id>
37+
*
38+
* @link https://conduction.nl
39+
*/
40+
41+
declare(strict_types=1);
42+
43+
namespace OCA\OpenBuilt\Tests\Unit;
44+
45+
use PHPUnit\Framework\TestCase;
46+
47+
/**
48+
* Tests the declarative lifecycle of ApplicationVersion (openbuilt#10).
49+
*/
50+
class ApplicationVersionLifecycleSchemaTest extends TestCase
51+
{
52+
/**
53+
* Decoded register-seed payload, lazily loaded.
54+
*
55+
* @var array<string, mixed>|null
56+
*/
57+
private static ?array $registerSeed = null;
58+
59+
/**
60+
* Load + cache the canonical register seed.
61+
*
62+
* @return array<string, mixed>
63+
*/
64+
private function registerSeed(): array
65+
{
66+
if (self::$registerSeed === null) {
67+
$path = __DIR__.'/../../lib/Settings/openbuilt_register.json';
68+
self::assertFileExists($path, 'register seed file must be present');
69+
$raw = file_get_contents($path);
70+
$decoded = json_decode($raw, true);
71+
self::assertIsArray($decoded, 'register seed must be a JSON object');
72+
self::$registerSeed = $decoded;
73+
}
74+
75+
return self::$registerSeed;
76+
}//end registerSeed()
77+
78+
/**
79+
* Pull the ApplicationVersion schema block out of the register seed.
80+
*
81+
* @return array<string, mixed>
82+
*/
83+
private function applicationVersionSchema(): array
84+
{
85+
$seed = $this->registerSeed();
86+
// The seed is OpenAPI-shaped; schemas live under components.schemas
87+
// and the version schema is keyed `ApplicationVersion` (PascalCase).
88+
self::assertArrayHasKey('components', $seed);
89+
self::assertArrayHasKey('schemas', $seed['components']);
90+
$schemas = $seed['components']['schemas'];
91+
self::assertIsArray($schemas);
92+
self::assertArrayHasKey(
93+
'ApplicationVersion',
94+
$schemas,
95+
'register seed must define an ApplicationVersion schema'
96+
);
97+
$schema = $schemas['ApplicationVersion'];
98+
self::assertIsArray($schema);
99+
return $schema;
100+
}//end applicationVersionSchema()
101+
102+
/**
103+
* Pull out the lifecycle declaration.
104+
*
105+
* @return array<string, mixed>
106+
*/
107+
private function lifecycle(): array
108+
{
109+
$schema = $this->applicationVersionSchema();
110+
// `x-openregister-lifecycle` sits at the schema level (sibling
111+
// of `properties`), not inside the properties map.
112+
self::assertArrayHasKey(
113+
'x-openregister-lifecycle',
114+
$schema,
115+
'ApplicationVersion must declare x-openregister-lifecycle'
116+
);
117+
$lifecycle = $schema['x-openregister-lifecycle'];
118+
self::assertIsArray($lifecycle, 'lifecycle block must be an object');
119+
return $lifecycle;
120+
}//end lifecycle()
121+
122+
/**
123+
* REQ-OBV-LC-1 — initial state is draft.
124+
*
125+
* @return void
126+
*/
127+
public function testInitialStateIsDraft(): void
128+
{
129+
$lifecycle = $this->lifecycle();
130+
self::assertSame('status', $lifecycle['field'] ?? null);
131+
self::assertSame('draft', $lifecycle['initial'] ?? null);
132+
}//end testInitialStateIsDraft()
133+
134+
/**
135+
* REQ-OBV-LC-2 — three named states exist: draft, published, archived.
136+
*
137+
* @return void
138+
*/
139+
public function testStateSetIsDraftPublishedArchived(): void
140+
{
141+
$lifecycle = $this->lifecycle();
142+
self::assertArrayHasKey('states', $lifecycle);
143+
self::assertIsArray($lifecycle['states']);
144+
145+
$expected = ['draft', 'published', 'archived'];
146+
$actual = array_keys($lifecycle['states']);
147+
sort($expected);
148+
sort($actual);
149+
self::assertSame($expected, $actual);
150+
}//end testStateSetIsDraftPublishedArchived()
151+
152+
/**
153+
* REQ-OBV-LC-3 — three transitions: publish, archive, reopen.
154+
*
155+
* @return void
156+
*/
157+
public function testThreeTransitionsAreDeclared(): void
158+
{
159+
$lifecycle = $this->lifecycle();
160+
$transitions = ($lifecycle['transitions'] ?? []);
161+
self::assertIsArray($transitions);
162+
self::assertCount(3, $transitions, 'expected exactly 3 transitions (publish/archive/reopen)');
163+
164+
$byName = [];
165+
foreach ($transitions as $t) {
166+
self::assertIsArray($t);
167+
self::assertArrayHasKey('name', $t);
168+
$byName[$t['name']] = $t;
169+
}
170+
171+
self::assertSame(['publish', 'archive', 'reopen'], array_keys($byName));
172+
173+
self::assertSame('draft', $byName['publish']['from']);
174+
self::assertSame('published', $byName['publish']['to']);
175+
176+
self::assertSame('published', $byName['archive']['from']);
177+
self::assertSame('archived', $byName['archive']['to']);
178+
179+
self::assertSame('archived', $byName['reopen']['from']);
180+
self::assertSame('draft', $byName['reopen']['to']);
181+
}//end testThreeTransitionsAreDeclared()
182+
183+
/**
184+
* REQ-OBV-LC-4 — publish fires the upsert_relation that keeps
185+
* BuiltAppRoute in sync. This is the declarative replacement for
186+
* the old ApplicationVersionSnapshotListener (per ADR-031).
187+
*
188+
* @return void
189+
*/
190+
public function testPublishUpsertsBuiltAppRoute(): void
191+
{
192+
$lifecycle = $this->lifecycle();
193+
$transition = null;
194+
foreach ($lifecycle['transitions'] as $t) {
195+
if (($t['name'] ?? null) === 'publish') {
196+
$transition = $t;
197+
break;
198+
}
199+
}
200+
201+
self::assertIsArray($transition, 'publish transition must exist');
202+
self::assertArrayHasKey('on_transition', $transition);
203+
self::assertArrayHasKey('upsert_relation', $transition['on_transition']);
204+
205+
$upsert = $transition['on_transition']['upsert_relation'];
206+
self::assertSame('openbuilt/built-app-route', $upsert['schema'] ?? null);
207+
// The slug-keyed match ensures the route survives republishes
208+
// (one row per Application slug).
209+
self::assertArrayHasKey('match', $upsert);
210+
self::assertArrayHasKey('slug', $upsert['match']);
211+
self::assertArrayHasKey('payload', $upsert);
212+
self::assertArrayHasKey('slug', $upsert['payload']);
213+
self::assertArrayHasKey('applicationUuid', $upsert['payload']);
214+
}//end testPublishUpsertsBuiltAppRoute()
215+
216+
/**
217+
* Sanity guard — a disallowed transition (e.g. draft → archived
218+
* directly) is NOT declared. OR's TransitionEngine rejects undefined
219+
* transitions; the test catches accidental schema drift that would
220+
* widen the state machine.
221+
*
222+
* @return void
223+
*/
224+
public function testDisallowedTransitionIsAbsent(): void
225+
{
226+
$lifecycle = $this->lifecycle();
227+
$pairs = array_map(
228+
static fn (array $t): string => sprintf('%s->%s', ($t['from'] ?? '?'), ($t['to'] ?? '?')),
229+
$lifecycle['transitions']
230+
);
231+
232+
self::assertNotContains('draft->archived', $pairs);
233+
self::assertNotContains('published->draft', $pairs);
234+
self::assertNotContains('archived->published', $pairs);
235+
}//end testDisallowedTransitionIsAbsent()
236+
}//end class

0 commit comments

Comments
 (0)