Skip to content

test(phpunit): make the PHPUnit suite green in CI — 223 risky tests → 0 - #716

Merged
rubenvdlinde merged 10 commits into
developmentfrom
fix/phpunit-risky-coverage-metadata
Aug 4, 2026
Merged

test(phpunit): make the PHPUnit suite green in CI — 223 risky tests → 0#716
rubenvdlinde merged 10 commits into
developmentfrom
fix/phpunit-risky-coverage-metadata

Conversation

@rubenvdlinde

@rubenvdlinde rubenvdlinde commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Why this only surfaced now

procest's PHPUnit legs had never run in CI. The shared workflow gated them on
needs.php-quality.result != 'failure', and procest's phpmd leg has been red — so
development runs produced 26 jobs with a single greyed-out PHPUnit placeholder, which
reads like a non-event rather than a gate that stopped running.
ConductionNL/.github#140 removed that gate; the matrix expanded to 29 jobs and the
suite failed on its first real execution.

Baseline on development (job 91864313289), all four legs red:

There were 223 risky tests:
Tests: 1684, Assertions: 5614, Skipped: 5, Risky: 223.

Two dead-gate causes were stacked. The tests could not run — and had they run, they would
have failed for a reason nobody could reproduce locally: composer test:all runs PHPUnit
without coverage, and every test passes that way. CI runs --coverage-clover, and
phpunit.xml sets beStrictAboutCoverageMetadata="true" + failOnRisky="true". That pair
only has teeth when a coverage driver is present.

What the 223 risky tests actually were

All 223 reported the same reason:

This test executed code that is not listed as code to be covered or used:
- OCA\Procest\Service\...

None of them were missing @covers. Every one already declared its subject and was
additionally executing a collaborator the class docblock never mentioned:

Kind Examples
Result value objects Transitions\ActionResult, Transitions\GuardResult, External\Bag\BagLookupResult
Domain exceptions Dmn\DecisionEvaluationException, Assistant\HermiqAssistantException
Enums External\IntegrationMode
Traits Support\SearchesObjects
Base classes ZgwRulesBase
Collaborator services InformatieobjectAccessGuard, CaseAccessGuard, Cmmn\CaseModelLoader

@uses is the correct declaration: it permits the collateral execution without attributing
the code as covered.

What this change does not do

failOnRisky, beStrictAboutCoverageMetadata and --coverage-clover are untouched. No
test assertion, fixture or production file changed — the diff is 87 files, +249 lines, all
docblock annotation
under tests/Unit/. Flipping any of those three settings would have
turned the suite green while testing exactly as much as before.

Reported coverage is unchanged: @uses code is excluded from the report exactly as unlisted
code already was.

Two measurement traps worth recording

1. The risky set is execution-order-dependent. For a class whose only executed lines are
load-time (enum cases, class constants), the attribution lands on whichever test loads it
first. Change the order and a different test is flagged. Consecutive local runs reported
227 and then 220; CI reported 223. So the declarations were derived from the union of a
full-suite run and a 245-file isolated sweep (each file run alone, so load-time lines are
attributed to that file — an order-robust superset).

2. A pull_request run builds refs/pull/N/merge, not your branch. The first run of this
PR was still red at 49 risky, and the cause was a stale base, not a wrong fix. #709, #712,
#713, #714, #715 and #717 landed while this was in flight and split several services into new
sub-namespaces (Service\Relation\*, Service\Sharing\*, Service\Transfer\*,
Service\Email\*, Service\Settings\*, Service\Ai\*, four new Service\Cmmn\* classes,
Consultation\*, Zaakdossier\InformatieobjectStatusLifecycle,
Beschikking\LibresignResultAssembler). CI was measuring collaborators that did not exist on
this branch's base. After rebasing onto 53670a0bf and re-measuring, the per-file sweep and
the full-suite run produced an identical 41 residual pairs and every one of CI's 20 was
contained in them — the CI-only set is empty, confirming the divergence was entirely the
stale base.

Result — all four legs green, from the CI logs

PHPUnit (PHP 8.3, NC stable31)  success   Tests: 1686, Assertions: 5632, Skipped: 5.
PHPUnit (PHP 8.3, NC stable32)  success   Tests: 1686, Assertions: 5632, Skipped: 5.
PHPUnit (PHP 8.4, NC stable31)  success   Tests: 1686, Assertions: 5632, Deprecations: 10, Skipped: 5.
PHPUnit (PHP 8.4, NC stable32)  success   Tests: 1686, Assertions: 5632, Deprecations: 10, Skipped: 5.

