Skip to content

Release: merge development into beta - #79

Open
github-actions[bot] wants to merge 673 commits into
betafrom
development
Open

Release: merge development into beta#79
github-actions[bot] wants to merge 673 commits into
betafrom
development

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Automated PR to sync development changes to beta for beta release.

Merging this PR will trigger the beta release workflow.

Reminder: Add a major, minor, or patch label to this PR to control the version bump. Default is patch.

juanclaude-conduction and others added 30 commits July 30, 2026 03:21
…l, and resolve schema properties from a route that exists

Two real product bugs, both found by the page-editor-coverage specs and both
live-verified on ob-vue3-e2e. The saves were never broken (the manifest PATCH
returns 200) — the designer was.

1. The page-list row overflowed its pane and painted over the centre one.
   `.page-designer__left` is a 280–320px grid track and the panes are
   `overflow: visible`, so anything that spills is not clipped — it is painted
   on top of the centre pane and, being later in paint order, swallows every
   pointer event aimed at the sub-editor underneath. The row packed a drag
   handle, two text inputs, a type tag, a group picker and a remove button into
   one `nowrap` line. Measured at 1280x720 before the fix:

     row                        x=355..639  (pane ends at 648)
     input (route)              x=517..647  8px past the row
     span.type-tag              x=656..698  overlaps the centre pane (x>=669)
     div.permission-group-field x=704, w=0, h=538
     button.remove              x=710..741  entirely inside the centre pane

   ~386px of content in a 284px row, and a 550px-tall row because the
   collapsed picker stacked its label and hint vertically — tall enough that
   the row's own centre point fell outside the viewport, so clicking a page row
   did nothing at all. Fixed by letting the row wrap and letting its items
   shrink: `flex-wrap: wrap`, `min-width: 0` + a small basis on the inputs
   (an `<input>`'s automatic minimum size is its intrinsic width, so
   `min-width: auto` alone kept two of them wider than the whole row), and the
   picker on its own line via `order`. Measured after: every child inside the
   pane, row 3 lines tall, nothing overlapping. Note the pre-existing
   `min-width: 0` on the picker was necessary but not sufficient — it fixed one
   item while the inputs still pushed the rest out.

2. `useRegisterPicker.fetchSchemaProperties()` called a route that does not
   exist, and swallowed the 404.
   It GET'd `/api/registers/{register}/schemas/{schema}`. OpenRegister routes
   `/api/registers/{id}/schemas` (list) and `/api/schemas/{id}` (flat); the only
   nested per-schema route is `.../import-template`. Confirmed against the
   instance AND against openregister's routes.php: 404 for slugs and for
   numeric ids alike. `if (!response.ok) return {}` turned that into an empty
   properties map, so EVERY field-mapping dropdown backed by it rendered with
   nothing but its placeholder — WikiPageEditor content/title/sidebar fields,
   IndexPageEditor, LogsPageEditor and MapPageEditor lat/lng. Every Vitest
   suite stubs this composable, so none of them could see it.

   Now resolved from the register's schema list, which already returns each
   schema's `properties` inline (`Schema::jsonSerialize`, the same assumption
   `buildDataSources` already relies on): one request instead of two, and
   matching within the register disambiguates schema slugs, which are not
   globally unique — this instance holds several registers with a
   `hello-world-production-hello-message`. Its unit tests now assert the URL,
   so a regression to a non-existent route fails there rather than silently in
   the UI.

Spec-side fixes in the same file:
  - reopening a page now clicks the row's type tag via `selectPageRow()`.
    Playwright clicks an element's geometric centre, and the row's centre lands
    on the permission picker, which carries `@click.stop` so opening a group
    dropdown does not re-select the row. The click was swallowed and no page
    was selected, so the sub-editor stayed unmounted.
  - the wiki spec binds hello-world's own register + schema by slug instead of
    `{ index: 1 }`, which resolved to Nextcloud's `directory` register.
  - `selectOrFill()` waits for the async property options before choosing one.
    It was reading `option[value="body"]` before the fetch landed, falling back
    to `{ index: 1 }`, and Playwright then applied that to the list that had
    meanwhile loaded — asking for `body` quietly bound `id`.
  - the round-trip assertions anchor `Register`/`Schema` so they do not also
    match the `Sidebar …` rows, and assert the exact bound values rather than
    merely "not empty".
Brings in the component-blocks (#53) and form-editor-logic (#52)
un-quarantines. Resolutions:

  tests/e2e/component-blocks.spec.ts (conflict) — ours.
  tests/e2e/form-editor-logic.spec.ts (no conflict, fixed anyway) —
    both arrived computing their own base URL as
    `process.env.NEXTCLOUD_URL || process.env.NC_BASE_URL || 'http://localhost:8080'`
    (resp. `NC_BASE_URL ?? …:8080`), which ignores PLAYWRIGHT_BASE_URL. This
    suite runs with PLAYWRIGHT_BASE_URL=http://localhost:8099 and NC_BASE_URL
    unset, so both fell through to :8080 — the SHARED `nextcloud` container
    holding other people's checkouts. Both specs WRITE (component-blocks
    provisions two fixture apps plus component-block objects; form-editor-logic
    PUTs the app manifest) while creating their app through `ensureApp()`, which
    uses a relative URL and therefore the config baseURL — so they would have
    written fixtures to one instance and asserted against another. Both now
    import E2E_BASE_URL; see tests/e2e/support/baseUrl.ts for the writeup that
    already exists on exactly this failure mode.

  src/components/page-editor/fields/FormFieldBuilder.vue — auto-merged, but the
    two docblocks concatenated into a self-contradiction: development's new text
    explains that `expandedIndices` is now re-based on removal, while the
    retained tail still asserted "expandedIndices is NOT re-based", plus a
    duplicate @param/@return pair. Dropped the stale tail; the code (which does
    re-base) is development's and is kept.

  package.json — untouched by both commits, so the Vue 3 line's
    `@conduction/nextcloud-vue: ^2.1.0-vue3.6` survives. Verified installed:
    2.1.0-vue3.6, no beta.2xx anywhere.
…obe; bump nc-vue to vue3.7; correct two spec drifts

Docudesk capability guard (automations.spec.ts)
  `REQ-AUTD-002 scenario 4` composes a `generateDocument` action, which is a
  Docudesk integration. Without Docudesk installed,
  `AutomationEditDialog.actionBlockedReason()` returns "Docudesk is not
  installed — document-generation actions are unavailable." and the template
  renders that blocked NoteCard via `v-if`, with the whole config sub-form
  behind the `v-else-if` — so the template/output fields genuinely do not
  exist and there is nothing to drive. Correct product behaviour (the spec's
  own "disabled without Docudesk" scenario is `@e2e exclude`d to Vitest), but
  the test never declared the dependency and so hard-failed instead of
  reporting its precondition. Every Docudesk test in
  spec-coverage/docudesk-document-templates.spec.ts is unconditionally
  `test.skip`d for the same reason; this was the odd one out.

  Added `docudeskIsAvailable()` following the existing
  `automationSchemaIsUsable()` pattern, and deliberately mirroring
  `useAppStatus`'s own probe semantics: ONLY 404/501 counts as absent. Any
  other status — including 5xx — means the app answered, so a
  broken-but-installed Docudesk still runs the test and fails loudly rather
  than being skipped into silence. A transport error also returns true for the
  same reason. Verified: skips on this instance (docudesk absent, `/apps/
  docudesk/api` -> 404), and the sibling "blocked on a schedule trigger" test
  is NOT gated and still passes, since its matrix reason fires first.

  Also corrected the inline comment claiming the template picker "degrades to
  a free-text field when Docudesk ... is absent" — absence blocks the action
  outright; the free-text degradation is the installed-but-no-templates case.

nc-vue 2.1.0-vue3.6 -> 2.1.0-vue3.7
  Carries the nested-modal stacking fix. Independently verified in the
  installed tarball rather than trusting the version string:
  `z-index: 10005 !important` 0 occurrences in dist/nextcloud-vue.css, `10005`
  present exactly once (the baseline, no `!important`), `cn-dialog--nested` 0
  occurrences, `dist/esm/utils/modalStack.js` present and referenced from the
  ESM barrel.

Spec drift, application-creation-wizard REQ-OBWIZ-005/006
  Both said "the wizard's Next / Create button is disabled until … corrected".
  That has never been true on any branch: `CnWizardDialog` binds its primary
  action to `:disabled="loading"` only and exposes no validity input, so no
  consumer can disable it. It is a validate-on-advance wizard — `validate()`
  runs on click, blocks the transition, and renders the reason. Amended to
  describe that guarantee (refuse to advance AND explain why), which is what
  the e2e specs now assert and is strictly stronger than a disabled-attribute
  proxy. "Disable the primary action until the step is valid" is recorded in
  the requirement as a future `CnWizardDialog` enhancement. Also corrected the
  duplicate-slug scenario, which said one row is flagged — both are.

Spec drift, openbuild-template-catalogue REQ-OBTC-003/008 + the
github-shop-catalogue change
  The canonical spec described `/templates` as a listing of local
  `ApplicationTemplate`s with a category filter, a free-text search and a "Use
  this template" action per card. The shipped `TemplateGallery.vue` contains no
  reference to `application-template` at all: it is a GitHub-backed App store
  with a Templates/Blocks tablist whose card action reads "Install". Identical
  on origin/development, so the old text described a surface that exists on
  neither branch. Rewritten to what ships, including the non-installable-card
  and GitHub-unavailable states. Scenario slugs kept byte-identical so gate-19
  `@e2e` traceability is unaffected.

  The unarchived github-shop-catalogue change was worse: it specified Local /
  Registry / GitHub tabs and promised "the Local and Registry surfaces SHALL be
  unchanged … the GitHub tab is additive, so the page never regresses on the
  existing two sources". The Local source was REPLACED, and there is no
  Registry tab or `storeConfigured` reference in the view. Restated as shipped.

  Recorded explicitly in both places: seeded `ApplicationTemplate`s are NOT
  gone — `Repair\SeedApplicationTemplates` still seeds them, they are still
  authored from an Application's detail page, and
  `POST /api/applications/from-template/{templateSlug}` still clones them. They
  are simply no longer surfaced by this gallery. Whether they should be again is
  flagged as a product question, not silently spec'd either way.
