From 9d7b496fa97ebfbd4506a99a788445abf84b897c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 17 Aug 2026 01:06:03 +0200 Subject: [PATCH 1/3] fix(softwarecatalog): the Organisations index filtered on values #520 deleted, and the SBOM provenance line read a name that no longer existed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five of the six E2E failures on `development` came from two renames that moved one half of a pair and left the other behind. Neither raised an error, which is why both survived: one produced an empty list, the other produced an element that never rendered. 1. THE ORGANISATIONS INDEX WAS EMPTY FOR EVERY USER. `src/manifest.json`'s Organisaties page filtered on `status: ["Concept", "Actief", "Deactief"]`. #520 translated that enum to Draft/Active/Inactive/merged and migrated the stored rows, but not this filter — so the page filtered on three values no row can hold. OpenRegister answers such a filter `200 {"total": 0}`, so the index rendered "No items found" and read as an empty catalogue: no console error, no failed request, nothing in the log. Measured on a running instance, positive and negative control: ?status[]=Draft&status[]=Active -> total 1 (the seeded row) ?status[]=Concept&status[]=Actief&... -> total 0 and reproduced in the browser: one organisation exists, the index shows "No items found". The same commit missed `organization.status`'s `default` ("Concept", not a member of its own enum, so every newly created organisation lands outside this filter) and the whole `x-openregister-lifecycle` block, whose `initial`, `final` and every `from`/`to` still named the Dutch values — a lifecycle whose transitions match no row simply offers nothing. Both are fixed here, with the schema version bumped: a deployed version >= the declared one makes the import SKIP, and OpenRegister's schemaContentDiffers() escape hatch compares only properties/required/ authorization — never `configuration` — so a lifecycle-only edit would never have deployed. 2. THE SBOM PROVENANCE LINE COULD NEVER RENDER. `SbomComponentsPanel`'s producer computed is `moduleVersie()`; when the schema slug was translated the CONSUMER was renamed to `this.moduleVersion` and the producer was not. Vue resolves the unknown property to `undefined`, `moduleVersionData` returned `{}`, and every derived value went empty: `lastImportedLabel` returned '' so the `v-if`-gated `data-testid="sbom-provenance"` never mounted, and `parentModuleId` returned '' so the vulnerability-match heuristic ran with an empty scope. "No import yet" is a legitimate state, so the broken build was indistinguishable from an unimported module version. 3. Three e2e tests asserted a surface the product stopped rendering. Organisations was decomposed from a bespoke `type: custom` OrganisatieIndexView to a standard `type: index` page; the tests still looked for that view's "Add organisation" button and its "No organisations" empty state. The empty- state assertion was the worse half: `toHaveCount(0)` against a string nothing renders passes unconditionally, so the guard meant to catch an empty list said nothing while the list really was empty. They now assert the CnIndexPage surface — heading, Cards/Table toggle, create action, list body — which is strictly more than before. 4. `gemma-faceted-search` still named the pre-#518 Dutch slug `dienst` in three places: the message assertion, the 200 control, and a `supportedSchemas.sort()` compared against an UNSORTED literal, which could not have held for any naming. 5. `index-pages`' "index standards" test.fixme claimed "blocked: missing `standaard` schema". The page is bound to `"schema": "element"`, which the CI seed enumerates among the 36 schemas present, and a running instance renders the index with an "Add Element" action and no app-origin error. A skip whose reason has stopped being true reads exactly like a passing test, so it is put back to work rather than re-worded. BEFORE / AFTER (local, same command both sides) tests/vitest/sbomProvenanceLabel.spec.js (new, 4 tests) on HEAD: 2 failed / 2 passed (both failures are the defect; both passes are the negative controls, so the assertions discriminate) after: 4 passed / 0 failed tests/vitest/manifestFilterEnumParity.spec.js (new, 3 tests) on HEAD: 1 failed / 2 passed — reporting exactly the three stale filter values, with its positive control passing on both sides after: 3 passed / 0 failed full vitest suite, run from `git archive HEAD` with the same node_modules: HEAD: 22 files, 21 passed / 1 failed, 226 tests passed branch: 23 files, 22 passed / 1 failed, 230 tests passed The one failing file is `adminApi.spec.js` (`ReferenceError: window is not defined`); it fails identically on pristine HEAD and this change does not touch it or anything it imports. eslint on the changed component: clean. `node tests/validate-manifest.js`: PASS (0 errors), 29 pages, schema 2.22.0. FILES MEASURED: 11 changed (2 config/JSON, 1 component, 5 e2e specs + 1 e2e helper, 2 new vitest specs + 1 stub). NOT DONE, DELIBERATELY — recorded on the fleet board: - Five more schemas carry the same #520 miss: `usage.status` default 'In productie', `connection.status` and `moduleVersion.status` default 'in gebruik', `module.type` default 'Applicatie', `connection.integrationType` default template emitting 'extern'/'intern' — every one outside its own enum — plus four more Dutch `x-openregister-lifecycle` blocks (usage 'Verwerving', contract 'In onderhandeling', connection and moduleVersion 'in ontwikkeling'). They are the same class of bug on surfaces this change does not measure, so they belong to whoever owns #520 rather than to an E2E repair. - The schema title is authored "Organization" while every other string in the app is British, and a deployed instance can still serve the older "Organisation" because a title change never redeploys. Rather than rename a schema title from an E2E fix, the affected assertions accept either spelling of that one word. - `organisatie-crud`'s UI-create test.fixme is NOT re-enabled. Its stated reason (an ObjectModal Catalogus cascade) describes a removed surface, so the reason is corrected to "unverified" rather than restated — the body has never been re-authored against the dialog that replaced it, and guessing which fields that dialog exposes is exactly the kind of assertion that passes without testing anything. --- lib/Settings/softwarecatalogus_register.json | 25 +-- src/components/sbom/SbomComponentsPanel.vue | 33 ++-- src/manifest.json | 4 +- tests/e2e/spec-coverage/dashboard.spec.ts | 31 +++- .../gemma-faceted-search.spec.ts | 20 ++- tests/e2e/spec-coverage/index-pages.spec.ts | 75 ++++++--- tests/e2e/workflows/_ui.ts | 18 +- tests/e2e/workflows/organisatie-crud.spec.ts | 90 +++++++--- tests/vitest/manifestFilterEnumParity.spec.js | 119 ++++++++++++++ tests/vitest/sbomProvenanceLabel.spec.js | 154 ++++++++++++++++++ tests/vitest/stubs/nextcloud-l10n.js | 18 ++ 11 files changed, 495 insertions(+), 92 deletions(-) create mode 100644 tests/vitest/manifestFilterEnumParity.spec.js create mode 100644 tests/vitest/sbomProvenanceLabel.spec.js diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json index 6d43542c..d3a058b6 100644 --- a/lib/Settings/softwarecatalogus_register.json +++ b/lib/Settings/softwarecatalogus_register.json @@ -3,8 +3,8 @@ "info": { "title": "Software Catalog Register", "description": "Register containing AMEF and Voorzieningen schemas for the VNG Software Catalog application. This configuration includes schemas for applications, services, organizations, and compliance tracking.", - "version": "2.4.3", - "changelog": "2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files." + "version": "2.4.4", + "changelog": "2.4.4: organization.status was left behind by #520's enum translation — its `default` was still 'Concept' and its whole x-openregister-lifecycle block still named Concept/Actief/Deactief, while the enum and the migrated rows are Draft/Active/Inactive/merged. A default outside its own enum makes every newly created organisation fall out of the Organisations index filter, and a lifecycle whose from/to values match no row offers no transition at all — neither raises an error. The schema version is bumped with it because a deployed version >= the declared one makes the import SKIP, and OpenRegister's schemaContentDiffers() escape hatch compares only properties/required/authorization — never `configuration` — so a lifecycle-only edit would never have deployed. 2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files." }, "x-openregister": { "type": "application", @@ -2023,7 +2023,7 @@ "x-schema-org": "schema:Organization", "title": "Organization", "description": "An organisation that offers provisions. Absorbs the former ArchiMate `organization` schema: its identity and statutory identifiers (name, summary, description, oin, tooi, rsin, pki, image) and its ArchiMate round-trip `xml` are declared here, so there is one organisation schema rather than two that shared no property.", - "version": "0.5.0", + "version": "0.5.1", "omschrijving": "", "icon": "OfficeBuildingOutline", "required": [ @@ -2318,7 +2318,7 @@ "description": "Geeft aan of de VNG de organisatie positief beoordeeld heeft voor toegang tot de Softwarecatalogus", "title": "Status", "type": "string", - "default": "Concept", + "default": "Draft", "visible": false, "hideOnCollection": true, "facetable": false, @@ -2618,32 +2618,33 @@ "fair": 0.5 } }, + "$comment-lifecycle": "🔴 THE STATE NAMES HERE ARE THE ENUM'S VALUES, NOT LABELS. #520 translated the status enum (Concept/Actief/Deactief -> Draft/Active/Inactive) and migrated the stored rows, but left this block in Dutch, so `initial`, `final` and every `from`/`to` named a value no row can hold: the initial state wrote an out-of-enum value and no transition could ever match. Nothing errors — a transition whose `from` matches nothing is simply never offered — so it reads as a lifecycle nobody uses.", "x-openregister-lifecycle": { "field": "status", - "initial": "Concept", + "initial": "Draft", "final": [ - "Deactief" + "Inactive" ], "transitions": { "activate": { "from": [ - "Concept" + "Draft" ], - "to": "Actief", + "to": "Active", "description": "Activate the organisation." }, "deactivate": { "from": [ - "Actief" + "Active" ], - "to": "Deactief", + "to": "Inactive", "description": "Deactivate the organisation." }, "reactivate": { "from": [ - "Deactief" + "Inactive" ], - "to": "Actief", + "to": "Active", "description": "Re-activate a deactivated organisation." } } diff --git a/src/components/sbom/SbomComponentsPanel.vue b/src/components/sbom/SbomComponentsPanel.vue index 61773493..1fc65de0 100644 --- a/src/components/sbom/SbomComponentsPanel.vue +++ b/src/components/sbom/SbomComponentsPanel.vue @@ -180,7 +180,7 @@ import { matchComponents } from '../../utils/sbomVulnerabilityMatch.js' * @license EUPL-1.2 * * ModuleversieDetail "Components" sidebar tab: renders the imported - * `sbomComponent` set for a `moduleVersie` (name/version/purl/licenses) with + * `sbomComponent` set for a `moduleVersion` (name/version/purl/licenses) with * summary counts (total, distinct licenses, matched vulnerabilities) and an * upload control that posts a CycloneDX/SPDX JSON file to `SbomController`. * Re-importing REPLACES the previous set server-side (design Decision 3); @@ -205,7 +205,7 @@ export default { }, props: { - /** The moduleVersie OR object uuid (passed by CnObjectSidebar as `objectId`). */ + /** The moduleVersion OR object uuid (passed by CnObjectSidebar as `objectId`). */ objectId: { type: [String, Number], default: null, @@ -248,13 +248,22 @@ export default { computed: { /** - * The moduleVersie being inspected: the active object, else looked up + * The module version being inspected: the active object, else looked up * by objectId in the fetched collection. * - * @return {object|null} The moduleVersie record. + * ⚠️ THE NAME IS A CONTRACT. `moduleVersionData` below reads this + * property by name. When the schema slug `moduleVersie` was translated + * to `moduleVersion` the consumer was renamed and this producer was not, + * so the consumer resolved `undefined`, returned an empty data bag, and + * every derived value went quietly empty — no error, no warning, and the + * provenance line simply never rendered. Renaming one half of a + * producer/consumer pair is a silent break; `tests/vitest/ + * sbomProvenanceLabel.spec.js` fails when the pair drifts again. + * + * @return {object|null} The module version record. * @spec openspec/specs/sbom-import/spec.md#requirement-moduleversie-records-sbom-import-provenance */ - moduleVersie() { + moduleVersion() { const active = typeof objectStore.getActiveObject === 'function' ? objectStore.getActiveObject('moduleVersion') @@ -279,12 +288,12 @@ export default { }, /** - * The moduleVersie's raw data bag. + * The module version's raw data bag. * * @return {object} The property bag. * @spec openspec/specs/sbom-import/spec.md#requirement-imported-components-persist-as-openregister-objects-scoped-to-a-moduleversie */ - moduleVersieData() { + moduleVersionData() { if (!this.moduleVersion) { return {} } @@ -292,18 +301,18 @@ export default { }, /** - * The moduleVersie's parent module uuid — scopes the possible-match + * The module version's parent module uuid — scopes the possible-match * heuristic (design Decision 6, never a catalogue-wide scan). * * @return {string} The parent module uuid, or ''. * @spec openspec/specs/sbom-import/spec.md#requirement-components-are-matched-against-existing-kwetsbaarheden-without-external-calls */ parentModuleId() { - return resolveUuid(this.moduleVersieData.module) + return resolveUuid(this.moduleVersionData.module) }, /** - * The imported `sbomComponent` set for this moduleVersie, sorted by name. + * The imported `sbomComponent` set for this moduleVersion, sorted by name. * * @return {Array} The component records. * @spec openspec/specs/sbom-import/spec.md#requirement-imported-components-persist-as-openregister-objects-scoped-to-a-moduleversie @@ -428,7 +437,7 @@ export default { * @spec openspec/specs/sbom-import/spec.md#requirement-moduleversie-records-sbom-import-provenance */ lastImportedLabel() { - const data = this.moduleVersieData + const data = this.moduleVersionData if (!data.sbomLastImportedAt) { return '' } @@ -547,7 +556,7 @@ export default { /** * Upload the selected file to `SbomController::importSbom`. On success, - * refetches the sbomComponent and moduleVersie collections so the + * refetches the sbomComponent and moduleVersion collections so the * table/summary/provenance reflect the REPLACED set (design Decision * 3) with no page reload. * diff --git a/src/manifest.json b/src/manifest.json index 6c8bc8ca..f36b2708 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -179,11 +179,11 @@ "viewMode": "cards", "cardComponent": "OrganisatieCard", "columns": ["name", "type", "status", "website"], - "filter": { "status": ["Concept", "Actief", "Deactief"] }, + "filter": { "status": ["Draft", "Active", "Inactive"] }, "sidebar": { "enabled": true, "showMetadata": true }, "documentationUrl": "https://softwarecatalog.conduction.nl" }, - "_note": "Decomposed from the bespoke OrganisatieIndexView to a standard type:index (Phase 8): renders organisatie OR objects as a card grid via config.cardComponent=OrganisatieCard. The card keeps its inline contactpersoon toggle internally; CnIndexPage provides the toolbar, search, view-toggle and create/edit/delete dialogs. config.filter excludes organisation-merge tombstones (status='samengevoegd') from the default listing per the organisation-merge spec — a merged-away source stays readable by direct UUID lookup (OrganisatieDetail route) but never appears in this index." + "_note": "Decomposed from the bespoke OrganisatieIndexView to a standard type:index (Phase 8): renders organisatie OR objects as a card grid via config.cardComponent=OrganisatieCard. The card keeps its inline contactpersoon toggle internally; CnIndexPage provides the toolbar, search, view-toggle and create/edit/delete dialogs. config.filter excludes organisation-merge tombstones (status='merged') from the default listing per the organisation-merge spec — a merged-away source stays readable by direct UUID lookup (OrganisatieDetail route) but never appears in this index. 🔴 THIS FILTER IS COUPLED TO THE SCHEMA'S status ENUM AND WENT STALE: #520 translated the stored enum values and migrated the rows (Concept/Actief/Deactief/samengevoegd -> Draft/Active/Inactive/merged) but did NOT translate this list, so the index filtered on three values no row can hold any more. A value filter that matches nothing is not an error — OpenRegister answers 200 with total=0 — so the Organisations index rendered 'No items found' for every user and read as an empty catalogue. Measured on the running instance: ?status[]=Draft&status[]=Active returns the seeded row, ?status[]=Concept&status[]=Actief&status[]=Deactief returns total=0. Any future enum rename must move this list in the same commit." }, { "id": "OrganisatieDetail", diff --git a/tests/e2e/spec-coverage/dashboard.spec.ts b/tests/e2e/spec-coverage/dashboard.spec.ts index 5b335004..b4eb158d 100644 --- a/tests/e2e/spec-coverage/dashboard.spec.ts +++ b/tests/e2e/spec-coverage/dashboard.spec.ts @@ -99,19 +99,40 @@ test('dashboard: "Ga naar Organisaties" quick-nav button is clickable and error- // The organisaties index is genuinely reachable via the real app nav entry // "Organisations" — this is the user's actual navigation path and lands on the -// CnIndexPage list surface (Add Organisatie + Cards/Table toggle). +// CnIndexPage list surface (Add button + Cards/Table toggle). test('dashboard: "Organisations" nav entry reaches the organisaties index', async ({ page, }) => { const bag = collectAppErrors(page) await navClickTo(page, 'Organisations') const main = page.locator(APP_MAIN).first() - // Organisations is a `type: custom` page (OrganisatieIndexView), not a - // CnIndexPage — its create action reads "Add organisation" and it has no - // Cards/Table toggle. Assert the custom surface's primary create action. + + // ⚠️ THIS USED TO ASSERT A SURFACE THE PRODUCT NO LONGER RENDERS. The + // comment here claimed Organisations was a `type: custom` page + // (OrganisatieIndexView) "with no Cards/Table toggle" whose create action + // read "Add organisation". src/manifest.json decomposed it into a standard + // `type: index` page (its own `_note` records the change), so the surface + // is CnIndexPage: a heading, a Cards/Table view toggle, and an Add button + // whose label CnIndexPage derives as `'Add ' + schema.title`. + // + // The schema title is authored "Organization" while every other string in + // this app is British ("Organisations" nav entry, "Organisation + // relationships" page title) — and a deployed instance can still carry the + // older "Organisation" title, because OpenRegister's import skips a schema + // whose deployed version is not older and its escape hatch never compares + // the title. Accept either spelling of the one word rather than pin the + // test to whichever an environment happens to hold; the assertion still + // names the action and the entity, so it cannot match another page. + await expect( + main.getByRole('heading', { name: 'Organisation relationships' }).first(), + ).toBeVisible({ timeout: 30000 }) await expect( - main.getByRole('button', { name: /Add organisation/i }).first(), + main.getByRole('button', { name: /^Add Organi[sz]ation$/i }).first(), ).toBeVisible({ timeout: 30000 }) + // The view toggle exists only on the index surface — it is what + // distinguishes "landed on the index" from "landed on any page with a + // create button". + await expect(main.getByRole('button', { name: 'Table' }).first()).toBeVisible() expectNoAppErrors(bag) }) diff --git a/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts b/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts index 67321ed2..f89d2d39 100644 --- a/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts +++ b/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts @@ -146,19 +146,31 @@ test('facets: an unsupported schema is rejected with 400 naming the supported on const message = String(body?.message ?? '') // The scenario requires the error to NAME the supported schemas, not merely // to reject — so both names are asserted, not just a non-2xx. + // + // ⚠️ The supported set is `FacetService::SUPPORTED_SCHEMAS = ['module', + // 'service']`. This assertion used to look for `dienst`, the pre-#518 Dutch + // slug, and so did the 200 control below — a slug rename moved the API and + // left the test naming a schema the service has never heard of. expect( message, `error message did not name the supported schemas: ${message}`, ).toMatch(/module/) - expect(message).toMatch(/dienst/) + expect(message).toMatch(/service/) // The supported set is also machine-readable, and must be exactly the two. - expect(body?.supportedSchemas?.sort?.()).toEqual(['service', 'module']) + // ⚠️ `Array.prototype.sort()` sorts IN PLACE and returns the sorted array, + // so the expected literal has to be sorted too — comparing a sorted actual + // against `['service', 'module']` could never have held whichever names the + // service used. Copy before sorting so the response body is not mutated. + expect([...(body?.supportedSchemas ?? [])].sort()).toEqual([ + 'module', + 'service', + ]) // Control: the same endpoint shape with a SUPPORTED schema is a 200, so the // 400 above is about the schema and not about the route being broken. - const ok = await ctx.get(`${FACETS}/dienst`) - expect(ok.status(), `GET ${FACETS}/dienst returned ${ok.status()}`).toBe(200) + const ok = await ctx.get(`${FACETS}/service`) + expect(ok.status(), `GET ${FACETS}/service returned ${ok.status()}`).toBe(200) }) // ⚠️ `no-text-query-returns-facets-over-the-full-rbac-scoped-set` IS DELIBERATELY diff --git a/tests/e2e/spec-coverage/index-pages.spec.ts b/tests/e2e/spec-coverage/index-pages.spec.ts index 7a67ade8..a544b25f 100644 --- a/tests/e2e/spec-coverage/index-pages.spec.ts +++ b/tests/e2e/spec-coverage/index-pages.spec.ts @@ -40,9 +40,10 @@ interface IndexPage { } // True manifest `type: index` pages (CnIndexPage against a voorzieningen -// schema). NOTE: "Organisations" is intentionally NOT here — it is a -// `type: custom` page (component OrganisatieIndexView) with its own surface, -// covered by a dedicated test below. +// schema). NOTE: "Organisations" IS one of these now (it was decomposed from a +// bespoke `type: custom` view), but its create label spells one word +// differently depending on which schema title an environment carries, and this +// list matches labels exactly — so it keeps a dedicated test below. // ⚠️ `addLabel` is not free text. nc-vue's CnIndexPage derives its primary // create action as `'Add ' + schema.title` (CnIndexPage.vue), and the // softwarecatalog schema titles were rewritten from Dutch to English on @@ -88,34 +89,46 @@ test('index contactpersonen: the route reaches the CnIndexPage surface (toggle + expectNoAppErrors(bag) }) -// BUG (pre-existing, app config/manifest): the "Standards" index page is wired -// to the schema slug `standaard`, but NO `standaard` schema exists in the -// softwarecatalog voorzieningen register/config (the app config exposes -// organisatie/contactpersoon/contract/beoordeeling/compliancy/moduleVersie/... -// schemas but never a `standaard` one). So the page's list fetch fails with a -// console error: "Error fetching 11-standaard collection: {status: undefined, -// ...}", and the CnIndexPage list body never loads. Driving this page can -// therefore never be app-error-free until the `standaard` schema is provisioned -// (or the Standards page is removed/repointed in the manifest). Kept as a -// documented fixme so it re-activates once the schema gap is closed. Not a test -// defect — the page genuinely cannot load its data. -test.fixme('index standards: nav entry reaches the CnIndexPage surface (blocked: missing `standaard` schema)', async ({ +// ⚠️ THIS TEST'S SKIP REASON WAS NO LONGER TRUE, so it was an invisible pass. +// It was a `test.fixme` "blocked: missing `standaard` schema", on the stated +// grounds that the Standards page is wired to the schema slug `standaard` and +// no such schema exists. It is not: `src/manifest.json` binds the Standaarden +// page to `"schema": "element"`, and `element` IS provisioned — the CI seed +// enumerates it among the 36 schemas present on the instance. Verified on a +// running instance as well: /standaarden renders the index chrome and its +// create action resolves to "Add Element" (not the "Add Item" fallback the +// skipped body asserted), with zero app-origin console errors. +// +// A skip whose reason has stopped being true reads exactly like a passing +// test, so the reason is not repaired here — the test is put back to work. +test('index standards: nav entry reaches the CnIndexPage surface (toggle + add + list body)', async ({ page, }) => { const bag = collectAppErrors(page) await navClickTo(page, 'Standards') - await expectIndexSurface(page, 'Add Item') + await expectIndexSurface(page, 'Add Element') expectNoAppErrors(bag) }) // --------------------------------------------------------------------------- -// Organisations is a `type: custom` page (OrganisatieIndexView), not a -// CnIndexPage. Its surface is the custom organisations view: the primary -// "Add organisation" create action, reached by clicking the nav entry. We -// assert that custom surface mounts WITHOUT an app-origin error (the register -// sentinel now resolves, so no @resolve 404). +// Organisations. ⚠️ THIS TEST DESCRIBED A SURFACE THAT NO LONGER EXISTS: it +// asserted the bespoke `type: custom` OrganisatieIndexView and its +// "Add organisation" button. src/manifest.json decomposed that view into a +// standard `type: index` page (its own `_note` records the change), so the page +// is a CnIndexPage like every entry in INDEX_PAGES above — heading, Cards/Table +// toggle, create action, list body. +// +// It is kept as a dedicated test rather than folded into INDEX_PAGES for one +// reason: `expectIndexSurface` matches the create label EXACTLY, and this is +// the one page whose label spelling is not stable across environments. +// CnIndexPage derives it as `'Add ' + schema.title`; the repo authors that +// title "Organization" while the rest of the app is British, and a deployed +// instance can still serve the older "Organisation" because OpenRegister skips +// importing a schema whose deployed version is not older, and its +// schemaContentDiffers() escape hatch compares properties/required/ +// authorization — never the title. Accept either spelling of that one word. // --------------------------------------------------------------------------- -test('custom organisaties: nav entry reaches the OrganisatieIndexView surface', async ({ +test('index organisaties: nav entry reaches the CnIndexPage surface (toggle + add + list body)', async ({ page, }) => { const bag = collectAppErrors(page) @@ -123,12 +136,24 @@ test('custom organisaties: nav entry reaches the OrganisatieIndexView surface', const main = page.locator(APP_MAIN).first() await expect(main).toBeVisible({ timeout: 30000 }) - // The custom view exposes a primary create action. Its empty-state button - // reads "Add organisation"; assert the create affordance is present. + // Index chrome — the view toggle is what separates "this is the index" from + // "this is any page that happens to have a create button". + await expect(main.getByText('Cards', { exact: true }).first()).toBeVisible({ + timeout: 30000, + }) + await expect(main.getByText('Table', { exact: true }).first()).toBeVisible() + + // Primary create action. await expect( - main.getByRole('button', { name: /Add organisation/i }).first(), + main.getByRole('button', { name: /^Add Organi[sz]ation$/i }).first(), ).toBeVisible({ timeout: 30000 }) + // List body mounted — empty-state OR a populated list. Proves the data layer + // ran (the `@resolve` register sentinel resolved), not just the chrome. + const emptyState = main.getByText('No items found', { exact: false }).first() + const populated = main.getByText(/Showing\s+\d+\s+of\s+\d+/i).first() + await expect(emptyState.or(populated)).toBeVisible({ timeout: 30000 }) + expectNoAppErrors(bag) }) diff --git a/tests/e2e/workflows/_ui.ts b/tests/e2e/workflows/_ui.ts index 6d229b3a..1d65a1d9 100644 --- a/tests/e2e/workflows/_ui.ts +++ b/tests/e2e/workflows/_ui.ts @@ -81,15 +81,23 @@ export async function openRowActions(page: Page, token: string): Promise { /** * Open the CnIndexPage create form via the primary "Add ..." button and return * the modal/dialog locator. + * + * `addLabel` accepts a RegExp as well as a string: CnIndexPage derives the + * label as `'Add ' + schema.title`, and at least one schema title differs + * between the repo and an already-deployed instance (OpenRegister skips + * importing a schema whose deployed version is not older, and its + * schemaContentDiffers() escape hatch never compares the title). `exact` is + * meaningless for a RegExp, so it is only passed for a string. */ export async function openCreateDialog( page: Page, - addLabel: string, + addLabel: string | RegExp, ): Promise { - await indexMain(page) - .getByRole('button', { name: addLabel, exact: true }) - .first() - .click() + const byName = + typeof addLabel === 'string' + ? { name: addLabel, exact: true } + : { name: addLabel } + await indexMain(page).getByRole('button', byName).first().click() const dialog = page.locator('[role="dialog"], .modal-container').first() await dialog.waitFor({ state: 'visible', timeout: 15000 }) return dialog diff --git a/tests/e2e/workflows/organisatie-crud.spec.ts b/tests/e2e/workflows/organisatie-crud.spec.ts index a6508e12..f860f73f 100644 --- a/tests/e2e/workflows/organisatie-crud.spec.ts +++ b/tests/e2e/workflows/organisatie-crud.spec.ts @@ -1,27 +1,26 @@ // SPDX-License-Identifier: EUPL-1.2 // SPDX-FileCopyrightText: 2026 Conduction B.V. /** - * DEEP, data-dependent persistence workflow for the ORGANISATIE entity, driven - * through the bespoke `type: custom` OrganisatieIndexView (a card grid, not a - * CnIndexPage). + * DEEP, data-dependent persistence workflow for the ORGANISATION entity, driven + * through the `/organisaties` index. That page is a standard manifest + * `type: index` (CnIndexPage rendering OrganisatieCard as its card component); + * it used to be a bespoke `type: custom` OrganisatieIndexView and this file + * still described that removed surface, which is why three of its assertions + * named strings the product no longer renders. * * What is proven through the UI: * - read-persistence: an organisation SEEDED via the OpenRegister API - * RENDERS as a real card in the custom organisations view (NOT the - * "No organisations" empty-state) — direct proof the list now fetches a - * real register (the `@resolve` sentinel fix) and the bespoke view binds - * the collection; + * RENDERS as a real card in the index (NOT the "No items found" + * empty-state) — direct proof the list fetches a real register (the + * `@resolve` sentinel) AND that the page's `config.filter` still selects + * values the rows can actually hold; * - the seeded card shows the organisation's name + type; - * - the "Add organisation" affordance opens the create modal. + * - the primary create action opens the create dialog. * - * What is NOT headlessly drivable here (documented test.fixme): - * - UI-driven CREATE of an organisation. "Add organisation" opens the generic - * ObjectModal whose first step is a Catalogus -> Register -> Schema cascade. - * The Catalogus select is populated from the `catalog` collection, which is - * EMPTY in this dev container (no catalog object is provisioned), so the - * cascade can never be completed and the object cannot be saved through the - * modal. This is a dev-env data gap, not an app bug. (Create + cleanup are - * exercised here via the OR API instead.) + * What is NOT verified here: see the `test.fixme` at the bottom — its old + * reason (an ObjectModal Catalogus cascade) describes a surface that no longer + * exists, and the leg has not been re-authored against the dialog that replaced + * it. (Create + cleanup are exercised here via the OR API instead.) * * Cleanup: the seeded org carries the RUN_ID token; afterAll deletes it via the * OR deleteObject verb. @@ -98,7 +97,16 @@ test('seeded organisation renders as a card (proves the list loads real data)', // prior runs leave rows behind, so the freshly-seeded org may land on a later // page — assert that real cards render (proving the @resolve list loaded data) // rather than requiring our specific seeded row to be on the first page. - await expect(main.getByText('No organisations', { exact: false })).toHaveCount(0) + // + // ⚠️ THE EMPTY-STATE ASSERTION USED TO NAME A STRING THAT NEVER RENDERS. + // It looked for "No organisations", which was the bespoke + // OrganisatieIndexView's wording; the page is a CnIndexPage now and its + // empty state reads "No items found". `toHaveCount(0)` against a string + // nothing ever renders passes unconditionally — so when this list really + // was empty (the manifest filter named three status values #520 had + // translated out of existence), the guard that was supposed to catch it + // said nothing and the failure surfaced one line later as a missing card. + await expect(main.getByText('No items found', { exact: false })).toHaveCount(0) const cards = main.locator('[class*=organisatie], [class*=card]') await expect(cards.first()).toBeVisible({ timeout: 30000 }) expect(await cards.count()).toBeGreaterThan(0) @@ -110,13 +118,20 @@ test('seeded organisation renders as a card (proves the list loads real data)', // The create affordance opens the create modal (the modal itself cannot be // completed here — see fixme below — but the entry point works). // --------------------------------------------------------------------------- -test('"Add organisation" opens the create modal', async ({ page }) => { +test('the create action opens the create dialog', async ({ page }) => { await navClickTo(page, 'Organisations') await dismissSupportDialog(page) const main = indexMain(page) + // CnIndexPage derives this label as `'Add ' + schema.title`. The repo + // authors that title "Organization" while the rest of the app is British, + // and an already-deployed instance can still carry "Organisation" — + // OpenRegister skips importing a schema whose deployed version is not older, + // and its schemaContentDiffers() escape hatch never compares the title. So + // accept either spelling of the one word rather than pin the test to + // whichever an environment happens to hold. await main - .getByRole('button', { name: /Add organisation/i }) + .getByRole('button', { name: /^Add Organi[sz]ation$/i }) .first() .click() const modal = page @@ -142,20 +157,42 @@ test('"Add organisation" opens the create modal', async ({ page }) => { }) // --------------------------------------------------------------------------- -// UI-driven CREATE — blocked by the empty `catalog` collection in this dev -// container. The ObjectModal's first step is a Catalogus select with zero -// options, so the Register/Schema cascade can never resolve and the object -// can't be saved. Re-enable once a catalog is provisioned in the dev dataset. +// UI-driven CREATE. ⚠️ THIS SKIP'S REASON WAS UNTRUE AND HAS BEEN CORRECTED +// RATHER THAN RE-STATED. +// +// It read "blocked: empty catalog collection", on the grounds that the create +// affordance opens the legacy ObjectModal whose first step is a Catalogus -> +// Register -> Schema cascade that cannot resolve in a dev container with no +// catalog object. That surface is gone: src/manifest.json decomposed the +// bespoke OrganisatieIndexView into a standard `type: index` page, so the +// create action opens nc-vue's CnIndexPage form dialog, which is already bound +// to the register and schema from the page config and asks for no cascade at +// all — as the sibling test above records, and as a running instance confirms +// (the dialog opens on "Create Organisation" with the schema's own fields and +// a disabled Create button until the required ones are filled). +// +// So this leg is NOT blocked. It is UNVERIFIED: the body below still drives the +// removed cascade and has never been re-authored against the dialog the product +// actually renders, and the assertion it ends on ("the new card appears by +// name") cannot simply be ported, because which fields the dialog exposes is +// governed by the schema's own `visible` flags and that has to be measured +// against the CI instance rather than guessed. +// +// Recorded on the fleet board for an owner. It is deliberately NOT dressed up +// as an environment gap again — a skip whose stated reason is false reads +// exactly like a passing test, and this one hid the fact that the whole create +// path had moved. // --------------------------------------------------------------------------- -test.fixme('UI create -> new organisation card appears (blocked: empty catalog collection)', async ({ +test.fixme('UI create -> new organisation card appears (unverified: body still drives the removed ObjectModal cascade)', async ({ page, }) => { await navClickTo(page, 'Organisations') await dismissSupportDialog(page) const uiOrgName = `${RUN_ID} UI Organisatie` - const modal = await openCreateDialog(page, 'Add organisation') - // Select the (currently non-existent) catalogus, then register + schema. + const modal = await openCreateDialog(page, /^Add Organi[sz]ation$/i) + // ⚠️ Stale body — the cascade below no longer exists. Kept verbatim so the + // re-author is obvious rather than silently deleted. const catalogSelect = modal .locator('.detail-item') .filter({ hasText: 'Catalogus' }) @@ -163,7 +200,6 @@ test.fixme('UI create -> new organisation card appears (blocked: empty catalog c .first() await catalogSelect.click() await page.locator('.vs__dropdown-option').first().click() - // ... register + schema cascade + JSON editor would follow here. await modal.getByRole('button', { name: 'Add', exact: true }).first().click() await navClickTo(page, 'Organisations') await expect( diff --git a/tests/vitest/manifestFilterEnumParity.spec.js b/tests/vitest/manifestFilterEnumParity.spec.js new file mode 100644 index 00000000..a61da032 --- /dev/null +++ b/tests/vitest/manifestFilterEnumParity.spec.js @@ -0,0 +1,119 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction / SoftwareCatalog Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * A manifest page's `config.filter` values must exist in the schema enum they + * filter on. + * + * WHY THIS FILE EXISTS. `src/manifest.json`'s Organisaties page filtered on + * `status: ["Concept", "Actief", "Deactief"]`. #520 translated that enum to + * Draft/Active/Inactive/merged AND migrated the stored rows, but did not move + * the manifest, so the index filtered on three values no row could hold. + * + * ⚠️ THAT IS NOT AN ERROR ANYWHERE. OpenRegister answers a filter on a valid + * property with a non-existent value as `200 {"total": 0}` — measured on a + * running instance: `?status[]=Draft&status[]=Active` returned the seeded row, + * `?status[]=Concept&status[]=Actief&status[]=Deactief` returned `total: 0`. + * So the Organisations index rendered "No items found" and read as an empty + * catalogue rather than as a broken page: no console error, no failed request, + * nothing in the log. Three e2e tests failed on the consequences (a missing + * card, a missing create affordance) rather than on the cause. + * + * This check is the cheap half of that day's work: a filter value that is not a + * member of its property's enum is always a bug, and it is visible from two + * static files. + * + * The last case is a POSITIVE CONTROL: the checker is handed a filter that IS + * stale and must report it. Without that, a checker that silently resolves no + * schemas at all would pass this file while proving nothing — the failure mode + * that makes a green gate worthless. + */ +import { describe, expect, it } from 'vitest' +import manifest from '../../src/manifest.json' +import register from '../../lib/Settings/softwarecatalogus_register.json' + +const SCHEMAS = register?.components?.schemas ?? {} + +/** + * Collect every `config.filter` entry whose value is not a member of the + * enum declared for that property on the page's schema. + * + * Only ENUM-typed properties are judged: a filter on a free-text property + * (a uuid, a slug, a route token) has no closed value set to check against, + * and `@`-prefixed / `:`-prefixed values are route interpolations resolved at + * fetch time, not literals. + * + * @param {object} man The app manifest. + * @param {object} schemas The register's `components.schemas` map. + * @return {Array} One human-readable line per violation. + */ +function staleFilterValues(man, schemas) { + const problems = [] + for (const page of man?.pages ?? []) { + const filter = page?.config?.filter + const slug = page?.config?.schema + if (!filter || typeof filter !== 'object' || !slug) continue + + const properties = schemas?.[slug]?.properties + if (!properties) { + problems.push( + `page "${page.id}" filters on schema "${slug}", which the register does not declare`, + ) + continue + } + + for (const [property, raw] of Object.entries(filter)) { + const declared = properties?.[property]?.enum + if (!Array.isArray(declared) || declared.length === 0) continue + + for (const value of Array.isArray(raw) ? raw : [raw]) { + if (typeof value !== 'string') continue + if (value.startsWith('@') || value.startsWith(':')) continue + if (declared.includes(value)) continue + problems.push( + `page "${page.id}" filters ${slug}.${property} on "${value}", ` + + `which is not in its enum [${declared.join(', ')}]`, + ) + } + } + } + return problems +} + +describe('manifest filters address values the schema enums actually declare', () => { + it('finds at least one page with an enum-typed filter to judge', () => { + // A run that judged NOTHING prints the same "no problems" as a clean one. + const judged = (manifest?.pages ?? []).filter((page) => { + const filter = page?.config?.filter + const slug = page?.config?.schema + if (!filter || !slug) return false + const properties = SCHEMAS?.[slug]?.properties ?? {} + return Object.keys(filter).some((key) => + Array.isArray(properties?.[key]?.enum), + ) + }) + expect(judged.length).toBeGreaterThan(0) + }) + + it('has no manifest filter value outside its schema enum', () => { + expect(staleFilterValues(manifest, SCHEMAS)).toEqual([]) + }) + + it('POSITIVE CONTROL: the checker reports a filter left behind by a rename', () => { + const stale = { + pages: [ + { + id: 'Organisaties', + config: { + schema: 'organization', + // The exact list the manifest carried before this fix. + filter: { status: ['Concept', 'Actief', 'Deactief'] }, + }, + }, + ], + } + const found = staleFilterValues(stale, SCHEMAS) + expect(found).toHaveLength(3) + expect(found[0]).toContain('Concept') + }) +}) diff --git a/tests/vitest/sbomProvenanceLabel.spec.js b/tests/vitest/sbomProvenanceLabel.spec.js new file mode 100644 index 00000000..980d51ab --- /dev/null +++ b/tests/vitest/sbomProvenanceLabel.spec.js @@ -0,0 +1,154 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction / SoftwareCatalog Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * SbomComponentsPanel — the module-version record must actually reach the + * computeds that read it. + * + * WHY THIS FILE EXISTS. `SbomComponentsPanel` resolves the inspected record in + * one computed and consumes it in `moduleVersionData`, which every other + * consumer reads through. When the schema slug `moduleVersie` was translated to + * `moduleVersion`, the CONSUMER was renamed (`this.moduleVersion`) and the + * PRODUCER was not (it stayed `moduleVersie()`), so `moduleVersionData` read an + * identifier that no longer existed on the instance, silently evaluated to `{}`, + * and every derived value went empty: + * + * - `lastImportedLabel` returned `''`, so the `data-testid="sbom-provenance"` + * line is behind `v-if` and NEVER rendered — the e2e assertion + * `expect(page.getByTestId('sbom-provenance')).toBeVisible()` failed on + * `development` with "element(s) not found" even though the import itself + * had succeeded (the success note, the table and the counts all rendered); + * - `parentModuleId` returned `''`, which is the scope of the + * vulnerability-match heuristic — an empty scope matches nothing and + * reports no error. + * + * Neither symptom throws. Vue resolves an unknown property to `undefined`, and + * `undefined` takes the "no import yet" branch, which is a legitimate state — + * so the broken build is indistinguishable from a module version that has + * genuinely never been imported. + * + * WHAT THIS ASSERTS. The computeds are exercised the way Vue evaluates them: + * every entry in the component's own `computed` map is installed as a getter on + * one object, so a producer/consumer name mismatch resolves to `undefined` here + * exactly as it does in the browser. The test therefore FAILS on the broken + * code and passes on the fixed code, rather than asserting the new name. + * + * The negative control ("never imported" -> empty label) is asserted too, so a + * label that is non-empty unconditionally cannot pass this file either. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// `@conduction/nextcloud-vue` re-exports through `@nextcloud/vue`, whose +// package exports map is not resolvable in this offline suite, and +// `src/store/store.js` instantiates real Pinia stores off the same library. +// Nothing here RENDERS and nothing here talks to a server, so both are stubbed; +// what is under test is the component's own computed chain, which runs for +// real. +const stubs = vi.hoisted(() => ({ + active: null, + collections: {}, +})) + +vi.mock('@conduction/nextcloud-vue', () => ({ + CnDataTable: { name: 'CnDataTable', render: () => null }, +})) + +vi.mock('../../src/store/store.js', () => ({ + objectStore: { + getActiveObject: () => stubs.active, + getCollection: (type) => stubs.collections[type] ?? { results: [] }, + }, +})) + +const Panel = (await import('../../src/components/sbom/SbomComponentsPanel.vue')) + .default + +const OBJECT_ID = '11111111-2222-3333-4444-555555555555' +const MODULE_UUID = '99999999-8888-7777-6666-555555555555' + +/** + * Build a record the panel is supposed to find for `OBJECT_ID`. + * + * @param {object} data Extra data-bag fields. + * @return {object} An OpenRegister-shaped moduleVersion record. + */ +function record(data = {}) { + return { + uuid: OBJECT_ID, + object: { + module: MODULE_UUID, + ...data, + }, + } +} + +/** + * Install the component's real computeds as getters on a bare object, so the + * producer -> consumer chain resolves the same way Vue resolves it. + * + * @param {object} base Instance data (props / data fields). + * @return {object} A stand-in component instance. + */ +function instance(base = {}) { + const vm = { objectId: OBJECT_ID, ...base } + for (const [name, getter] of Object.entries(Panel.computed)) { + Object.defineProperty(vm, name, { + get: () => getter.call(vm), + configurable: true, + }) + } + return vm +} + +/** + * Point the shared object store at a fixed collection for this test. + * + * @param {Array} results The moduleVersion collection rows. + * @return {void} + */ +function seedStore(results) { + stubs.active = null + stubs.collections = { moduleVersion: { results } } +} + +describe('SbomComponentsPanel — the resolved module version reaches its consumers', () => { + beforeEach(() => { + seedStore([]) + }) + + it('renders the provenance label once the record carries an import stamp', () => { + seedStore([ + record({ + sbomLastImportedAt: '2026-08-16T10:00:00+00:00', + sbomFormat: 'cyclonedx-json', + sbomFileName: 'bom.json', + }), + ]) + + const label = instance().lastImportedLabel + + // Non-empty is what the `v-if` gates on, and the file name is the part + // that can only come from the resolved record — checking for the + // EXPECTED content, not merely "not empty". + expect(label).toContain('bom.json') + expect(label).toContain('CycloneDX') + }) + + it('NEGATIVE CONTROL: a module version that was never imported has no label', () => { + seedStore([record()]) + + expect(instance().lastImportedLabel).toBe('') + }) + + it('scopes the vulnerability-match heuristic to the resolved parent module', () => { + seedStore([record({ sbomLastImportedAt: '2026-08-16T10:00:00+00:00' })]) + + expect(instance().parentModuleId).toBe(MODULE_UUID) + }) + + it('NEGATIVE CONTROL: an unresolvable objectId yields no module scope', () => { + seedStore([record()]) + + expect(instance({ objectId: 'no-such-uuid' }).parentModuleId).toBe('') + }) +}) diff --git a/tests/vitest/stubs/nextcloud-l10n.js b/tests/vitest/stubs/nextcloud-l10n.js index e27ebeea..938cec06 100644 --- a/tests/vitest/stubs/nextcloud-l10n.js +++ b/tests/vitest/stubs/nextcloud-l10n.js @@ -11,6 +11,12 @@ * * Tests mutate `__setLanguage('nl')` between cases; the stub returns the * current value from each `getLanguage()` call. + * + * `translate()` is the other export components pull in (`import { translate as + * t }`). Without it `t` is `undefined` and any component computed that builds a + * label throws a TypeError — which is NOT the failure mode under test, so the + * stub returns the source string with `{placeholder}` substitution, exactly the + * shape the real package produces for an untranslated (English) string. */ let currentLanguage = 'en' @@ -19,6 +25,17 @@ export function getLanguage() { return currentLanguage } +export function translate(app, text, vars) { + if (!vars || typeof vars !== 'object') { + return String(text) + } + return String(text).replace(/\{(\w+)\}/g, (match, key) => + Object.prototype.hasOwnProperty.call(vars, key) + ? String(vars[key]) + : match, + ) +} + export function __setLanguage(lang) { currentLanguage = lang } @@ -29,4 +46,5 @@ export function __resetLanguage() { export default { getLanguage, + translate, } From 64cd529c4a26a3f006e3a3b1ae5e503b7233179a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 17 Aug 2026 01:21:55 +0200 Subject: [PATCH 2/3] docs(e2e): record the measured cause of the standards index failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un-skipping `index standards` exposed a real defect, and this records what it is so the next reader does not re-derive it — and so nobody "fixes" it the wrong way. The surface assertions pass (chrome, "Add Element", list body). The failure is `expectNoAppErrors`: `Error fetching 14-element collection`. The page config is `register: "@resolve:voorzieningen_register"` + `schema: "element"`, but `element` is bound to the OTHER register declared in the same register file — `components.registers.vng-gemma.schemas`, not `.voorzieningen.schemas`. Same family as openconnector#1275's `synchronization_run`: declaring a schema does not attach it, and only an attached schema is fetchable. ⚠️ Adding `element` to the voorzieningen register would make the request succeed and return NOTHING, because objects live per register and the GEMMA elements were imported under vng-gemma — a visible error turned into an empty list, which is an invisible pass and worse than the red. The honest fix needs a second `@resolve:` sentinel for the gemma register. `voorzieningen_register` is currently the only one (34 uses), provisioned in Application.php::boot() from the `voorzieningen_config` blob; no app-config key holds a vng-gemma register id, and tests/e2e/ci-seed.sh does not provision that register at all. Where that id lives is a config-ownership decision, so it is escalated on the board rather than guessed. No behaviour change: comment only. --- tests/e2e/spec-coverage/index-pages.spec.ts | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/e2e/spec-coverage/index-pages.spec.ts b/tests/e2e/spec-coverage/index-pages.spec.ts index a544b25f..33a44c9f 100644 --- a/tests/e2e/spec-coverage/index-pages.spec.ts +++ b/tests/e2e/spec-coverage/index-pages.spec.ts @@ -101,6 +101,43 @@ test('index contactpersonen: the route reaches the CnIndexPage surface (toggle + // // A skip whose reason has stopped being true reads exactly like a passing // test, so the reason is not repaired here — the test is put back to work. +// +// 🔴 IT IS RED, AND THE CAUSE IS MEASURED — DO NOT RE-SKIP IT. +// Un-skipping it produced a real, previously invisible defect. The surface +// assertions all hold (chrome, "Add Element", list body), and the failure is +// `expectNoAppErrors`: +// +// Error fetching 14-element collection +// +// The page config is `register: "@resolve:voorzieningen_register"` + +// `schema: "element"` — but `element` is NOT attached to the voorzieningen +// register. `lib/Settings/softwarecatalogus_register.json` binds it to the +// SECOND register in the same file: +// +// components.registers.voorzieningen.schemas (15) — no `element` +// components.registers.vng-gemma.schemas (5) — element, model, +// property-definition, +// relation, view +// +// So the page addresses schema `element` under a register that does not carry +// it. Same family as openconnector#1275's `synchronization_run`: declaring a +// schema does not attach it, and only an attached schema is fetchable through +// /api/objects/{register}/{schema}. +// +// ⚠️ THE OBVIOUS FIX IS THE WRONG ONE. Adding `element` to +// `registers.voorzieningen.schemas` would make the request succeed and return +// NOTHING — objects live per register, and the GEMMA elements were imported +// under vng-gemma. That converts a visible error into an empty list, i.e. an +// invisible pass, which is worse than this red. +// +// The honest fix is to point the page at the register that holds the data, +// and that needs a second `@resolve:` sentinel: `voorzieningen_register` is +// currently the ONLY one (34 uses), it is provisioned in +// lib/AppInfo/Application.php::boot() from the `voorzieningen_config` blob, +// and NO app-config key holds a vng-gemma register id — nor does +// tests/e2e/ci-seed.sh provision that register at all. Choosing where that id +// lives is a config-ownership decision, not an E2E repair, so it is escalated +// on the fleet board rather than guessed at here. test('index standards: nav entry reaches the CnIndexPage surface (toggle + add + list body)', async ({ page, }) => { From e34975e81c338b67a42bc5a6f1ed9fb5534545fb Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 17 Aug 2026 01:24:06 +0200 Subject: [PATCH 3/3] style(e2e): satisfy prettier and eslint on the files this branch touched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `quality / Frontend Check (format)` was the one check this branch INTRODUCED against `development` — prettier disagreed with two of my line breaks. Fixed by running the repo's own `prettier --write` on exactly those two files, plus the two eslint errors on files this branch added: - perfectionist/sort-imports — the register import must precede the manifest import in the new manifest/enum parity spec; - prefer-object-has-own — `Object.hasOwn()` in the l10n stub. Re-verified after the change: `prettier --check "**/*.{js,ts,vue,css,scss}"` reports "All matched files use Prettier code style!", eslint on the four touched/added files is silent, and both new vitest specs still pass 7/7 — including the manifest guard's positive control, so the reformat did not turn the instrument off. --- tests/e2e/spec-coverage/gemma-faceted-search.spec.ts | 5 +---- tests/vitest/manifestFilterEnumParity.spec.js | 2 +- tests/vitest/stubs/nextcloud-l10n.js | 4 +--- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts b/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts index f89d2d39..805e547d 100644 --- a/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts +++ b/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts @@ -162,10 +162,7 @@ test('facets: an unsupported schema is rejected with 400 naming the supported on // so the expected literal has to be sorted too — comparing a sorted actual // against `['service', 'module']` could never have held whichever names the // service used. Copy before sorting so the response body is not mutated. - expect([...(body?.supportedSchemas ?? [])].sort()).toEqual([ - 'module', - 'service', - ]) + expect([...(body?.supportedSchemas ?? [])].sort()).toEqual(['module', 'service']) // Control: the same endpoint shape with a SUPPORTED schema is a 200, so the // 400 above is about the schema and not about the route being broken. diff --git a/tests/vitest/manifestFilterEnumParity.spec.js b/tests/vitest/manifestFilterEnumParity.spec.js index a61da032..58185de8 100644 --- a/tests/vitest/manifestFilterEnumParity.spec.js +++ b/tests/vitest/manifestFilterEnumParity.spec.js @@ -29,8 +29,8 @@ * that makes a green gate worthless. */ import { describe, expect, it } from 'vitest' -import manifest from '../../src/manifest.json' import register from '../../lib/Settings/softwarecatalogus_register.json' +import manifest from '../../src/manifest.json' const SCHEMAS = register?.components?.schemas ?? {} diff --git a/tests/vitest/stubs/nextcloud-l10n.js b/tests/vitest/stubs/nextcloud-l10n.js index 938cec06..163eeaac 100644 --- a/tests/vitest/stubs/nextcloud-l10n.js +++ b/tests/vitest/stubs/nextcloud-l10n.js @@ -30,9 +30,7 @@ export function translate(app, text, vars) { return String(text) } return String(text).replace(/\{(\w+)\}/g, (match, key) => - Object.prototype.hasOwnProperty.call(vars, key) - ? String(vars[key]) - : match, + Object.hasOwn(vars, key) ? String(vars[key]) : match, ) }