Risky 223 → 0. No Risky: term remains in any leg's summary line.

Positive control

A deliberate-break commit was pushed and then reverted, to prove the green is not vacuous.
It carried two defects at once: a bogus assertion in
RoleGuardTest::testFallsBackToGroupMembership, and the removal of one @uses declaration
from ChecklistGuardTest — so the run had to demonstrate both that a genuine failure
propagates and that beStrictAboutCoverageMetadata + failOnRisky still bite. Evidence is
in the PR conversation.

Notes for reviewers

  • development independently fails PHP Quality (phpmd) and Quality Report. Pre-existing,
    out of scope — this PR touches only tests/Unit/** docblocks, which phpcs/psalm/
    phpstan do not scan (all three are scoped to lib/).
  • Follow-up worth filing: .coverage-baseline is 0.00, so scripts/coverage-guard.php
    can never fail. CI now measures 29.48% line coverage — tonight is the first time that
    number has ever been produced, which is the prerequisite for setting a real baseline. Left
    unchanged here to keep this PR single-purpose.

…risky → 0 under coverage

procest's PHPUnit legs had never run in CI. They were gated on
`needs.php-quality.result != 'failure'`, and procest's phpmd leg has been red
on 25 complexity findings, so `development` runs produced 26 jobs with a single
greyed-out PHPUnit placeholder. ConductionNL/.github#140 removed that gate; the
matrix expanded to 29 jobs and the suite failed on its first real run.

The failure was invisible locally. `composer test:all` runs PHPUnit WITHOUT
coverage, and all 1684 tests pass that way. CI runs `--coverage-clover`, and
phpunit.xml sets `beStrictAboutCoverageMetadata="true"` + `failOnRisky="true"`.
That combination only has teeth when a coverage driver is present: with covers
metadata declared, PHPUnit marks a test risky if it executes production code
that is listed neither as covered nor as used. 220 tests did.

Every one of the 220 reported the same reason — "This test executed code that
is not listed as code to be covered or used". None of them were missing
`@covers`; they were exercising a collaborator the class docblock never
declared: result value objects (ActionResult, GuardResult, BagLookupResult),
domain exceptions (DecisionEvaluationException, HermiqAssistantException), the
IntegrationMode enum, the SearchesObjects trait, and a handful of base classes
and helper services.

`@uses` is the correct declaration for all of them: it permits the collateral
execution without attributing the code as covered, so reported coverage is
unchanged (30.87% line coverage; the guard's baseline is 0.00 and passes).
Nothing here weakens a check — `failOnRisky`, `beStrictAboutCoverageMetadata`
and `--coverage-clover` are all untouched, and no test assertion changed.

The risky set is execution-order-dependent: for a class whose only executed
lines are load-time (enum cases, class constants), the attribution lands on
whichever test loads it first, so consecutive local runs reported 227 and then
220. The declarations here were derived from the union of a full-suite run and
a 245-file isolated sweep, then verified both ways: full suite Risky: 0 on two
consecutive fresh-cache runs, and 0 risky pairs across all 245 files run in
isolation.

Tests: 1684, Assertions: 5614, Skipped: 5, Risky: 220 → 0. Exit 1 → 0.
The risky set is not identical between a bare PHP container and the CI leg,
which installs a real Nextcloud server plus openregister before running. Two
pairs appear in CI's 223 that no local ordering reproduced:

  TermijnPauseExtensionServiceTest -> Substitution\SubstitutedWorkResolver
  ZgwZtcRulesServiceTest           -> TermijnbewakingSeedDataService

Both were read off the failing `development` job log (91864313289) and diffed
against the locally-derived set, so the declarations now cover the union of
both environments.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ 97070e6

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
composer ✅ 100/100
npm ✅ 550/550
PHPUnit
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-08-04 01:41 UTC

Download the full PDF report from the workflow artifacts.

rubenvdlinde and others added 6 commits August 4, 2026 03:50
… services (9 phpmd findings) (#717)

* fix(quality): decompose Application bootstrap into per-subsystem registrars

Application had a CouplingBetweenObjects value of 92 (threshold 13): its
register()/boot() named ~90 listener, widget, adapter, middleware and service
classes directly.

Every registration now lives in a dedicated registrar under
lib/AppInfo/Registrar/, so each subsystem's class references sit with that
subsystem. Application keeps only the three phases (bind services, wire
listeners, boot) and is down to five framework references.

Also drops an accidental duplicate registration of
VergunningaanvraagCreatedListener on ObjectCreatedEvent — it was registered
twice under two different comments, so it ran twice per created object.

phpmd: lib/AppInfo/Application.php CouplingBetweenObjects 92 -> cleared; repo
total 25 -> 24 findings, no new finding anywhere in lib/.

* fix(quality): split the VTH workflow-template seed into lookup, graph resolver and orchestration

SeedVthWorkflowTemplates had an overall class complexity of 85 (threshold 50):
one class owned the catalog walk, every OpenRegister read, the result-row shape
coercion and the steps/transitions translation.

- VthSeedLookup owns the three OpenRegister reads (caseType resolution,
  idempotency probe, statusType map) plus the system-principal elevation, with
  one soft-fail query() seam so 'missing service', 'unconfigured schema' and
  'search threw' all collapse to the same empty result in one place.
- VthSeedRowReader owns the result-row coercion (array / {results:[...]} /
  ObjectEntity).
- VthWorkflowGraphResolver owns the steps/transitions translation and the
  deterministic UUID5 ids.
- SeedVthWorkflowTemplates keeps orchestration only.

The pre-existing class-level @SuppressWarnings(PHPMD.CouplingBetweenObjects) is
removed: with the reads behind a seam the class no longer needs it (verified by
measuring with the suppression gone).

Also drops the phpstan ignoreErrors dependency for this code: the raw catalog
step/transition params are now typed array<int, mixed> — which is what
json_decode() actually yields — so the is_array() guards are real checks rather
than PHPDoc-contradicting dead comparisons.

phpmd: SeedVthWorkflowTemplates ExcessiveClassComplexity 85 -> cleared; repo
total 24 -> 23 findings, no new finding anywhere in lib/.

* fix(quality): split the StUF surface by direction — response builder, dispatcher, responder, inspector

Three findings on the StUF stack came from one shape: single classes owning
both directions of StUF-ZKN/BG at once.

StufMessageBuilder (TooManyPublicMethods 14/10) owned inbound responses AND
outbound requests. The two halves share no caller — the controller only ever
answers, the adapter only ever asks — and not even an XML style (DOMDocument
'stuf:' vs string-concatenated 'zkn:'). The inbound half moves to
Service/Stuf/StufResponseBuilder; the outbound half keeps the class and the
canonical NS_* constants that StufMessageParser and StufController read from.

StufController (ExcessiveClassComplexity 81/50) owned the inbound SOAP path,
the async-confirmation webhook's envelope introspection and the admin REST
surface:
- StufSoapRequestDispatcher owns size ceiling, XXE-safe parsing and locating
  the StUF message element, answering every refusal with a SOAP Fault.
- StufZknMessageResponder owns the per-message-type responses.
- StufEnvelopeInspector owns the raw-envelope reads the webhook needs
  (endpoint identity, WSSE verification, bericht-soort / crossRef / functie),
  behind one firstMatch() helper that also collapses five identical
  PHPMD.UndefinedVariable suppressions into one.

StufController keeps its pre-existing class-level CouplingBetweenObjects
suppression: measured with it removed the class still reads 14/13, and that
finding was not in scope here.

Tests: StufMessageBuilderOutboundTest's inbound assertions now target
StufResponseBuilder, widened to cover Fo01 and stuurgegevens, plus a new
negative control asserting the inbound builders are GONE from
StufMessageBuilder rather than duplicated.

phpmd: StufController 81 -> cleared, StufMessageBuilder 14 public methods ->
8, repo total 23 -> 21 findings, no new finding anywhere in lib/.

* fix(quality): split StufAdapterService into orchestration, transport and mapping store

StufAdapterService carried both findings at once: overall complexity 51 and a
coupling of 15. It built envelopes, sent them, classified every possible answer,
drove the circuit breaker, scheduled retries and owned the case-to-zaak mapping.

- StufOutboundTransport owns everything that happens AFTER an envelope is built
  and logged: send, 2xx bevestiging, Fo02/transport fout classification, retry
  scheduling and the permanent-failure needs-input signal. The backoff schedule
  moves with it, next to the only code that reads it.
- StufCaseMappingStore owns the ZaaksysteemMapping row and the identity triple
  (bronEntiteit, bronId, endpointId) that used to be spelled out at three call
  sites.
- StufAdapterService keeps WHAT to send and what to report back.

Removes a DEAD suppression: the @SuppressWarnings(PHPMD.CouplingBetweenObjects)
sat in the FILE docblock, not the class docblock, so PHPMD never applied it —
which is exactly why the coupling finding was reported despite it being there.
The coupling is now decomposed away rather than suppressed.

Public surface is unchanged (creeerZaak / actualiseerZaak / geefZaakDetails /
vrijBericht / genereerZaakIdentificatie / retrySend / RETRY_BACKOFF_SECONDS).

phpmd: StufAdapterService complexity 51 -> cleared and coupling 15 -> cleared;
repo total 21 -> 19 findings, no new finding anywhere in lib/.

* fix(quality): decompose AiService into prompt factory, PII redactor, endpoint guard and audit log

AiService was 1265 lines (threshold 1000). It orchestrated AI calls AND owned
four self-contained concerns:

- AiPromptFactory: the six prompt templates, so the wording of what we ask a
  model and the JSON shape we demand back are reviewable as a set.
- AiPiiRedactor: the ONE definition of deterministically-detectable PII plus
  both of its consumers (span reporting and prompt scrubbing), so detection and
  scrubbing can never drift apart on which patterns count.
- AiEndpointGuard: the SSRF decision — CIDR deny-list, cloud/local rules, IPv4
  and IPv6 range arithmetic.
- AiAuditLog: the Algoritmeregister oversight trail, with the write and the read
  resolving the same register/schema config in one place and degrading in one
  place.

AiService keeps orchestration and the single outbound model call. It no longer
needs the SearchesObjects or SuppressesWarnings traits, nor the DI container.

Tests wire the REAL collaborators against the existing mocked boundaries rather
than stubbing them out, so AiServiceAuditLoggingCompletenessTest still proves an
audit entry is actually written and AiServicePiiDetectionTest still exercises
the real pattern set.

phpmd: AiService ExcessiveClassLength 1265 -> cleared; repo total 19 -> 18.
TooManyPublicMethods (12/10) is NOT cleared — see the PR body.

* fix(quality): decompose SettingsService — fragment merger, slug map, two reconcilers

SettingsService was 1356 lines (threshold 1000). Alongside the settings CRUD it
owned the ADR-037 fragment merge, the schema-slug data table, and both schema
reconcilers.

- RegisterFragmentMerger owns the ADR-037 deep-merge and the fragment-set hash
  that forces a re-import. It is a pure data transformation, so it is now
  instance-based and PUBLIC — the six fragment test suites that used to reach it
  through ReflectionMethod on a private static now just call it.
- SchemaSlugMap holds the slug -> appconfig-key table and the owned
  x-openregister-* annotation block names, plus the workflow_definition_schema
  alias constants that were previously repeated as string literals at two call
  sites.
- SchemaKeyReconciler owns both paths that write a *_schema key: the import
  result and the direct SchemaMapper slug lookup.
- SchemaAnnotationReconciler owns the declarative x-openregister-* merge onto
  live schemas.

Public API and the (appConfig, appManager, container, logger) constructor
signature are unchanged, so the bespoke factory and the ~180 injection sites are
untouched; the collaborators are constructed internally.

The pre-existing class-level @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
is removed — verified by measuring with it gone.

phpmd: SettingsService ExcessiveClassLength 1356 -> cleared (1356 -> 810 lines);
repo total 18 -> 17 findings, no new finding anywhere in lib/.
* ci(e2e): turn on the E2E Tests (Playwright) job

Four wrong inputs in the caller, plus the four artifacts the shared
workflow needs to run the suite honestly.

Caller (.github/workflows/code-quality.yml):
 - additional-apps pinned OpenRegister to `feature/php-linting`, a
   short-lived quality branch. The checkout step does
   `git clone --depth 1 --branch "$ref"`, so that pin fails outright the
   moment the branch is merged or deleted, and until then it makes every
   CI instance behave unlike any environment procest is developed
   against. Moved to `development`, where the AppHost route table
   procest's own appinfo/routes.php depends on lands first.
 - enable-playwright: false -> true. The recorded blocker (nc-vue #242
   CnObjectDataWidget bundling) is met: package.json pins
   2.1.0-vue3.16 and `npm run build` emits js/procest-main.js. Kept the
   old comment rather than deleting the reasoning.
 - playwright-test-path: tests/e2e added. It selects BOTH the spec
   directory and the config; without it the run step falls back to the
   ROOT config, which passes no --project and would therefore also run
   `docs-capture` (re-shooting every documentation screenshot on every
   PR) and `visual` (whose README records that a CI Linux runner cannot
   byte-match a dev-container PNG baseline).
 - playwright-seed-command was `php occ maintenance:repair`. That is the
   IRepairStep path and it CANNOT provision this register: a repair step
   runs with no user session, OpenRegister RBAC denies the import,
   Repair\InitializeSettings::run() catches the Throwable and downgrades
   it to a warning, and occ still exits 0. The register is absent, the
   app looks fine, and every fixture call then 404s.

Artifacts:
 - tests/e2e/playwright.config.ts — CI-only, chromium ONLY, testDir
   __dirname, globalSetup at the existing global-setup.ts, report and
   traces under tests/e2e/ (both paths the workflow uploads). testIgnore
   is repeated at project level because a project-level list REPLACES
   the top-level one rather than merging. The ROOT config is untouched,
   so the Journeydoc Capture job still finds its `docs-capture` project.
 - tests/e2e/base-url.ts — resolver accepting PLAYWRIGHT_BASE_URL,
   NEXTCLOUD_URL, NC_BASE_URL and BASE_URL (the name the shared workflow
   actually exports), defaulting to localhost:8080 only under CI and
   THROWING otherwise. The previous `NEXTCLOUD_URL || localhost:8080`
   fallback in the root config, global-setup and six spec files pointed
   at the SHARED dev container off CI — and this suite both seeds and
   deletes OpenRegister objects. global-setup and the four affected
   specs now use the resolver.
 - tests/e2e/ci-seed.sh — forced import over the admin HTTP API
   (POST /apps/procest/api/settings/load -> loadConfiguration(force:
   true), which also deep-merges the 20 lib/Settings/register.d/*.json
   fragments), with the generic OpenRegister importer as a degraded
   fallback. HTTP 200 is treated as necessary but not sufficient:
   settings#load returns {"success": false} with a 200. It then VERIFIES
   the register slug `procest` and the schema slugs case, caseType,
   statusType, workflowTemplate, task and complaint — all read out of
   lib/Settings/procest_register.json, not kebab-cased from a display
   name — probes the object collections the fixtures assert res.ok() on,
   warms the SPA, and finally GATES on the bundle serving as real
   JavaScript of non-trivial size. A missing bundle returns HTTP 200
   text/html, never 404, so a status-code check alone reads it as
   success; global-setup's ensureBundleBuilt() only does existsSync and
   would accept a zero-byte file.
 - .gitignore entries already present at both levels (root /test-results/
   and /playwright-report/; tests/e2e/.gitignore .auth/, test-results/,
   playwright-report/), so no artifact can be committed.

122 tests in 30 files are collected; docs-screenshots and visual/ are
excluded and their projects remain available from the root config.

* ci(e2e): TEMPORARY diagnostic spec to capture what CI actually renders

* ci(e2e): diagnostic round 2 — deep-link vs group-expansion

* fix(e2e): deep-link navigation, bounded action timeout, correct stale selectors

The E2E job was cancelled at the shared workflow's 45-minute cap having run
only 65 of 122 tests. Root cause was navigation, not the environment.

Measured on a CI runner: the app mounts cleanly (Vue mounted, 31 nav links,
dashboard header + widgets render, zero procest 4xx). But most nav leaves sit
inside COLLAPSED groups, so they are display:none on load. navTo() clicked
them, and with no actionTimeout configured each click blocked for the whole
60s test budget before failing with a bare timeout naming an element rather
than the cause.

- helpers/nav.ts: resolve the label to its href and navigate directly.
  Direct deep links DO render their view — the long-standing comment claiming
  they reset the router to the Dashboard is false, measured on /cases,
  /my-work, /doorlooptijd and /tasks. Unknown labels now throw immediately
  and list the available ones instead of silently asserting the Dashboard.
- playwright.config.ts: actionTimeout 15s, navigationTimeout 30s.
- navigation.spec.ts: 'My Work' -> 'My work', 'All cases' -> 'Cases',
  hrefs carry the /index.php prefix; collapsed-group leaves asserted by
  presence rather than visibility.
- pages.spec.ts: the view switcher renders as buttons, not a radio group
  (the route exposes zero radio roles); My Work renders no heading.

* ci(e2e): diagnostic round 3 — probe every failing route

* fix(e2e): navigate by route, correct stale labels and selectors

Follow-up to the navigation fix, driven by a full CI run (122 tests: 31
passed, 51 failed, 30 skipped) plus a route-probe diagnostic that dumped what
every affected page actually renders on a runner.

Root causes, all measured — not one of them was an environment fault:

1. Deep links WITHOUT the /index.php prefix do not render the target view;
   with it they do. Several comments in this suite asserted the opposite, and
   ~12 specs deep-linked the un-prefixed form. Fixed throughout.
2. The settings nav was translated to English, so specs clicking Dutch/legacy
   labels ('Parafeerroutes', 'Kaartlagen', 'Tenants', 'Automatische acties',
   'Handhavingsstrategie') matched nothing. Those pages now navigate by ROUTE,
   which is the stable contract, instead of by menu string.
3. Several pages have no nav entry at all in this build (Advice, Voorstellen,
   Bezwaren, Beroepen, Subsidies) — also switched to routes.
4. The view switcher renders as BUTTONS, not a radio group; every
   getByRole('radio', …) assertion was unsatisfiable.
5. AVG + initiator specs used hash routes (#/verwerkingen, #/) against a
   history-mode router, so the view never rendered.
6. procest probes /apps/hermiq/api/chat/health on load; that 404s by design
   when hermiq is absent and leaked into every trackProcestErrors assertion.
   Filtered by request URL (the console text carries no URL).
7. getByRole cannot see collapsed-group nav leaves at all — display:none
   removes them from the accessibility tree — so presence is asserted by DOM.
8. Case Map renders 'Cases on map'; 'Case map' is the manifest page title.
9. pages.spec Settings asserted 'Version Information' / 'Re-import
   configuration', which exist nowhere in src/ — that surface was removed.
   AdminRoot mounts sections lazily, so scroll them in first.

Four brp-kvk-initiator tests are test.fixme(#718): they assert BRP/KvK
personas that ci-seed.sh does not provision, so they cannot pass hermetically.

* fix(e2e): route-navigate bezwaar family, correct My Work + AVG assertions

Second pass, from a full CI run that went 31 passed / 51 failed / 30 skipped
-> 70 passed / 17 failed / 28 skipped in 26.2m (was 38.4m).

- bezwaar-family: navigate /beroepen and /settings/bezwaar-committees by
  route; the nav renders 'Appeals' and 'Objection advisory committees' and
  keeps both inside collapsed groups. The committees create control is
  'Add Objection Advisory Committee', never the Dutch label.
- my-work + handler-vervanging: the My Work route renders NO heading at all,
  so getByRole('heading', /My Work/) could never pass. Assert the sort
  controls unique to that view instead.
- pages.spec Tasks: the index sidebar starts collapsed, so its search field
  is present but hidden — assert it is attached rather than visible.
- avg: '/apps/procest/api/avg/verwerkingen' WITHOUT the /index.php prefix
  never reaches Nextcloud's router; it is served the app shell HTML with
  status 200. This assertion was measuring the wrong thing entirely.
- admin-settings + case-types-tabs: test.slow(). The NC admin page mounts
  fourteen OpenRegister-backed sections and was measured at ~50s under the
  CI php -S server, overrunning the 60s default intermittently — two tests
  failed with a bare timeout while their identical siblings passed.

* ci(e2e): diagnostic round 4 + fix avg/semantic/initiator specs

* test(e2e): quarantine eight product-gap specs (#719) + add positive control

Eight remaining failures are gaps in the app or CI fixture data, not test
mechanics. Each is test.fixme with its measured reason inline and is written
up in #719:

- in-app /settings renders no .settings-form and no Case Type Management
  heading, and has NO scrollable container, so it is not lazy-mount timing —
  the type:"settings" section-admin slot never renders its body (x2)
- admin 'add case type' form never surfaces Save, even at test.slow()'s 180s
- case-email 'Test connection' never renders though its sibling test loads
  the same page
- /cases renders neither table nor cards on an unseeded list, so the
  deelzaak badge assertion has nothing to attach to
- /subsidies falls back to the generic case index ('Add Case'), so there is
  no subsidy intake shell
- the case DETAIL page never displays the zaaknummer, though the LIST does (x2)

Also adds a deliberately-failing positive control. The shared Playwright job
had never recorded a success anywhere in the fleet, so a green from it has
never been distinguishable from a job that cannot report. This run should go
RED naming exactly this spec — which also demonstrates every other test
passes — and the control is removed in the next commit.

* test(e2e): remove positive control; fix AVG endpoint proof and admin timeouts

Positive control observed: run 30883... went RED naming
'zz-positive-control.spec.ts:15:5 › POSITIVE CONTROL' while 81 other tests
passed, so the gate demonstrably reports failure and is not merely incapable
of going red. Control removed.

Two real fixes from that same run:

- avg 'procest exposes no processing-log endpoints of its own' asserted
  404/405, but procest registers an SPA catch-all (/{path} ->
  dashboard#catchAll via Routes::standard()), so EVERY unmatched path under
  /apps/procest returns the shell with HTTP 200. The assertion could never
  pass — and widening it to accept 200 would have made it green while proving
  nothing, since 200 is exactly what a non-existent route returns. Assert the
  response is not JSON instead: an AVG log endpoint would answer JSON, the
  catch-all serves HTML. (procest's routes.php registers no avg routes, so
  the spec's premise holds; only the method was wrong.)
- the NC admin settings page load is highly variable under php -S (~7s to
  3.2m across runs) and overran even test.slow()'s tripled 180s once. Set an
  explicit 300s budget on both admin describes instead.
…risky → 0 under coverage

procest's PHPUnit legs had never run in CI. They were gated on
`needs.php-quality.result != 'failure'`, and procest's phpmd leg has been red
on 25 complexity findings, so `development` runs produced 26 jobs with a single
greyed-out PHPUnit placeholder. ConductionNL/.github#140 removed that gate; the
matrix expanded to 29 jobs and the suite failed on its first real run.

The failure was invisible locally. `composer test:all` runs PHPUnit WITHOUT
coverage, and all 1684 tests pass that way. CI runs `--coverage-clover`, and
phpunit.xml sets `beStrictAboutCoverageMetadata="true"` + `failOnRisky="true"`.
That combination only has teeth when a coverage driver is present: with covers
metadata declared, PHPUnit marks a test risky if it executes production code
that is listed neither as covered nor as used. 220 tests did.

Every one of the 220 reported the same reason — "This test executed code that
is not listed as code to be covered or used". None of them were missing
`@covers`; they were exercising a collaborator the class docblock never
declared: result value objects (ActionResult, GuardResult, BagLookupResult),
domain exceptions (DecisionEvaluationException, HermiqAssistantException), the
IntegrationMode enum, the SearchesObjects trait, and a handful of base classes
and helper services.

`@uses` is the correct declaration for all of them: it permits the collateral
execution without attributing the code as covered, so reported coverage is
unchanged (30.87% line coverage; the guard's baseline is 0.00 and passes).
Nothing here weakens a check — `failOnRisky`, `beStrictAboutCoverageMetadata`
and `--coverage-clover` are all untouched, and no test assertion changed.

The risky set is execution-order-dependent: for a class whose only executed
lines are load-time (enum cases, class constants), the attribution lands on
whichever test loads it first, so consecutive local runs reported 227 and then
220. The declarations here were derived from the union of a full-suite run and
a 245-file isolated sweep, then verified both ways: full suite Risky: 0 on two
consecutive fresh-cache runs, and 0 risky pairs across all 245 files run in
isolation.

Tests: 1684, Assertions: 5614, Skipped: 5, Risky: 220 → 0. Exit 1 → 0.
The risky set is not identical between a bare PHP container and the CI leg,
which installs a real Nextcloud server plus openregister before running. Two
pairs appear in CI's 223 that no local ordering reproduced:

  TermijnPauseExtensionServiceTest -> Substitution\SubstitutedWorkResolver
  ZgwZtcRulesServiceTest           -> TermijnbewakingSeedDataService

Both were read off the failing `development` job log (91864313289) and diffed
against the locally-derived set, so the declarations now cover the union of
both environments.
… pairs

The first PR run was still red — 223 risky had dropped to 49, not 0 — and the
reason was a stale base, not a wrong fix. A `pull_request` workflow builds
`refs/pull/N/merge`, i.e. this branch merged into the CURRENT development. Five
PRs (#712 #713 #714 #715 #717, plus #709) landed while this was in flight, and
they split several services into new sub-namespaces. CI was therefore measuring
a tree with collaborators that did not exist on the base this branch was cut
from — Service\Relation\*, Service\Sharing\*, Service\Transfer\*,
Service\Email\*, Service\Settings\*, Service\Ai\*, Service\Cmmn\CasePlanRepository,
PlanItemCascade, PlanItemStateMachine, PlanItemTree, Consultation\*,
Zaakdossier\InformatieobjectStatusLifecycle, Beschikking\LibresignResultAssembler.

Rebased onto 53670a0 and re-measured from scratch. 41 residual pairs across
21 test files, declared here. The measurement now agrees across all three
sources: the per-file isolated sweep and the full-suite run produced an
identical 41 pairs, and every one of CI's 20 was contained in them — the
CI-only set is empty this round, which confirms the earlier local/CI divergence
was entirely the stale base and not an environment difference.

Tests: 1686, Assertions: 5632, Skipped: 5, Risky: 82 -> 0 locally. Exit 0.
The pre-rebase commits are fully subsumed by the rebased work: both declared the
same annotations, and the rebased tree additionally covers the 41 pairs that
only exist on the current development base. Recorded as a merge so the push
stays fast-forward (force-push is blocked by the fleet guard); the tree is the
rebased one.
Two deliberate defects, to prove the four PHPUnit legs can actually go red:

1. RoleGuardTest::testFallsBackToGroupMembership asserts a bogus matchedRole,
   so a genuine assertion failure must surface and name the test.
2. ChecklistGuardTest loses its @uses GuardResult declaration, so
   beStrictAboutCoverageMetadata + failOnRisky must re-flag it as risky.

If the legs stay green with this commit in place, the suite is not testing what
it claims to and the green on the previous commit means nothing.

This commit is reverted in the next one.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ 92ece95

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
composer ✅ 100/100
npm ✅ 550/550
PHPUnit
Newman ⏭️
Playwright

Quality workflow — 2026-08-04 05:18 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Positive control — evidence

The green on this branch is not vacuous. Commit 6b2668b0 deliberately introduced two
defects at once and was then reverted by 6893893c.

Defect 1 — a genuine assertion failure (RoleGuardTest::testFallsBackToGroupMembership
asserting a bogus matchedRole).
Defect 2 — one @uses declaration removed from ChecklistGuardTest, to prove
beStrictAboutCoverageMetadata + failOnRisky are still live and not merely satisfied.

All four legs went RED, naming both (run 30880245785)

There was 1 failure:
1) OCA\Procest\Tests\Unit\Service\Transitions\RoleGuardTest::testFallsBackToGroupMembership
-'POSITIVE-CONTROL-DELIBERATE-BREAK'

There were 4 risky tests:
1) OCA\Procest\Tests\Unit\Service\Transitions\ChecklistGuardTest::testFailsWhenTaskIdMissing
- OCA\Procest\Service\Transitions\GuardResult
   ... (4 tests, all naming GuardResult)

FAILURES!
Tests: 1686, Assertions: 5631, Failures: 1, Skipped: 5, Risky: 4.

Note that removing a single @uses line re-flagged 4 tests — which is the direct
measure of how much this gate is actually holding.

Revert verified on disk, not just in git

$ git status --porcelain
(clean)
$ grep -rn "POSITIVE-CONTROL" tests/ lib/
(not present)
$ git diff --stat dd138b46e HEAD -- tests/
(empty — tree byte-identical to the pre-control commit)

@uses \OCA\Procest\Service\Transitions\GuardResult is back at ChecklistGuardTest.php:38 and
assertSame('Behandelaar', ...) is back at RoleGuardTest.php:126.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ 7577276

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
composer ✅ 100/100
npm ✅ 550/550
PHPUnit
Newman ⏭️
Playwright

Quality workflow — 2026-08-04 05:23 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ dc997be

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
composer ✅ 100/100
npm ✅ 550/550
PHPUnit
Newman ⏭️
Playwright

Quality workflow — 2026-08-04 05:47 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ dc997be

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
composer ✅ 100/100
npm ✅ 550/550
PHPUnit
Newman ⏭️
Playwright

Quality workflow — 2026-08-04 06:05 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Final CI state — commit 6893893c

All four PHPUnit legs green, with counts read from the logs themselves (run 30880523059):

PHPUnit (PHP 8.3, NC stable31)  success   Tests: 1686, Assertions: 5632, Skipped: 5.
PHPUnit (PHP 8.3, NC stable32)  success   Tests: 1686, Assertions: 5632, Skipped: 5.
PHPUnit (PHP 8.4, NC stable31)  success   Tests: 1686, Assertions: 5632, Deprecations: 10, Skipped: 5.
PHPUnit (PHP 8.4, NC stable32)  success   Tests: 1686, Assertions: 5632, Deprecations: 10, Skipped: 5.

No Risky: term remains in any leg's summary. 223 → 0.

Check set vs development's own

development (run 30876345083) this PR
PHP Quality (phpmd) fail fail (identical: same script, same exit code 2)
Quality Report fail fail (downstream of phpmd)
PHPUnit × 4 fail pass
everything else pass pass (24 green)

This PR strictly improves the set: the only two remaining failures are exactly
development's own pre-existing ones, and the diff touches no lib/ file, so phpmd cannot
differ.

E2E Tests (Playwright) — flaky, not caused by this change

The first run failed one spec, admin-settings.spec.ts:56 ("case type list renders its
management surface and add control"), timing out at 5.2m on both the initial attempt and the
retry, with 82 other specs passing. Re-running the job passed. The job was only turned on
last night by #709 and has exactly one prior green data point on development, so this looks
like a new flake in that spec rather than anything here — this PR's diff is 87 files of
@uses docblocks under tests/Unit/, and Playwright reads tests/e2e/.

@rubenvdlinde
rubenvdlinde merged commit 8a6f707 into development Aug 4, 2026
57 of 62 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/phpunit-risky-coverage-metadata branch August 4, 2026 06:06
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