…the modal defect

The nested-modal fix landed the copilot-wizard-generate pair as-is. The two
copilot-panel failures turned out NOT to be the stacking defect at all: the
copilot panel is a side panel (`[data-testid="copilot-panel"]`), not a modal.
The stacking defect merely failed both tests early enough to hide two stale
assertions underneath. On vue3.7 they now reach those assertions and fail there
instead. Both are test bugs; the feature works.

"Approving a proposal applies it to the open app"
  `page.locator('text=e2e-suppliers')` can never match. A page-list row renders
  its id and route as `<input :value="…">` (PageListEditor.vue) and Playwright's
  `text=` engine matches TEXT NODES only — an input's value is a property, never
  a text node. The same trap the sibling form-editor-logic spec documents for its
  own row locator. Verified the feature works: `/api/copilot/execute` returns 200
  and hello-world's served manifest carries
  `{"id":"e2e-suppliers","route":"/e2e-suppliers","type":"index",…}`. Now asserts
  the persisted manifest first (the strongest form, and what the test's own
  comment said it wanted) and then reads the reloaded designer's live input
  values.

"Discarding a proposal changes nothing"
  `toHaveCount(0)` asserted that the proposal card disappears. It does not, and
  never has on either line — src/components/copilot/ is byte-identical to
  origin/development. Proposals render from the chat transcript
  (`messages[].plan`), while `discard()` clears the composable's `plan` and the
  panel's `pendingMessageId`: the turn stays in the log — which agent-workspace
  relies on ("A discarded proposal is still logged") — and stops being
  actionable. The spec requires "no execute request is sent and the app's
  manifest is unchanged", nothing about the card vanishing. Now asserts exactly
  that, byte-comparing the served manifest before and after (verified stable
  across calls, so the comparison is not flaky) plus that Approve is disabled.
  The manifest-unchanged half was previously not asserted at all.

Also both specs now take their absolute base URL from E2E_BASE_URL rather than
building one, per tests/e2e/support/baseUrl.ts.

Step1Basics.vue: replaced the KNOWN DEFECT block with the resolution and the
actual root cause (our own CnEditDataModal.vue shipped an unscoped
`.modal-mask.dialog__modal { z-index: 10005 !important }` that rollup folded into
the global dist stylesheet, pinning every NcDialog mask in every consuming app to
one layer and leaving mount order to break the tie — which is why the earlier
<Teleport to="body"> attempt changed nothing), plus the >= 2.1.0-vue3.7 floor.
…nt walk

All 7 remaining e2e failures (component-blocks x2, form-editor-logic x5)
threw the same error:

    page designer not mounted — cannot read the staged manifest

The designer WAS mounted. Both specs reached it through
`document.querySelector('.page-designer').__vue__`, the Vue 2 element ->
component back-reference. Vue 3 never sets `__vue__`, so the probe's own
guard fired and blamed the product for a stale test helper.

Measured on the live instance before changing anything: `.page-designer`,
`.page-designer-host` and `.form-page-editor` were all present and
visible, and the failure screenshot shows the FormPageEditor holding
exactly the steps the scenario had just authored. Only the handle was
missing.

The Vue 3 devtools handles are not usable here either — `__vnode` and
`__vueParentComponent` are stamped only when
`__DEV__ || __FEATURE_PROD_DEVTOOLS__`, and this app bundles with
`__VUE_PROD_DEVTOOLS__ = false` (verified in the emitted bundle and at
runtime). `container.__vue_app__` and `container._vnode` ARE assigned
unconditionally by `createApp().mount()` and the renderer, so the new
helper walks the component tree from there and reads `manifest` off the
`<PageDesigner>` instance — the same prop the old `__vue__` read
observed, i.e. the in-editor buffer before any save. Assertions are
unchanged.

Extracted to tests/e2e/support/stagedManifest.ts so both specs share one
probe, and a future rename reports the component names it DID find
instead of a phantom "not mounted".

form-editor-logic.spec.ts  5 failed -> 5 passed
component-blocks.spec.ts   2 failed -> 6 passed
`GET /apps/docudesk/api/templates` answered `notConfigured: true` on this
instance: Docudesk's own initializer had set `templateVersion_register` /
`templateVersion_schema` but left `template_register` / `template_schema`
EMPTY, so its template picker was permanently empty and every
document-attachment scenario had nothing to pick.

Configure it and seed the two fixture templates the spec's scenarios name
(Bevestigingsbrief, Besluit) from globalSetup, through Docudesk's own
public API — `POST /api/settings` (which allowlists both keys) and
`POST /api/templates` — so it is repo state that any fresh container and
CI get, not a value set by hand on one machine. Idempotent; skips with a
log when Docudesk is absent; OPENBUILD_DOCUDESK_SEED=0 opts out.

Verified by deleting the templates, blanking both config keys, and
re-running: globalSetup restored register=237/schema=453 and recreated
both templates.

Basic auth is sent preemptively — `httpCredentials` only replays after a
401 challenge, and Nextcloud answers an OCS-APIRequest call with a bare
401 and no WWW-Authenticate, so the retry never fires (measured: every
request 401'd).
…lder e2e

Two defects found by writing the docudesk-document-templates e2e
scenarios for real instead of leaving them as placeholder stubs.

1. FIXED — the runtime document-actions surface was dead for every app.
   `DocumentActions` reads its attachments from an `attachments` prop
   that NOTHING supplies: the widget is resolved through
   CnPageRenderer's slot-override path, which hands a registry component
   the detail surface's own props and has no way to know it wants a
   slice of the manifest. It now reads `runtime.documents[]` from the
   `cnManifest` injection — the same pattern the sibling TrackLinkAction
   already uses for `runtime.externalForms[]` — with the prop kept as an
   explicit override. `docudeskAvailable` likewise defaulted to `true`,
   so an instance WITHOUT Docudesk would still have issued requests to
   /apps/docudesk (REQ-DDT-005 forbids exactly that); it now defaults to
   "probe the instance" via useAppStatus, and `onGenerate` resolves the
   capability before generating rather than reading a possibly-unresolved
   flag. Covered by two new vitest cases.

2. REPORTED, not papered over — the same surface also compares
   `object['@self'].schema` (which OpenRegister returns as the NUMERIC
   schema id, measured: `{"register":"15","schema":"21"}` for a
   hello-message object) against `documents[].schema` (a SLUG,
   "hello-message", which is what the attach dialog writes and what
   REQ-DDT-001 specifies). Those can never match, so the surface renders
   nothing for any real object. The five runtime scenarios stay skipped
   with THAT recorded as their reason, replacing the stale "#41 builder
   UI not functional" text, because un-skipping them would only report a
   product defect this commit does not fix.

The five BUILDER scenarios are now real tests against the real surfaces
(attach writes the manifest entry with the template UUID + name snapshot;
preview calls the pinned route and presents without committing; editing a
deleted template 404s and warns; the docudesk dependency is added exactly
once and stays once; the designer degrades with the Add action disabled
while an existing attachment stays detachable). They replace bodies that
were `goto('/applications')` + `expect(main).toBeVisible()` under titles
claiming to drive the Documents section — removing `.skip` alone would
have produced twelve tests asserting nothing.

Harness notes worth keeping: NcSelect option accessible names carry a
seam ("Bevestigi ngsbrief"), so options are matched on text; the dialog
fetches its template list from the `open` watcher, so the list is awaited
rather than raced; and `detach()` goes through `window.confirm`, which
Playwright auto-dismisses without an explicit handler.
feat(vue3): migrate OpenBuild to Vue 3 + @conduction/nextcloud-vue 2.1.0-vue3.7
…urface

The file sat behind a blanket quarantine citing Conduction/openbuild#41
('openbuild admin UI not functional in this build'). That reason is stale --
the detail page, its Manifest / Version history / Diff sidebar tabs, the
builder host and the per-app Schemas route all render live.

Un-skipping alone would have been worse than the skip: the bodies asserted
almost nothing (expect(page.locator('main')).toBeVisible() standing in for
'the seeded index page renders'), and several wrapped their only real
assertion in 'if (await x.count() > 0)' so they passed having asserted
nothing. Every body is rewritten against the requirement text.

Adds tests/e2e/support/componentTree.ts: the negative half of REQ-OBR-006a
('the nested CnAppRoot is NOT mounted on the schemas route') is not
expressible in DOM selectors -- not-mounted and mounted-but-loading look
identical from outside. Walks the Vue 3 tree from container.__vue_app__, the
only handle that survives a production build (__VUE_PROD_DEVTOOLS__ = false).
…multi-boot scenarios

- REQ-OBR-002 asserted the outer nav via '#app-navigation-vue, .app-navigation';
  nc-vue's CnAppNav stamps data-testid="cn-nav" and neither of those exists.
- REQ-OBR-007a is a PRODUCT GAP, not a test problem: src/manifest.json menu[]
  has five entries and none routes to /builder/:slug/schemas; BuilderHost.vue
  renders no navigation; menu-layout.json is empty; and the l10n key the
  requirement mandates (openbuild.builder.menu.schemas) is absent from en/nl.
  The ROUTE works and is covered by the two REQ-OBR-006a tests. Skipped with
  that evidence rather than left red for a feature that was never built.
- Five scenarios boot the SPA twice or poll the API; the 30s project default is
  sized for single-navigation tests. Per-test budgets added with the reason
  written down; every assertion inside keeps its own tight timeout.
Un-quarantined (reason was stale or never applied):
- builder-host: /builder/hello-world mounts the nested CnAppRoot and renders
  the seeded index; bodies were already real, so they run as written.
- applicationCard: only ever read the applications index, so 'builder host
  blank' never applied. Tests 3 and 4 claimed to target hello-world but
  selected '[data-slug="hello-world"], .ob-app-card' + .first() -- no
  data-slug attribute is rendered, so the alternation always collapsed to
  'whatever card is first'. Retargeted via the slug chip. Test 4 asserted only
  length>0 and not-/undefined/, which the string 'Version null' would satisfy;
  now a format assertion.
- promoteDestructive static block: it is an fs.stat, opens no browser.

Reasons corrected, still skipped (each names its real blocker):
- versionRouting x3, schema-access-scopes-rbac: need ApplicationVersion chains
  and Newman-provisioned users this suite does not create -- AND target
  selectors absent from src/ (.ob-schema-designer, [data-app-version],
  .note-stub), so seeding alone would leave them green-but-dead.
- rbac-403: needs rbac-outsider; its deny assertion also counts
  [data-app-slug]/[data-testid=builder-host-<slug>], neither of which src/
  emits, so it would pass vacuously.
- version-rollback: drives a one-big-textarea editor that no longer exists.
- promoteDestructive live block: TODO_PROMOTE_BUTTON_SELECTOR was never wired.

Records a product defect found while doing this: ApplicationCard's status badge
and version chip are dead -- /api/applications returns productionVersion as a
UUID string, the card requires an object, so every card reads Draft / 'Version
-' regardless of state.
… for 3 more

iconUpload was quarantined for '#41 no detail page renders'. The detail page
renders; the file was written against a surface that never existed:
  - it looked for .ob-icon-preview img / [data-testid=icon-preview] img, neither
    of which is in src/. The real markup is src/dialogs/IconUploadSection.vue
    (.ob-icon-section__file-input / __preview-img / __remove-btn / __error),
    mounted by ApplicationIconTab.vue;
  - it never opened the sidebar, so the Icons tab could not be in the DOM, so
    its own 'if (!iconUiExists) test.skip(...)' hatch fired every single run;
  - two of its three tests were permanent no-op test.skip('pending deploy')
    bodies;
  - it clicked [data-slug=hello-world], an attribute ApplicationCard.vue never
    renders, silently falling back to an arbitrary card.
Rewritten as three asserting tests: the tab mounts both variants with an
SVG-only picker; a non-SVG pick is rejected inline AND sends no POST; an SVG
upload persists (read back independently) and previews from the icon endpoint.

Reasons corrected, still skipped:
  - page-designer: navigates /applications/<slug>/design (no such route) and
    asserts .application-editor__* (no such class); its 'Design tab is default'
    assertion encodes the tab pair the app never shipped.
  - export-zip: polls [data-test=export-job-row] (absent) and uses the slug as
    the detail route param, which takes the object id.
  - application-editor: SUPERSEDED -- its round-trip is now driven against the
    real sidebar editor by REQ-OBR-005 in openbuild-runtime.spec.ts.
1) 'navigates to a hello-message detail page' timed out clicking a message.
   The nested CnAppRoot mounts with appId 'openbuild-hello-world', so nc-vue's
   first-visit support dialog had never been seen for THAT app id and opened
   over the virtual app. Measured: 55 click retries, every one reporting
   'cn-support-dialog subtree intercepts pointer events'. It is a real modal
   (aria-modal + backdrop), so swallowing the click is correct behaviour --
   the shared dismissFirstVisitOverlays() helper is the right fix, not a
   library change.

2) 'navigates to the form page' looked for input[name="title"] /
   [data-field="title"] input. nc-vue's CnFormPage wraps each field in
   [data-field-key="<key>"] and names the control field-<key>, so neither
   form was ever emitted. Retargeted, and strengthened rather than merely
   made to find something: it now asserts the form page rendered, that title
   exposes an EDITABLE control, and that the second declared field (body)
   rendered too -- proving the declared form rendered, not one stray input.
…r the detail-overview UI blocks

- 'Application insights — endpoint surface' was quarantined for a UI reason it
  could not have been affected by: both tests are request-only contract checks
  (400 on an invalid window enum with the spec-defined body; 404 on an unknown
  appUuid, specifically without the public,max-age=60 header a 200 carries).
- The two UI blocks keep their skip but name the real blockers: they opt out of
  the shared storageState and form-log-in per test, which playwright.config.ts
  documents as the thing that trips Nextcloud's brute-force throttle and drops
  every later spec onto /login; and their pill/deep-link scenarios need a
  development->staging->production chain that this instance does not have, so
  they would hit their own pillCount<2 guards regardless.
- Recorded that 14.5 ends on 'void req' under 'assertion is best-effort' — it
  captures the request it exists to check and discards it, so un-skipping as
  written would report deep-link coverage while asserting nothing.
- Fixed pre-existing operator-linebreak lint in the same file.
Menu icons across the fleet had drifted into meaninglessness: a scan of 21
manifest-shipping apps found 120 distinct icons for 262 distinct labels, with
one glyph standing for as many as 18 unrelated concepts
(`icon-category-monitoring`) and the same concept drawn differently per app —
Store was `icon-category-integration` in one app and
`icon-category-organization` in another.

Moves this app's menu onto the shared vocabulary: MDI PascalCase names, one
concept to one icon. Tier A entries (Dashboard, Documentation, Settings, Store,
Features & roadmap) now match every other Conduction app, which is the whole
point — a glyph should mean the same thing wherever a user meets it.

Two defect classes are fixed along the way:

* Icon names that do not exist in vue-material-design-icons at all. They could
  never resolve — rendering a help-circle at best, nothing at all in the
  navigation.
* Menu entries that rendered with NO icon, because CnAppNav resolves an MDI name
  only through the registry `registerIcons()` populates, with no fallback for a
  name the app never registered. Apps that relied on legacy `icon-*` classes
  registered nothing at all and were fine until the first MDI name appeared.

src/icons.js is generated from the app's own manifests and register files, so
every name the app references is registered and the migration stands on its own
against the CURRENTLY RELEASED @conduction/nextcloud-vue — it does not wait on
the library-side vocabulary (ConductionNL/nextcloud-vue#563).

Verified: 0 menu entries render without an icon (was 51 fleet-wide), every icon
import resolves against the app's own node_modules, and hydra's gate-60
icon-vocabulary check passes with no failures or warnings.

Spec: ADR-077 (ConductionNL/hydra#408).

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
… normalised schema key

Two product defects, both 100% dead surfaces, both found by driving the e2e.

1) ApplicationCard's status badge and version chip were inert. GET
   /api/applications returns productionVersion as a bare UUID STRING, but the
   card's productionVersion computed bailed unless typeof pv === 'object'. So
   statusKey always fell back to 'draft' and productionSemver to '-' for EVERY
   card, whatever the app's real state -- hello-world rendered 'Draft /
   Version -' while its production ApplicationVersion is
   {status: 'published', semver: '1.0.0'}. REQ-OBR-007b's 'newly published
   Application shows published badge' was unsatisfiable from the list.

   Fixed server-side: attachProductionVersionDetail() resolves the UUIDs in ONE
   extra query (all applicationVersion rows fetched once and indexed, mirroring
   ApplicationVersionsController::index) and projects
   {uuid, slug, name, semver, status} as productionVersionDetail. Only those
   fields -- the full row carries the whole manifest blob, which would bloat a
   list response for data no card reads. productionVersion is deliberately left
   a UUID string: ApplicationDetailHeader, ApplicationDetailDashboard,
   ApplicationVersionsTab, promoteVersionDefaults and useApplicationVersion all
   depend on that shape. The resolve fails soft AND logs, because a silent catch
   would recreate the exact defect being fixed.

2) DocumentActions and TrackLinkAction could never match a manifest entry.
   They compared the object's @self envelope against manifest slugs, but @self
   names register/schema by NUMERIC ID (measured: {'register':'15','schema':'21'})
   while runtime.documents[].schema is 'hello-message' and
   runtime.externalForms[] carries slugs. '21' === 'hello-message' is never
   true, so both widgets rendered nothing for every object, on every app --
   invisibly, since an empty filter result is exactly what they render blank by
   design.

   Fixed by comparing on a normalised key set (src/utils/objectSchemaKeys.js):
   CnDetailPage provides cnObjectContext, whose register/schema come from the
   page's manifest config -- the slugs, the same vocabulary the entries use.
   The @self ids stay in the set so a host mounting a widget without detail-page
   context still matches. What the attach dialogs WRITE is unchanged: REQ-DDT-001
   specifies the slug, so the numeric side is the wrong one and is what gets
   normalised away.

E2E for (1) is now data-driven rather than vocabulary-only: applicationCard and
REQ-OBR-007b compare the rendered badge and chip against the API's resolved
productionVersionDetail, so a regression cannot pass as 'one of the three
words'.
The requirement says BuilderHost SHALL surface a Schemas entry in the outer
shell while in a virtual app's builder context, routing to
/builder/{slug}/schemas via the key openbuild.builder.menu.schemas. The ROUTE
has worked all along; only the affordance was missing, so the designer was
reachable by typing a URL and from a deep link PageDesignerHost builds by hand,
but never from the navigation.

Why an href and not a route name: CnAppNav.itemTo() builds {name, query} only --
it has no params support -- and /builder/:slug/schemas is parameterised by
definition, so no STATIC manifest entry could ever address it. item.action is a
fixed library enum, not a callback. That leaves item.href, which itemHref()
supports and NcAppNavigationItem renders as a real anchor. Trade-off stated in
the source rather than hidden: an href is a full page load, not a router.push.
Adding params support to itemTo() would let this become a route entry and is the
better long-term fix -- it belongs in nc-vue.

main.js now wraps the merged manifest in reactive() so the menu edit is
observed; CnAppRoot documents that it hands CnAppNav the live manifest BY
IDENTITY precisely so async menu updates re-render. BuilderHost publishes the
entry on mount and on slug change, and removes it on unmount -- without that
teardown the entry would linger on every other page still pointing at the last
app opened, which is worse than no affordance.

Adds both mandated l10n keys (en: Schemas, nl: Schema's) as a two-line diff.
Un-skips the REQ-OBR-007a e2e as two real tests (the entry exists, is
app-scoped, and lands on SchemaDesigner; and it is absent outside the builder
context), plus 6 unit tests for the menu module.

Also corrects the Docudesk note: the id-vs-slug defect it described as blocking
is fixed, but those 7 bodies are stubs (goto + main-is-visible), so they stay
skipped rather than report coverage they do not have.
The first pass migrated menus — the cross-app chrome users meet in every app.
Everything else kept its legacy `icon-*` classes: page and tab icons, widget
headers, and the `actions[]` / `headerActions[]` entries.

Those render through the same CnIcon registry and carry the same hazard: a
legacy name with no CSS_ICON_TO_MDI bridge entry falls through to the raw
Nextcloud class, and on NC34+ light themes several of those ship a baked white
background-image — an invisible glyph, wherever it appears, not just in the nav.

Action icons name a VERB rather than a domain noun, so the vocabulary gained an
actions family (view / create / edit / delete / run / test / publish / upload /
download / refresh / …). Where a label named no concept the conversion used the
CSS bridge target, which is exactly the glyph already on screen — those entries
change dialect, not appearance.

src/icons.js is regenerated so every name the manifests reference is registered.

Verified: gate-60 icon-vocabulary clean (0 failures, 0 warnings) with the gate
now walking EVERY icon field rather than menu entries only; every icon import
resolves; eslint clean.

Spec: ADR-077.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…he cards actually get their data

My first attempt fixed the wrong endpoint. attachProductionVersionDetail() on
ApplicationsController::listMine was correct and verified live, but the cards
never see it: the virtual-apps index is a manifest type:index page over
register:openbuild/schema:application, so CnIndexPage fetches its rows from
OpenRegister's GENERIC objects endpoint and never calls /api/applications at
all. Caught by driving it -- the two data-driven applicationCard assertions
failed in 4.4s, far too fast to be a timeout.

Adds src/store/productionVersions.js: a de-duplicated, reactive uuid -> version
index that ApplicationCard consults for a UUID-string productionVersion. Two
alternatives were measured and rejected, and the reasons are in the file:
one /applications/{slug}/versions call per card is N requests from a grid; one
bulk OR call for applicationVersion returns 262 rows on this instance, each
carrying its whole manifest blob, for five scalar fields per card. Reading
/api/applications is ONE request, already RBAC-filtered, and carries no
manifests -- which is what makes the controller change load-bearing rather than
dead code.

The store never throws (an empty map means cards fall back to the placeholder,
the pre-existing behaviour) but it does WARN, because a silent catch here would
recreate the exact defect being fixed.

11 new unit tests: the index keys by UUID, N cards issue ONE request, it reads
the RBAC-filtered endpoint and not the bulk OR list, it accepts both the bare
array and {results:[...]}, and a failed lookup leaves the map empty, warns, and
does not throw.
…ecture; revert the nav entry

Driving the suite showed REQ-OBR-002/003/006a and REQ-OBR-007a all rest on a
premise the product deliberately abandoned. appinfo/routes.php maps the bare
/builder/{slug} to dashboard#builder -- a STANDALONE page booting src/builder.js
(its own webpack entry). That file says why outright: 'deliberately NOT the
OpenBuild SPA: rendering the app inside OpenBuild's shell nests one NcContent in
another (double chrome) and, worse, shares OpenBuild's router -- which has none
of the app's page routes'. src/views/BuilderHost.vue only mounts for sub-paths
falling to the SPA catch-all, never for the bare runtime route.

That is why data-testid=openbuild-builder-host was genuinely absent while the
app rendered: the failed run's snapshot shows the virtual app's Messages index
with all three seeded rows and no builder-host wrapper. It was never a timeout.

So the REQ-OBR-007a nav entry is reverted. I built it, and it could not trigger:
an entry published from BuilderHost cannot appear on the only route the
requirement is about. Shipping a feature that cannot fire is worse than not
shipping it. The findings from the attempt are recorded at the skip
(CnAppNav.itemTo has no params support; item.action is a fixed enum; the l10n
key is absent), so whoever designs the real affordance -- probably on the
standalone shell's own menu -- starts with them.

Two further real defects found and recorded rather than papered over:
- The raw JSON manifest editor is empty for EVERY app and its Save writes to a
  dead field. Evidence: the OR application row has no USAGE:
  /usr/bin/manifest export [-|URL|FILENAME]
  /usr/bin/manifest import -|URL|FILENAME key at all;
  under ADR-002 the manifest lives on the ApplicationVersion, which
  ApplicationsController itself notes 'returns null for every app'. The
  validation half of REQ-OBR-005 is unaffected and still passes.
- The version-history empty state has no fixture: every Application has at least
  one version because the wizard provisions production in the same transaction.
  Not weakened into 'renders something' -- the empty state IS the scenario.

The Docudesk id-vs-slug fix and its regression tests are untouched.
…lookup

The card badge/chip assertions failed while the product was correct. The card
paints its placeholder first and swaps to the real status when the shared
production-version lookup resolves; a one-shot textContent() read caught the
placeholder. Proof it was the assertion and not the app: the failure snapshot
Playwright captured shows the hello-world card reading

  "Hello World Virtual Published ... Version 1.0.0 Owner /hello-world"

i.e. exactly what the test demanded, a moment after it looked.

Switched to expect(locator).toHaveText(...), which polls. Nothing is weakened --
the assertions still pin the badge to the production version's REAL status and
the chip to its REAL semver, they just wait for the settled state. Applied to
both applicationCard assertions and to REQ-OBR-007b's two.
…EQ-OBR-008a/009a

The Version history panel renders its EMPTY state -- 'No versions yet -- create
a draft to start a new version.' -- for an app that demonstrably has one:

  GET /api/applications/hello-world/versions
  -> [{name: '1.0.0', slug: 'production', semver: '1.0.0', status: 'published'}]

while .version-history__row resolves to 0 elements across 44 polls over 20s.

Evidenced from the failure snapshot, not inferred: the tab mounts, its heading
and empty-state paragraph are both present, so this is VersionHistory.vue not
receiving/keeping rows -- not a missing tab and not a timeout. The sibling Diff
tab reads obApp.slug from the same mixin and passes, which rules out the obvious
cause. Both REQ-OBR-009a scenarios are blocked behind it: rollback and its
confirmation modal are per-row actions in a panel that renders no rows.

Skipped with that evidence rather than left red, and the assertions stay at this
path for whoever fixes the panel.
Same defect class as the ApplicationCard fix, second location. Spec C moved
status off the Application and onto the ApplicationVersion, but
ApplicationDetailHeader.applicationStatus still read application.status -- the
legacy field. Once the card was fixed the two disagreed for the same app:
hello-world's card read 'Published' (its production version) while the detail
header read 'Draft'. REQ-OBR-007b requires the editor header to carry 'the same
badge' as the list row, so this was a real inconsistency, surfaced by the
REQ-OBR-007b test rather than papered over by relaxing it.

Reads the resolved production version's status first, keeping the
Application-level value as the fallback for legacy rows predating the versioned
model. The header already resolves productionVersion from its own fetched
versions list, so this adds no request.
…verage

test(e2e): un-quarantine runtime coverage — real assertions, honest skips
…d (4/4) (#59)

Both were quarantined on #41. Re-measured against the Vue 3 build on the
disposable :8099 instance; neither was blocked by anything #41 covered.

applicationCard — green 4/4 completely untouched. It only ever reads the
Applications INDEX, which #41's blockers (builder host / detail / editor /
version pages) never affected. The quarantine was applied file-by-file across
the whole e2e directory rather than to the surfaces actually broken.

builder-undo-redo — was 0/8; now 12 pass, 1 self-skips. Three harness defects,
each measured rather than guessed:

  - the first-open support dialog mounts a `.modal-mask` over the designer, and
    `.page-list-editor__add` sits under it. elementFromPoint over the Add button
    returned `<h2 class="dialog__name">`, so every click retried until timeout.
    Fixed with the shared suppressSupportDialog() (+ dismissOverlays()).
  - REQ-BUR-005 opened the Schemas page WITHOUT `?_version=production`, so the
    designer fell back to the legacy `openbuild-{slug}` register a
    wizard-created app does not have. The schema was created but never
    attached ("Schema created, but could not be attached to register
    openbuild-pw-undo-redo", with a Nextcloud login page as the response body),
    the field editor never rendered, and the Add-field click waited out the
    timeout. The real in-app nav carries the marker via buildVersionedRoute().
  - it then drove Save with the added field still unnamed. Save is gated on
    fieldNamesUnique, which rejects any unnamed property — the button was
    correctly disabled and the click timed out. The field is now named, with a
    run-unique suffix: this test saves, so a fixed name made the NEXT run stage
    a duplicate that the same gate correctly refused (passed in isolation,
    failed on re-run).

Also made the schema row lookup `.first()`: the designer namespaces a created
schema to `{app}-{slug}`, which still contains the bare slug, so every previous
run's schema matched the filter too.

Verified: 12 passed / 1 skipped, twice consecutively, on Vue 3 at :8099.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
… under Vue 3 (#60)

`ApplicationDetailActions.vue` registered the export dialog as

    const ExportDialog = () => import('../dialogs/ExportDialog.vue')

which is Vue 2's async-component form. Vue 3 accepts a plain function as a
FUNCTIONAL component, so this registered a component whose render function
returns a Promise. It rendered nothing — no error, no warning, no failed
request. Clicking Export set `exportOpen = true` and the `v-if` passed; the
dialog simply never appeared. Export was unreachable from the app detail page
for every user, and `ExportDialog.vue`, `ExportJobsTab.vue` and the whole ZIP
pipeline behind it were dead UI.

It was the last bare `() => import(…)` component registration in src/ — every
sibling dialog in this file is imported eagerly — so the Vue 3 migration missed
exactly one call site. Fixed with `defineAsyncComponent()`, keeping the lazy
load the original intended.

Found by driving the quarantined export-zip e2e suite rather than reading it:
the suite blamed openbuild#41, and the assertion that actually failed
(`combobox[name=/target/i]` not found) looked like ordinary selector drift.

tests/e2e/export-zip.spec.ts gains an ACTIVE regression guard asserting the
dialog mounts with its Version/Target/License pickers and a reachable
"Start export" — the exact thing that broke. Its comboboxes carry no accessible
name in this @nextcloud/vue version, so the guard matches the `<label for>`
text instead of a role+name lookup.

The original ZIP round-trip block stays skipped: it polls a
`[data-test="export-job-row"]` attribute that exists nowhere in src/ and needs a
background job to reach `succeeded`. That reason was already documented in the
file and is unrelated to this defect.

Verified: the dialog opens live on the Vue 3 instance (3 pickers, "Start
export"); guard green; component-blocks + builder-undo-redo + applicationCard
re-run green (18 passed) against the rebuilt bundle.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…(8 pass, 3 honest skips) (#61)

Both blamed openbuild#41. Neither was blocked by it.

applicationDetailOverview — the KPI, activity and structural-widget rows were
addressed as `.ob-detail-header__*`. Those rows are rendered by
ApplicationDetailDashboard.vue under an `ob-detail-dashboard__` prefix; only the
hero, controls and version pills belong to ApplicationDetailHeader.vue. The UI
was built and rendering the whole time — the selectors named a component that
does not own it, so three scenarios could never match. The window toggle is a
`role="group"` of NcButtons marked with `aria-pressed`, not a `__window-btn`
with an `--active` class.

Also fixed here:
  - the block form-logged-in per test instead of inheriting globalSetup's
    storageState. Nextcloud's brute-force throttle fires after a handful of
    near-simultaneous form logins from one IP, which is why the rest of the
    suite stopped doing that; the now-dead loginAs helper is removed.
  - REQ-OBADO-003 captured the insights re-fetch it exists to verify and then
    threw it away (`void req`, "best-effort"). That is the green-but-dead shape:
    it would have reported coverage while proving only that a button
    highlights. The request IS the requirement, so it is now asserted.
  - the activity scenario matched `.ob-detail-dashboard__activity`, the WRAPPER
    that contains the empty state, which made its own "never both at once"
    assertion fail against a correct UI. It matches the chart now.

The three remaining skips are honest: the pill-strip and promote-affordance
scenarios need a development -> staging -> production chain, and hello-world has
one version here, so their own `pillCount < 2` guards fire.

page-designer — rewritten onto the real designer. It navigated to
`/applications/hello-world/design`, which is not a route (the designer is
`/builder/:slug/pages`), and asserted `application-editor__*` classes that exist
nowhere in src/. The add-page -> save -> render journey now drives
PageDesignerHost, verifies the save reached the STORED manifest rather than the
editor buffer, and asserts the new route renders in the builder host. It also
gains a real REQ-OBPD-002 assertion: Confirm stays disabled until a page type is
chosen from the closed enum.

Its second scenario ("edits survive a Design <-> Raw JSON tab switch") is
deliberately NOT rewritten: that tab pair is spec drift — the page designer
never shipped a Design/Raw-JSON toggle, and the raw manifest editor is a sidebar
tab on the app DETAIL page. Rewriting it would invent coverage for a surface
that does not exist; the drift is recorded in the file header.

Verified: 8 passed / 3 skipped on the Vue 3 instance, twice consecutively.
Both specs seed and reset their own fixtures, so re-runs are idempotent.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…app (#63)

`openSaveAsTemplate()` read the manifest off the Application record:

    this.saveTemplateManifest = this.obApp.manifest
        || (this.obApp.currentVersion && this.obApp.currentVersion.manifest)
        || {}

An Application carries NEITHER field. The manifest lives on the
ApplicationVersion, and `GET /api/applications` returns no `manifest` and no
`currentVersion` for any app — the seeded hello-world included, so this was
never app-specific. The capture therefore always fell through to `{}`,
SaveAsTemplateDialog validated an empty object, and the dialog opened showing

    The captured manifest is invalid and cannot be published:
    /version must be a string /menu must be an array /pages must be an array

with Save permanently disabled. Saving an app as a template was impossible for
EVERY application. Now resolved through the endpoint that owns the resolution,
`GET /api/applications/{slug}/manifest`, with the old fields kept as fallbacks.

Found the same way as the Export defect in #60: by driving a quarantined suite
instead of reading it.

Test changes:

save-as-template — un-quarantined and NARROWED. Its capture half is live and is
now asserted end to end (dialog → ApplicationTemplate record in OpenRegister,
org-local, carrying the source manifest and no object rows), with a per-run
baseline reset so the slug-collision guard cannot block a re-run. Its clone
round-trip is NOT rewritten: nothing in src/ calls
`POST /api/applications/from-template/{templateSlug}` any more, so an org-local
template cannot be cloned from the UI at all. Its "viewer" test was renamed and
trimmed — it ran on the ADMIN session (so it proved scoping, not rights-gating)
and its seeded-card assertions passed against an empty locator, since the
GitHub-only gallery renders no seeded cards.

template-gallery — un-quarantined and rewritten. It expected four locally
seeded cards and a clone flow; commit f8e0eec ("keep GitHub-only") made the
Templates tab a server-backed GitHub search, so the two cards a run finds are
repos, not templates. My earlier triage called this a fixture gap — it was not:
all four fixtures are seeded and correct. It now covers the tab pair, asserts
the search is forwarded to OpenBuild's own endpoint (never github.com directly),
and tolerates GitHub being unreachable or rate-limiting.

⚠️ Recorded in both files: the four seeded ApplicationTemplates and the
from-template endpoint are now an ORPHANED CAPABILITY — seeded on every install,
routed, and unreachable from the UI. That is a product decision, not a test fix.

builder-undo-redo — REQ-BUR-005 baselined its field-row count the instant the
detail mounted, catching a partially-painted list; it saves a property per run,
so the schema grows and the race got easier to hit. It now polls until two
consecutive reads agree.

Verified on the Vue 3 instance: 13 passed / 5 skipped across save-as-template,
template-gallery, page-designer, applicationDetailOverview and export-zip;
builder-undo-redo + component-blocks green; REQ-BUR-005 green twice in a row.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…onnectors[] binding

v1 emits four file kinds; everything that makes an app WORK — the shared data
registers it binds, the OpenConnector configs feeding them, its automations and
its skills — is left behind. spectr is the sharp case: its manifest references
spectr-live 109 times and it has no meaningful per-app companion schemas, so v1
serialises it to a manifest plus ZERO schemas/ entries and reports success.

Connectors are bound EXPLICITLY (21-connectors.json, ADR-037 fragment mirroring
20-data-registers.json) rather than inferred from register targets. Inference
needed no schema change but would have made an app's published surface depend on
which OTHER objects happened to target a shared register — the same app would
export differently on two instances.
Serializer gains four channels — data-registers/, connectors/, automations/,
skills/ — and stamps formatVersion 2.0. Parser accepts BOTH majors: a v1 repo
parses byte-identically to before, a v2 repo additionally yields channels.

Collectors are total, mirroring collectCompanionSchemas(): a missing source
yields no entries rather than an exception, so serialisation never blocks a
publish. The descriptor's per-channel counts are what stop that becoming a
silently empty artefact — spectr under v1 serialised to a manifest plus ZERO
schemas and reported success.

Connectors come from the EXPLICIT Application.connectors[] binding, with
one-level dependency resolution (a synchronization's source/mapping) reported
separately from declared entries. Secret stripping is defence in depth, not the
primary control: credentials live in OpenRegister's broker and configs reference
them by UUID — verified against the live instance, where source 23 carries
host/port/user/dbname plus a credential UUID and no password field at all.

Parser channel reading is deliberately LENIENT where companion-schema parsing is
strict: a companion schema is load-bearing, a channel entry is additive, so one
unreadable connector must not make a valid repository unimportable.

formatVersion had NO test coverage before this change — nothing asserted it, so
the field governing whether a repo parses at all could change silently. Now
pinned in both directions, and the v1 back-compat test was verified to fail
without the fix.
rubenvdlinde and others added 30 commits August 16, 2026 10:46
…the-contract

refactor(deps): type-hint OpenRegister's published contract (ADR-084)
docs(spec): Journey Designer beside the Page Designer
feat(export): carry the flows an app is made of, and make them runnable
…cal-docs

docs(product-page): add canonical UseCases/Features/Integrations/Technical sections
ci: turn Newman on — it has never run, and its seed could not fail
Kept this branch's iconUpload/app-icon-management specs. Development's side
is the older shape: two QUARANTINED test.skip bodies whose assertions sit
under nested `if` guards, so the product failing to reject a non-SVG is
exactly the case that asserts nothing. Development's own comment removed the
coverage anchors for that reason; this branch instead rewrites the tests to
assert unconditionally and keeps the anchors, which is the point of the PR.
test(e2e): cover 10 gate-19 scenarios with real tests, and fix the icon-remove 404 they found
…218)

Adopts the canonical script from ConductionNL/.github (quality-config/coverage-guard.php).

The whole-project comparison fires on measurement noise. doriath#240 was a PR
whose entire diff was `webpack.config.js` — no PHP at all — and the guard failed
it: identical denominator (13723), both runs reporting exactly
`Tests: 948, Assertions: 3051, Skipped: 1`, and six covered statements of
run-to-run xdebug variance between them.

The measured `--against` floor cancels driver variance (xdebug vs pcov), as its
header says. It does not cancel run-to-run variance within one driver, and the
ratchet has no tolerance. Scoping the comparison to the PHP a change actually
touches keeps full strength where a regression matters and makes the noise
unreachable by construction — a diff with no PHP cannot fail.

New `changed-files` capability; the shared workflow PROBES for it rather than
assuming, so an un-updated copy keeps the previous behaviour instead of silently
accepting and ignoring the flag.

Script only — no behaviour change until the workflow passes `--changed-files`.
Byte-identical to the canonical copy (md5 5be122aad209da030c79b22a133232fb).

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
)

gate-49 controller-exception-translation reported
`AgentsController::loadRunsForAgent()` as calling a service method that may
throw a tracked exception with no try/catch and no `@throws`.
`RegisterMapper::find()` / `SchemaMapper::find()` raise
DoesNotExistException when the `openbuild` register or the `agentRun`
schema is absent, and `\Exception` when their RBAC check refuses.

PROPAGATION IS THE CORRECT ANSWER HERE, not a catch. The helper returns
`array`, so it cannot answer with a JSONResponse; and swallowing the
failure to return `[]` would render an empty run list indistinguishable
from a genuinely empty history — in the one endpoint whose stated purpose
is that agent runs are "transparently logged and reviewable". The caller
`runs()` already wraps the whole body in `catch (Throwable)`, logs the
cause and answers HTTP 500, which is the honest status for a broken
install. So the docblock now says what was already true, which is exactly
what the gate's `@throws` branch is for.

This is also why the sibling `loadAgent()` / `loadApplication()` helpers
may return `null` and this one may not: there, `null` becomes a 404 the
caller can act on. That reasoning is recorded in the docblock rather than
left for the next reader to re-derive.

No exclusion comment was used. The change is 17 lines, all docblock —
mechanically confirmed (`git diff` adds no non-comment line).

Measured with the gate's own script (ConductionNL/.github@main,
`scripts/run-hydra-gates.sh --full`), over the repo's lib/Controller files:

  before  [gate-49] controller-exception-translation: FAIL — 1 controller method(s) missing try/catch or @throws
  after   [gate-49] controller-exception-translation: PASS

Repo failing-gate count 7 -> 6; no other gate verdict changed.
phpcs, phpmd (app ruleset + unusedparams) and phpstan are clean on the
file; psalm reports the same 4 pre-existing `UndefinedClass
OCA\OpenRegister\Contract\ObjectServiceInterface` errors as it does on the
unmodified file at `origin/development` — an out-of-container autoload
artefact, unchanged by this commit.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…HPUnit 35→0 (#221)

PR #208 (`refactor/adr-084-type-hint-the-contract`) landed half-applied on
`development` at e58292d. Run 31937314387 was `cancelled` at run level with
`PHP Quality (phpstan)` and the three PHP 8.4 PHPUnit cells `failure`; the
three PHP 8.3 cells were `cancelled`, so CI never gave a verdict on them.
Measured locally: 8.3 and 8.4 are identical (849 tests, 41 errors,
4 failures each), so the 8.3 cells were failing too.

lib/ — phpstan 14 -> 0 over 116 analysed files
  - 10x `is_array($results|$rows) === false` after `findAll()`. The contract
    declares `findAll(): array`, so the guard is statically dead. It was dead
    before the refactor too (openregister's own `findAll` is `: array`); the
    refactor only made the declared type visible to phpstan, because the leaf
    used to reach the service through `$container->get()` = `mixed`. Removed,
    keeping the `=== []` / `count() === 0` half where one existed.
  - `SeedHelloWorldFixture::create()` declared `: Db\ObjectEntity` while
    returning what `saveObject()` now answers. Retyped to
    `ObjectEntityInterface`; every caller only uses `getUuid()`.
  - `ApplicationsController::resolveApplicationBySlug()` `@return` tuple
    retyped for element 0. The callers that need the concrete entity for the
    audit write still narrow with `instanceof`.
  - `AppRepoSerializer::findConnector()` kept an `is_array($found)` branch on
    a `find()` result that the contract types as `?ObjectEntityInterface`.
    Returns `$found->getObject()` directly.

tests/ — 41 errors + 4 failures -> 0 (8 remaining are the container's
missing ext-zip, identical on the base)
  - Four call sites carried a `objectService:` named argument appended after
    a positional argument that already filled the slot, two of them bound to
    a variable declared LATER in the file (`$provider`, `$entity`). Removed.
  - `AbstractToolHandler` and `OpenBuildToolProvider` gained `$objectService`
    as parameter #5; the test constructions still passed the old positional
    order. Fixed, naming `permissionResolver:` where it now shifts.
  - `AppChannelApplierTest`'s `saveObject` expectation was one constraint
    short of the contract's parameter list, shifting `failIfExists` onto
    `$currentUser` so the never-overwrite assertion never matched.
  - `ApplicationsControllerDiffVersionsTest` mocked the concrete
    `Service\ObjectService`; mocks the contract now.

Four RBAC tests were passing while testing nothing. `requireWriteRole()`
reads the INJECTED object service, but the tests only wired the container,
so the gate answered `not_found` — the same code those tests assert for a
downstream reason. `testUpsertPageAllowedForOwner`,
`testPromoteVersionAllowedForExplicitOwner`,
`testUpsertPageAcceptsValidRoute` and `testAddWidgetAcceptsKnownWidgetType`
were green without the gate ever being cleared. The double is now shared
between the injection and the container stub, and the reason is recorded at
each site so it cannot be undone silently.

No baseline entry, no `@phpstan-ignore`, no skipped test, no widening of
`ObjectServiceInterface::saveObject`. No `saveObject()`/`updateObject()`
call site is touched, so object identity is carried exactly as before —
`git diff cd07105..e58292d` shows #208 changed none of them either.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…prop (#223)

Burning down `@nextcloud/no-deprecated-library-props`, one of the four autofixes
withheld in the eslint 10 migration (ConductionNL/.github#455). Like the
openconnector one before it, this turned out not to be lint debt.

`NcDialog` in `@nextcloud/vue` 9 declares `noClose`. `canClose` appears **ZERO**
times in the shipped component — it is not a prop, so all four bindings fell
through as plain attributes and did nothing. Every one of these dialogs has been
dismissible while the work it guards is still running:

  ExportDialog           :canClose="!submitting"
  ImportDataWizard       :canClose="!importing"
  CopilotGenerateDialog  :canClose="state !== 'planning' && state !== 'executing'"
  PromoteVersionDialog   :canClose="true"

⚠️ `noClose` is the INVERSE of `canClose`, and three of these are EXPRESSIONS,
not literals. A rename that kept the value would have inverted the guard —
locking the dialog exactly when it should be closable and vice versa. That is
the second instance of this shape in two apps, and it is precisely why the
autofix was withheld:

  :canClose="!submitting"   ->  :noClose="submitting"
  :canClose="!importing"    ->  :noClose="importing"
  :canClose="a && b"        ->  :noClose="!a || !b"      (De Morgan, not a rename)
  :canClose="true"          ->  :noClose="false"

Verified
  - `@nextcloud/no-deprecated-library-props` suppressions 4 -> 0, and
    `--prune-suppressions` removed the entries rather than leaving them stale.
  - eslint on src: 0 errors before and after; 138 warnings before and after.
  - vitest: 1378 passed.
Four files were failing prettier --check on development: IconUploadSection.vue
and three e2e specs. Pre-existing — this branch touches nothing else.

Worth noting for the next person: the first --write left two of the four still
failing --check, and a SECOND --write converged them. So "I ran prettier and it
is still red" is not necessarily a config problem; run it again before going
looking for one.

prettier --check over the job's own glob now reports all matched files clean.
style: satisfy the Frontend Check (format) job
…re not true

Both failures were in the collection, not the app.

Request 3 called openregister's approval-steps list with NO headers and got
412 "CSRF check failed", so the array assertion then ran against the error
body. Nextcloud exempts a request from CSRF when it carries
`OCS-APIRequest: true`, which is exactly what every other collection in this
repo sends to openregister. Added, with Accept beside it.

Request 4 asserted a bare 404 for the approve path, and got 405. The premise
was wrong rather than the app: openregister's AppHost::Routes::standard()
appends the SPA catch-all at `/{path}` as a GET route, so a POST to an
unrouted path matches that pattern but not its verb and Nextcloud answers
Method Not Allowed. Both codes prove the same thing — openbuild serves no
approve endpoint, which is the ADR-022 point — and 404 alone would only hold
in an app with no catch-all. The assertion now accepts either and says why.

Verified openbuild's routes carry no approval entry at all, so the thing being
asserted is still true.
fix(newman): the approval-steps collection asserted two things that are not true
…hange, planning only) (#226)

schedules-editor shipped ScheduleEditDialog.vue / SchedulesSection.vue /
schedules.js reading and writing the classic OpenConnector dialect directly
(GET .../openconnector/synchronization, action: "openconnector:synchronization").
That dialect is being retired fleet-wide in favour of OpenRegister's native
Flow engine (ADR-065); the fleet-wide policy is being recorded in parallel as
hydra change adr-092-openconnector-dialect-retirement (renumbered from an
earlier ADR-091 attempt, which collided with an unrelated, already-merged
ADR-091). Actual cutover is gated on openregister's flow-sync-decomposition
change landing real decomposed nodes — that change has no tasks.md yet, so
implementation has not started there either.

This is a NEW change (not folded into schedules-editor): schedules-editor's
own tasks.md is fully checked off and its code is already merged
(0b6eddc), so it reads as closed work rather than in-progress. Planning
only — no code changes. Audits the current classic-dialect call sites,
flags AutomationEditDialog.vue as an adjacent surface with the identical
call that is NOT in scope here, defines the open design questions for the
Flow-native target shape, and gates all real implementation tasks behind
the two blocking dependencies above.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Hydra Gates stays red on gate-66 (13 ADR-083 violations) — that is a dependency
-shape question about how this app reaches OpenRegister, not something to fold
into a gates sweep. Two of three gates are cleared here.

## gate-26 — the coverage existed and nothing could read it

All three views ARE driven by e2e specs; the component names just appear only in
COMMENTS, never in executable text:

  * DashboardIndex  <- dashboard-overview.spec.ts (name in the file docblock)
  * FeaturesRoadwap <- features-roadmap.spec.ts   (name in the file docblock)
  * TemplateGallery <- openbuild-template-catalogue.spec.ts (name in an inline
    comment on line 101)

FeaturesRoadmap is the sharpest case: its manifest page is `type: "roadmap"`
with NO `component` key, so the component name genuinely appears nowhere in any
executable text in the repo.

Each spec now binds its route to a constant named after the component and
navigates through it — 11 `goto()` calls rewritten across the three files. URLs
unchanged. gate-26: 3 findings -> PASS, 5 pages all proven. prettier clean.

## gate-7 — six methods, two different reasons, both checkable

RulesController `evaluate` / `schema` / `testAll`: authorization is delegated to
OpenRegister's schema RBAC. Every read goes through this controller's private
`query()`, which calls `searchObjectsBySlug(..., _rbac: true,
_multitenancy: false)`. The tenancy opt-out is deliberate and already documented
there — `openbuild` is a SYSTEM-WIDE register, not org-scoped, so a true org
filter would throw and break resolution for every caller. `_rbac: true` is the
guard and it is explicit, which is the opposite of the usual gate-7 finding.

StoreController `search` / `install`, ShopController `githubInstall`: these
address no openbuild-owned object at all. The slug identifies a template in an
EXTERNAL catalogue (the configured store registry, or a GitHub repo), so there
is nothing of another tenant's to reach by guessing it, and the install path
CREATES a new app owned by the calling user rather than reading an existing one.

Accounting: 6 findings, 6 new tags, 0 remaining. The tree now shows 8 because it
already carried 2 — reconciled against `development` rather than trusting the
total, and each tag confirmed to sit on the method it was written for.
fix(gates): clear gate-7 (6) and gate-26 (3)
gate-66 (openregister-dependency-shape) reported 13 ADR-083 rule 1
violations. Reproduced locally on the same tree — 13 findings over 134
files, now 0 over 134 (not a zero-file run).

MCP handlers (7 files, 8 lookups). AbstractToolHandler already had
OpenRegister's published ObjectServiceInterface injected as a
constructor property (ADR-084), and used it for the per-Application RBAC
gate — while every handler body resolved a SECOND instance of the same
service out of the container by string name. The provider test's own
docblock documented the split. The property is now protected and the
bodies use it. UpsertSchemaHandler is the exception: OpenRegister
publishes no contract for SchemaMapper/RegisterMapper (lib/Contract/
holds only ObjectServiceInterface and ObjectEntityInterface), so the
lookup stays and the availability question is asked out loud instead,
turning an opaque internal_error into a stated reason.

ExportsController, ExportJobService, JobOwnerImpersonator. These already
guarded the reach with `$container->has('OCA\OpenRegister\...')`. That
is not a behaviour change either way: NC's SimpleContainer::has() IS
`isset($this->container[$id]) || class_exists($id)`
(server/lib/private/AppFramework/Utility/SimpleContainer.php:50). The
guard is now spelled class_exists(), which is the idiom ADR-083 and the
rest of this app already use, and which a reader can evaluate without
routing the answer through the container.

Separately — the defect that leaves every export at status "queued":

JobOwnerImpersonator::impersonate() reads the object to discover WHO to
impersonate. That read necessarily precedes the impersonation, so the
caller is still the background job's session, which is nobody. An
RBAC-checked read is evaluated as `Anonymous` and refused by any schema
that does not grant anonymous read:

  OpenBuild: owner impersonation lookup failed for object <uuid>:
  User 'Anonymous' does not have permission to 'read' objects in
  schema 'Export Job'

It is a chicken-and-egg, not a permission decision. The `fail`
transition that should have recorded why is refused for the same reason,
so the job never even reaches `failed` — it sits at `queued`, looking
exactly like a job nobody picked up. That is what
tests/e2e/export-flows-and-agents.spec.ts:489 reports in CI.

The opt-out is one read: the id is not user input but the argument the
pipeline enqueued for a job it created; exactly one field is consumed
(getOwner()); and the outcome is strictly MORE restrictive, because the
work then runs AS that owner with every write RBAC-checked against them.
Failing the lookup does not deny the write — it runs the job as Anonymous,
the weaker identity.

The test uses a hand-written fake rather than a PHPUnit mock on purpose:
the production call passes named arguments, and a generated mock cannot
observe those — it sees its own defaults (_rbac => true) whether or not
the fix is present, so it would pass on broken code. Positive control
run both ways: with find($objectId) it reports _rbac => true and fails;
with the fix it passes.

Also fixes three pre-existing phpcs errors in OpenBuildToolProvider's
constructor docblock (standing rule).

Verified: 850 unit tests green (was 849), phpcs clean of errors across
lib/Mcp, gate-66 0/134.
E2E — 2 of the 3 failures.

builder-host "detail page must render the seeded body text". The seed is
correct and the text really is on the object; what is missing from the
page is the whole Data widget. From the CI trace of job 95207190738 the
object response completed at 16.171s and the schema at 16.181s — ten
milliseconds apart, OBJECT FIRST — and nc-vue's CnDetailPage flips
shouldRenderAutoBody as soon as the object lands, materialises the auto
body EXACTLY ONCE, and materializeAutoBody() DROPS the Data widget when
currentSchema is still null. Nothing rebuilds it when the schema arrives
and fetchSchema fails silently, so there is no error state either. On a
developer box the schema usually wins the race, which is why this only
ever failed in CI.

MessageDetail now ships the ejected default grid, byte-for-byte nc-vue's
own defaultDetailGrid() — which is also exactly what OpenBuild's edit
button writes the moment anyone edits the page. That takes the
explicit-grid path, where the widget's schema arrives through
CnPageRenderer's read-through context and fills in reactively. It changes
no pixels; it removes the race. THE nc-vue DEFECT IS NOT FIXED BY THIS:
every other auto-body detail page still has it. Reported separately.

app-icon-management "Remove in the dark slot". The trace shows the
sidebar opening (assertion PASSED at 1148937ms) and the tab click issued
42ms later never returning; the snapshot taken 120s on has no sidebar and
no tablist at all, ending on a "Open sidebar" button. The page closes the
sidebar again while it is still hydrating. The tab BUTTON stays in the
DOM, which is why the log says "not stable" then "not visible" rather
than the honest "not found". `.app-sidebar__toggle` is a TOGGLE and
`isVisible()` is an instant probe, so the old code could also click it
shut. The open-and-click is now one idempotent retried step. The twin
helper in iconUpload.spec.ts was not doing anything smarter — it burns
~4s on an overlay ci-seed.sh already suppressed and the page settles
meanwhile. That is luck, not a guard.

Newman — 9 of the 12 assertions, and none of them was an openbuild
security defect.

rbac (3). A per-request Basic override does not change who the request
is. Newman keeps ONE cookie jar per run, and Nextcloud consults
Authorization only when there is no session (OC::handleRequest calls
handleLogin() strictly in the `else` of isLoggedIn(), base.php:1054-1066).
The Setup folder authenticates as admin, so the outsider requests
executed AS ADMIN — who sees every app and clears getManifest through the
audited bypass. Worse than the two red assertions: 2.1/2.2/3.2/4.2 were
PASSING VACUOUSLY for the same reason, measuring admin rather than the
role they name. The outsider requests move to {{outsiderBase}}
(127.0.0.1 — same server, other origin, empty jar), the mechanism this
repo already measured for the anonymous case, and a new positive control
asserts the acting user really is rbac-outsider before anything is
concluded from it. Only two origins are trusted, so the viewer/editor
requests stay vacuous; that is stated in the collection rather than
hidden.

page-editor (2). Written against the pre-ADR-002 model: it PUT `manifest`
onto the OR Application object, which has no such property (it lives on
ApplicationVersion). OR accepted and echoed it — which is why request 2's
own assertion passed — while GET .../manifest reads the production
version and never saw it. The same absence is why an invalid manifest
came back 200: no property, nothing to validate. Both requests now use
openbuild's own PUT /api/applications/{slug}/manifest.

And that endpoint really did accept an unrenderable manifest.
REQ-OBPD-009 puts the refusal in the CLIENT, so the guarantee stopped at
the browser and an API caller could brick an app with `{version, menu}`
and no `pages`. saveManifest now applies the SAME two structural rules
AppRepoParser::validateManifest() has always applied on the import path.
Positive control run: with the check disabled the new unit test reports
500, with it 400.

templates-marketplace (2). The request demanded "different user + same
slug -> 201", citing REQ-OBTC-004 for owner-scoped uniqueness. The spec
says the opposite (spec.md:254-261 — an existing slug in the ORGANISATION
is rejected), the controller deliberately scopes org-wide "to prevent
squatting", the {{second_user}} it needed is created by nothing, and
createFromTemplate is admin-only so a real `tester` would have got 403,
never 201. It now asserts the contract that is actually specified.

docudesk-documents (2). Not an openbuild defect and not a docudesk one
either: ci-seed.sh never configured docudesk's template register, because
the helper that does it lives in global-setup.ts and Playwright hooks do
not run on the Newman leg. Item 1 passed throughout only because
TemplatesController catches RegisterNotConfiguredException while
CorrespondenceController lets it out as a 500. ci-seed.sh now configures
it — optionally, skipping cleanly when docudesk is absent, and reading
the value back rather than trusting the write.

NOT FIXED, QUARANTINED — versioning "REQ-OBV-005 diff" (3 assertions).
It carried "STILL RED, DELIBERATELY" and it was right to. REQ-OBV-005
defines the refs as a version SLUG, `current:<slug>` or
`history:<slug>:<rev>` returning {from:{manifest,semver,savedAt},…};
resolveVersionBlob() accepts `draft` or a UUID and returns
{manifest,version,publishedAt}. On top of that the folder diffs two
sibling snapshot rows, which ADR-002 retired, so `v2_uuid` is never
assigned and the request leaves with an empty `to` — the 404 is correct.
Rewriting the assertions against today's UUID behaviour would pass and
would delete the only executable record that endpoint and spec disagree.
Moved verbatim to tests/integration/quarantine/ (CI globs the directory
non-recursively) with a README stating the divergence and what has to be
true before it comes back. This is a DEFERRAL, not a fix.

Verified: 851 unit tests green (was 849), phpcs 0 errors over lib/ +
tests/Unit, prettier clean, and the full hydra-gates suite exits 0 with
62 of 70 gates reporting (gate-19 advisory only). Local psalm is not a
usable instrument here — it resolves OCA\OpenRegister\ to tests/Stubs/,
which has no Contract/, so all 193 of its errors are that one missing
interface; CI installs the real openregister and is green.
My own change reddened phpmd, which `development` passes. Three findings,
all mine, read from the PR's CI log (33 KB via `gh api .../logs` — `gh run
view --log` returns empty and exits 0 here):

  SeedHelloWorldFixture.php:470  ExcessiveMethodLength  buildManifest() 119 lines
  SeedHelloWorldFixture.php:536  MissingImport          new \stdClass()
  UpsertSchemaHandler.php:41     ExcessiveMethodLength  handle() 115 lines

The MessageDetail page — and the long rationale for why its body grid is
ejected — moves into buildMessageDetailPage(), which is where that
explanation belonged anyway; buildManifest() goes 119 -> 53. `stdClass`
is imported. UpsertSchemaHandler's availability probe moves into
schemaMappersAvailable() with the reasoning on the helper; handle() goes
115 -> 96, against a threshold of 100.

Verified with the project's OWN command and a positive control, because a
zero-finding phpmd run looks identical to a zero-FILE one: both rulesets
over lib/ report 0 findings, and the same invocation with the threshold
forced to 5 reports `handle() has 96 lines of code` — so the files really
were read. 49 unit tests over the three touched classes green; phpcs 0
errors.
…what it does not declare

ExportJobService::queue() has always WRITTEN `flows` and `applicationSlug`
onto the ExportJob record. The exportJob schema never DECLARED either, and
OpenRegister stores only the properties a schema declares — silently.

Measured, not inferred, against a live NC + OpenRegister:

  POST /api/objects/openbuild/export-job carrying `flows`, `applicationSlug`
  and a nonsense key returned 200 and ECHOED ALL THREE BACK. An immediate
  fresh GET of that same object returned none of them — only applicationUuid,
  applicationVersion, includeSeedData, license, status, target.

The save response is therefore not evidence of what was stored; only a
read-back is. Positive control on the same instance: `dataRegisters`, which
IS declared (fragment 30-) and has the identical array-of-objects shape
(required + additionalProperties:false), round-trips intact through a fresh
read. So the shape is fine — the declaration was simply missing.

Two live consequences, both silent:

1. `flows` — `is_array($job['flows'] ?? null)` was false on every job, so
   FlowAndAgentExportBundler::bundleFlows() returned immediately and no bound
   flow was ever written into lib/Settings/flows/. The export job still
   transitioned to `succeeded` and produced a downloadable ZIP, so nothing
   looked wrong. This is the e2e failure "the bound flow must be in the ZIP —
   this is the whole feature", red on `development` as well as here.

2. `applicationSlug` — RunExportJob:150 fell back to its `'exported-app'`
   default on EVERY export, so each exported app was scaffolded with appId,
   namespace and name `exported-app` instead of the real slug, and
   bundleAgents() then queried agents whose applicationSlug is `exported-app`,
   matched none, and recorded no skip. Agents were never exported either.

Declaration-only: no PHP changes, because the writing and reading code was
already correct.

The exportJob schema version is bumped alongside the fragment. OpenRegister
SKIPS the import when the deployed version is >= the declared one, so a
property added without a bump reaches nothing but a fresh install. 1.1.0 is
the same target PR #219 picked, for the same reason.
fix(ci): green the E2E, Newman and Hydra Gates jobs — at their real causes
…nt after it

The two E2E runs of the SAME sha (6349aa6) disagreed on
`version-rollback.spec.ts:314`: the pull_request run failed it in 36.6s,
the push run passed it in 49.7s with 192/192. Identical code, so this is a
race, not a defect — and the failing shape is the one this file already
documents.

`openVersionHistory()` is already an idempotent open-and-click under
toPass(), and it works: it returns only once the panel body is on screen.
What it could not cover was the caller's OWN assertion, made after it
returned:

    await openVersionHistory(page, await appUuid(page))
    await expect(page.locator('.version-history__row').first()).toBeVisible(...)

The detail page re-mounts the sidebar after first paint — the helper's own
comment block records this — and when that re-mount lands in the window
between the helper returning and the caller asserting, there is no longer
anything that can re-open it. The caller then polls a hidden element for 20s
and reports the app hiding its rows. The CI log shows exactly that: the
locator resolved to a real
`<li class="version-history__row version-history__row--current">` on all 36
polls, `hidden` every time.

So move the same assertion inside the loop via an opt-in `requireRows`, and
drop the now-redundant external one at both call sites. Nothing is weakened:
the condition asserted is identical, and for these two callers
`.version-history__empty` correctly stops counting as success. The third
call site keeps the either/or default — it asserts toHaveCount(), which
counts DOM nodes regardless of visibility, which is why it never flaked.

Type-checked with tsc --noEmit (exit 0). Positive control, because a clean
tsc run and a zero-file run print the same nothing: the same file with
`requireRows` misspelled fails TS2561, exit 2.
`quality / Frontend Check (format)` was the one red job on this PR. Fixed by
running the project's OWN command (`npm run format`, i.e. the quoted
`prettier --check "**/*.{js,ts,vue,css,scss}"`), not a hand-rolled prettier
invocation — an unquoted glob in an npm script silently narrows what is
checked, and this fleet has been bitten by that before.

The check named exactly one file, this one, and the fix is layout only: the
ternary assigning `panelBody` is reflowed onto its own lines. Verified from
the DIFF rather than the exit code, because an autofix that rewrites a string
literal is a semantic change wearing a formatting costume — on shillinq that
shape orphaned 17 translations across 36 locales by editing a `t()` key. Here
both selector literals are byte-identical, no `t()` is involved, and no
Playwright selector text moved.

Re-verified after the reflow: `npm run format` exit 0, `tsc --noEmit` exit 0.
…ry-flake

test(e2e): fix the version-history flake — require rows inside the toPass() retry
…it could not answer its own question (#231)

* test(export): make the stuck-export failure decisive — the job census was taken after the worker, so it could not answer its own question

`export-flows-and-agents.spec.ts` fails on `development` with

    the export job must finish — last status "queued";
    last worker pass: worker ok: (no output); job list: 112 total, 0 RunExportJob

and that message has been read as proof that the enqueue never happened. It is
not proof of anything. `runExportJobWorker()` took the census AFTER running
`background-job:worker`, and a QueuedJob is DELETED from `oc_jobs` once it
executes — so `0 RunExportJob` is what you see whether the job never existed or
ran to completion. The helper's own comment says the census exists to
disambiguate exactly those two cases; in that order it cannot.

- census taken BEFORE the worker pass, and both are reported;
- on a non-terminal status the failure now also reports the DEPLOYED exportJob
  schema's version and whether it carries `x-openregister-lifecycle`. Without
  that block OR's TransitionEngine finds no state machine and returns silently,
  the job row is consumed, and the object sits at "queued" with no log line —
  #219.

Read from the schema API, not the object: this OpenRegister build exposes no
`available-actions` on an object read at all (measured, including
`?_extend=all`), so probing for that key would report "absent" on a healthy
instance and read as evidence.

No assertion is relaxed and no skip is added; the failure only gets louder.

* docs(export): the #219 bump landed in #229 — say what a MISSING lifecycle means now

Merging development brought in #229, which shipped `exportJob` 0.1.0 -> 1.1.0.
The probe's docblock and its failure string still told the reader that a missing
`x-openregister-lifecycle` meant #219 was unfixed. On the merged base that is no
longer true, and a comment that survives the change it describes is the half of
a diff git cannot check.

A MISSING reading now means the instance under test never converged onto the
bumped schema — a narrower fault than the one #219 described. The probe keeps
its value; only the conclusion it licenses changes.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…install (#230)

Three shop-install bugs (credential forwarding to the skills channel, the
missing ApplicationVersion/productionVersion link, the missing BuiltAppRoute
on publish), plus a fourth found while fixing the tests:

linkProductionVersion() patched `productionVersion` in with a hand-built
four-field payload. OpenRegister's saveObject() is PUT, not PATCH — on the
update path SaveObject::prepareObjectForUpdate() calls
fillMissingSchemaPropertiesWithNull() and then setObject() replaces the stored
data outright, with no merge against the existing object. That write NULLed
`owner`, `status`, `version` and `templateOrigin` off the Application persisted
one statement earlier, on every local-template, remote-registry and
GitHub-shop install. It now re-saves the whole stored object with the one
field patched in, mirroring ApplicationPublishController::setStatus().

Test doubles assert the real write counts (exactly(3) for an install,
exactly(2) for a publish) with each write pinned by register/schema/uuid, and
the saveObject double echoes the persisted object back the way OpenRegister
does — returning a bare ['uuid' => ...] is what made the wipe invisible.

phpmd: installFromTemplateArray() (114 lines) and collectConnectors() (104)
split along real seams into materialiseApplication(), collectDirectReferences()
and exportReferencedConnector(). Six UndefinedVariable findings fixed too —
$realUuid !== '' against a possible null read the wrong way. No suppressions,
no threshold changes.
…233)

The shared Code Quality workflow maps `phpcs` exit 1 to success, so these
have been shipping unnoticed on `development`. Errors 10 -> 0; the raw
`phpcs` exit code goes 1 -> 0. The 98 warnings are untouched.

- 5x PEAR.Commenting.FunctionComment.MissingParamTag in ExportService.
  Three signatures had grown parameters the docblocks never gained:
  `__construct()` was missing `$flowAndAgentBundler`, and both
  `generateAppZip()` and `buildScaffoldMap()` were missing `$flows` and
  `$applicationSlug`. Tags are inserted in signature order with the types
  the code actually passes on (`array<int,mixed>` for `$flows`, matching
  `FlowAndAgentExportBundler::bundle()`).
- 4x Generic.Files.LineLength (>150) in TemplateSeedService::seed(): the
  `@return` description continuation lines were indented to the width of
  the array-shape type. Re-indented, no wording changed.
- 1x Squiz.PHP.DisallowInlineIf.Found in
  FlowAndAgentExportBundler::bundleAgents(). The ternary is expanded into
  a guard WITHOUT an `else`, deliberately: an if/else here would trade a
  PHPCS error for a PHPMD `ElseExpression` violation, which this fleet's
  shared ruleset also enforces.

No behaviour change. lint, phpmd (both rulesets, with the baseline),
psalm (same 4 findings once line-number shift is normalised) and phpstan
are unchanged. PHPUnit is 851 tests / 2543 assertions / 8 errors /
1 skipped on both sides with an identical failing-test-name set; those 8
are a pre-existing `ZipArchive` extension absence in the runner, not
introduced here. FlowAndAgentExportBundlerTest passes 6/6 on both sides,
and both branches of the rewritten conditional were checked against the
original expression with a negative control.
Composer had no package-ecosystem entry at all, so composer dependencies
got no release-age cooldown whatsoever, unlike npm which has had one for a
while. Adds cooldown.default-days: 2 with a conduction/* exclude, matching
the fleet-wide floor gate-93 (composer-cooldown-config) enforces.

See ConductionNL/hydra openspec/changes/composer-dependency-cooldown and
ADR-093 (proposed, ConductionNL/hydra#591).

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants