ci(e2e): run the 433-test suite and make it return a verdict - #280
Open
rubenvdlinde wants to merge 19 commits into
Open
ci(e2e): run the 433-test suite and make it return a verdict#280rubenvdlinde wants to merge 19 commits into
rubenvdlinde wants to merge 19 commits into
Conversation
The `E2E Tests (Playwright)` job had never run on this repo — `enable-playwright`
was absent from the caller entirely, so 34 spec files and ~260 tests sat in the
tree with no gate behind them.
What this adds:
* `tests/e2e/playwright.config.ts` — a CI-only config. The shared workflow
resolves `${playwright-test-path}/playwright.config.ts` first and passes no
`--project`, so every project in the chosen config runs; this one declares
only the regression project, and writes its HTML report and test-results to
the APP ROOT, which is where the workflow's upload step looks. The root
config writes them one level deeper — with `if-no-files-found: ignore` that
mismatch uploads an EMPTY artifact, i.e. a red run with no report to read.
The root config is untouched and stays the local entry point.
* `tests/e2e/ci-seed.sh` — explicit force-import of the scholiq register over
the admin HTTP API, the example-data seed, a hard verification that the
register and six core schemas exist, a warm-up, and a bundle gate. The
`occ app:enable` repair path cannot be trusted on its own: an IRepairStep
runs with no user session (OR RBAC can deny it), it swallows its exception
as "Non-fatal", `occ` still exits 0, and the non-forced import advances the
recorded version WITHOUT applying the register. The bundle gate exists
because a missing bundle returns HTTP 200 text/html, never 404 — it reads
the real src out of the rendered app page (assets are at /apps/ on CI but
/custom_apps/ in docker dev images) and asserts the content type is
javascript.
* `tests/e2e/base-url.ts` — now accepts NEXTCLOUD_URL and NC_BASE_URL as well.
The shared workflow exports BASE_URL / NEXTCLOUD_URL / NC_BASE_URL and does
NOT export PLAYWRIGHT_BASE_URL. Still no localhost:8080 fallback.
* `tests/e2e/global-setup.ts` — the auth storageState path is anchored to the
repo root instead of the process CWD (a CWD-relative path does not fail, it
runs the whole suite logged OUT), and the seed is skipped when ci-seed.sh
already ran it.
* `.github/workflows/code-quality.yml` — `pull_request: types: [opened,
reopened, synchronize]` (without `synchronize` every commit after the first
merged unchecked behind a stale green tick), a top-level permissions block
(a caller cannot grant a reusable workflow more than it holds, and capping
at read makes the call fail to START), a concurrency group, and
`github.event.created != true` so a branch-creation push does not produce a
run that looks green because it ran nothing.
Real defect fixed on the way: `src/manifest.json` declared `openconnector` as a
bare string, which CnAppRoot treats as a HARD dependency — absence switches the
entire shell to the blocking `dependency-missing` phase. Scholiq calls
openconnector from exactly two places (LtiToolPlacementController's OIDC launch
forward and PaymentTransactionController's payment initiation), both optional
integrations. A school running Scholiq without LTI or online payments got a
completely unusable app. It is now `{ id, required: false }` — a soft
dependency whose absence surfaces a dismissible in-shell notice and degrades
only those two features. Rationale recorded in src/App.vue.
Refs: opencatalogi#787, opencatalogi#790
First CI run of the job (30796644591) failed in the seed step, exactly where it
was designed to. Everything below is a defect it surfaced.
1. settings/load returns HTTP 200 with success:false. The endpoint answered
200 and {"success":false,"message":"SchemaMapper::loadSchema(): Argument #1
($identifier) must be of type string|int, array given"} — importing nothing.
A status-code-only gate would have declared provisioning green and handed
the suite an empty register. ci-seed.sh now parses the body and annotates.
2. OpenRegister 500s on any schema with a top-level allOf. Measured: the three
schemas that carry one (lesson, grade-entry, portfolio-entry) all failed
with 500; all 115 without one succeeded. 3 of 3 vs 0 of 115. The seeder now
retries once with the top-level allOf stripped and says so loudly — that
drops only conditional if/then validation, keeps every property and the
base required list, and is the difference between an index page that exists
and one that 404s for a reason unrelated to the code under test. The
underlying defect is OpenRegister's, and cannot be fixed from this repo.
3. index-pages.spec.ts asserted rows for schemas that had none. It carried a
hand-written SEEDED_SCHEMAS set of 32 names; the seeder actually created
rows for 17. The other 15 creates were failing OpenRegister's required-
property validation, being logged as a warning, and swallowed. Both halves
are fixed: the fixture bodies now satisfy the register (Assignment.dueAt is
date-time not date; GradeScale.kind; GradeEntry/FinalGrade
curriculumPlanId+gradeScaleId; LearningPlan.coordinatorId;
LearningPlanEvaluation.evaluatedAt+evaluatedBy; Signature.subjectKind;
AttendanceRecord.sessionId; Regulation.audienceScope; Credential
issuerDid+signature+openbadges3Payload and a UUID learnerId;
XapiStatement.stored; DataMappingProfile.direction+sourceSchema;
DataExchangeJob.requestedAt; plus Portfolio/PortfolioEntry), and the
assertion set is now DERIVED from what the seeder really created
(.e2e-state/seeded-schemas.json) so the two can never disagree again.
ci-seed.sh gates the floor so a silent collapse to zero still fails.
4. Pretty URLs 404 under the CI php -S. The shared workflow runs a bare
`php -S` with no router, so pretty URLs are not rewritten: the built-in
server only falls back to index.php for paths that do NOT exist, and
server/apps/<app>/ exists without an index.php. Five spec files used the
short /apps/... form (29 of 34 already used /index.php/apps/...); they are
now consistent. Tracked upstream as ConductionNL/.github#125.
5. /Settings is not a route. Two specs navigated to
/apps/scholiq/Settings while the manifest declares /settings — vue-router
paths are case-sensitive, so the heading those tests assert on could never
appear.
6. Marker files under test-results/ are deleted before any spec can read them.
Playwright's createRemoveOutputDirsTask removes every project outputDir at
the start of the run — after the workflow's seed step, before globalSetup.
The seed state now lives in .e2e-state/.
Also: TOTAL_SCHEMAS in the seeder said 35; the register declares 118, so every
ratio it logged was wrong. It is now derived from the register.
Run 30798535945: 389 passed, 17 failed, 27 skipped (1.0h). Every failure is
fixed at the cause; no assertion was weakened, skipped or given a longer
timeout.
1. `#/…` navigates to a route that does not exist (11 failures)
src/main.js builds the router with
`createWebHistory(generateUrl('/apps/scholiq'))` — HISTORY mode. vue-router
strips the base from location.pathname and then appends the untouched hash,
so `/index.php/apps/scholiq/#/admissions/review-board` resolves to the
location `/#/admissions/review-board`, which matches no declared route.
<router-view> renders nothing.
The run proves it: every `#/` spec failed with
Received string: "Keyboard navigation help / Skip to app navigation / …"
— the Nextcloud chrome with an empty app body — while index-pages.spec.ts
and detail-pages.spec.ts, which use the plain path form, passed 206/206 on
the same instance. Fixed in the 7 failing specs plus credential-verify,
whose docstring described this as a 'hash-route component mount timing' gap
that 'may' appear 'in some test runs'. It was neither intermittent nor a
timing gap.
2. shell.spec.ts asserted a role-gated menu item is always present (1)
The manifest gates Compliance on
visibleIf: { "user.primaryRole": { in: ["compliance-officer", "hr"] } }
and the CI session resolves primaryRole to the default `learner`. Hiding it
is correct behaviour. Inverted into the opposite assertion rather than
dropped: for a non-gated role the entry MUST be absent — which proves
visibleIf is enforced, something the old assertion could not have detected
being broken.
3. dashboard.spec.ts measured Nextcloud's own chrome and its own BEM children (2)
`[class*="dashboard-page"]` also matches the 25 BEM sub-elements
@conduction/nextcloud-vue ships for CnDashboardPage (__header, __content,
__title, … — verified in the published dist CSS), so ONE dashboard counted
as 5 and was reported as dashboard-in-dashboard nesting. Now counts the
`.cn-dashboard-page` host token exactly.
`nav a, .app-navigation a, [role="navigation"] a` selects NC's global
header too. The captured DOM shows the 3 matches were: NC's logo link 'Go to
Dashboard' -> /index.php, NC's Dashboard app link -> /index.php/apps/
dashboard/, and the one real Scholiq entry. It would have counted 2 with the
Scholiq nav entirely absent. Now scoped to #app-navigation-vue.
4. nextcloud-app.spec.ts pointed at the wrong surface entirely (4)
Every assertion there ('Scholiq Settings', the OpenRegister section, the
register combobox, 'Credential Signing') targets AdminRoot.vue, which
src/settings.js mounts on the NEXTCLOUD ADMIN page. It navigated to
/apps/scholiq/Settings, which is neither: /Settings is not a declared route,
and the in-app /settings page is a different surface — it rendered a generic
'Settings' heading and a disabled Save button, with no 'Scholiq Settings'
anywhere. Now /index.php/settings/admin/scholiq (sectionId defaults to the
app id in OpenRegister's AppHost Bootstrap; Application.php passes no
override).
Still outstanding and NOT fixed here: 18 further spec files navigate with the
same dead `#/` form. They pass today only because their assertions are
permissive or they skip on absent data, so they are green-but-dead. Converting
them needs its own verification pass and is filed separately.
⚠️ This merge is not housekeeping — it un-blocks CI entirely. development moved to cb101f1 (#257, workflow hardening) and edited the same `permissions:` block this branch edits. GitHub could no longer compute the PR's merge ref, so PR #254 went CONFLICTING and **every `pull_request` workflow stopped firing**: the push of 991e132 produced no Code Quality run at all, only CodeQL. The PR page shows no red — it shows nothing, which is the failure mode where a missing gate is indistinguishable from a passing one. Conflict resolved as a union, because both sides are load-bearing: * issues/pull-requests: write — development's least-privilege pass; without them the Quality Report job cannot post its sticky PR comment. * contents/actions: write, packages: read — required by jobs the CALLED workflow declares (coverage-baseline update, journeydoc capture, SBOM). A caller cannot grant a reusable workflow more than it holds, so capping those at read makes the call fail to START. development at cb101f1 shows exactly that: two startup_failure runs back to back.
The previous measurement (run 30810841150: 326 passed / 80 failed) was taken INSIDE a broken window. ConductionNL/.github#125 merged at 08:49:30Z and its router exempted other PHP entry points with `is_file(__DIR__ . $path)`, which matches /status.php (the one case it verified) but never matches /ocs/v2.php/cloud/user, because that carries path info — so the entire OCS surface fell through to index.php and 404'd. That is exactly the two signatures I measured: dashboard-main.js fetchApiWidgets hits /ocs/v2.php/apps/dashboard/..., and 'core: Failed to load user status' hits /ocs/v2.php/apps/user_status/... — which is why 10 spec files I never touched flipped with byte-identical URLs. ConductionNL/.github#127 merged at 12:11:39Z and fixed it (exempt by prefix, plus a gate asserting /ocs/v2.php/cloud/user is not 404). A plain re-run replays the ORIGINAL workflow resolution and would not pick that up, so this is a fresh commit. Baseline for comparison is the 08:44:01Z pre-router run 30798535945: 389 passed / 17 failed / 27 skipped — compared by failing test NAME, not count.
Run 30817505312 came back `cancelled`, not failed. The E2E job ran 13:21:24 -> 14:06:42 and the 'Run Playwright tests' step was killed at exactly 45 minutes — the `timeout-minutes: 45` the shared workflow gained in ConductionNL/.github#261. That cap was sized from suites of '4-10 min' (planix 0.8, doriath 4.2, opencatalogi 10.0). Scholiq declares 276 manifest pages; the suite is 433 tests and took 1.0h then 1.4h single-worker. It was never going to fit, and a cancelled job is not a result — it is the failure mode where a gate produces no verdict at all. So the suite now runs 4 workers with fullyParallel. This is safe by construction, not by hope: every spec is read-only against a dataset ci-seed.sh provisions BEFORE the run, so no spec creates, mutates or deletes objects and workers cannot race. The single shared mutable artefact is the auth storageState, which globalSetup writes once before any worker starts and workers only read. Server-side capacity is already there — the shared workflow sets PHP_CLI_SERVER_WORKERS=8, so `php -S` is no longer the single-request bottleneck that motivated `workers: 1`. 4 rather than 8 because each Playwright worker drives a full Chromium plus the SPA's boot fan-out; matching the PHP worker count 1:1 would just re-queue the requests.
Three distinct root causes, none of them the test suite.
1. PascalCase schema segments in OpenRegister object-API URLs (10 of 12).
SchemaMapper::findBySlugInIds() matches on LOWER(slug), so a SINGLE-word
title accidentally resolves ('Course' -> 'course') while a MULTI-word one
cannot ('AccessibilityStatement' -> 'accessibilitystatement', which is not
the declared slug 'accessibility-statement'). ObjectService::setSchema()
then rethrows DoesNotExistException and the request 404s. That is why some
calls in the same file worked and others did not.
Fixed every multi-word segment across 20 views to the slug declared in
lib/Settings/scholiq_register.json — all 20 target slugs verified present
before use. Single-word segments are left alone: they resolve correctly.
This follows the precedent set by e5b78e9 ("resolve register/schema ids to
slugs so 9 dead listeners can run") and matches the declarative manifest
pages and SubjectChoicePicker.vue, which were already correct.
2. LearningRecordShareVerify returned HTML where JSON was promised.
fetchObject() was the one method on the controller with no try/catch around
ObjectService::find(). On a #[PublicPage] an escaping exception makes
Nextcloud render printExceptionErrorPage(), so the caller got an HTML error
page — and the view called resp.json() with no resp.ok guard, producing
"SyntaxError: Unexpected token '<'". Both halves fixed: the controller fails
closed to the specified denied response, the view renders the denied state
instead of logging a client crash.
3. networkidle never settles on Nextcloud (ADR-074 rule 4 / hydra gate 58).
The notification poll keeps a request in flight for the whole session, so
waitForLoadState('networkidle') silently burns its full 30s out of a 60s
test budget; the customary .catch(() => {}) hides the throw but not the
cost. The symptom is a bare "Test timeout exceeded" attributed to whatever
call was in flight, indistinguishable from an app outage. Replaced with an
explicit readiness assertion on the Vue root in the affected tests only.
No assertion was weakened, skipped or given a longer timeout.
Two stale specs rewritten, with evidence:
- nextcloud-app: asserted <th>Feature</th>, but Scholiq's own AI-feature
table was removed and delegated to Hermiq (ADR-005). No <th> exists in any
settings view, so the assertion could only ever fail. Now asserts the
delegation branch the app actually renders.
- dashboard: counted /Dashboard/i nav entries expecting <=1, but the manifest
legitimately declares "Risk dashboard", "Skills gap dashboard" and "BSA risk
dashboard" beside the single "Dashboards" landing page, and hasText matches
descendants. Now matches the accessible name exactly and asserts the real
invariant — no per-role dashboard entries (ADR-009 section 6).
…ed-e2e # Conflicts: # .github/workflows/code-quality.yml
…comments
The previous commit's comments said OpenRegister "matches on LOWER(slug), so a
single-word title resolves and a multi-word one cannot". That explanation is
wrong and dangerous to generalise.
SchemaMapper::findBySlugInIds() lowercases BOTH sides
(openregister lib/Db/SchemaMapper.php:586), so the actual invariant is:
strtolower(<url segment>) === strtolower(<declared slug>)
Casing never breaks resolution. STRUCTURE does. 'AccessibilityStatement'
lowercases to 'accessibilitystatement', which is not the declared slug
'accessibility-statement' — the hyphen is the difference. Prefixes and
underscores break it identically.
This matters because "kebab-case the schema name" is NOT a valid rule and
applying it elsewhere would create the exact bug being fixed here: scholiq is
the fleet outlier in declaring hyphenated slugs for most of its schemas, and
even inside scholiq AiFeature declares its slug as literally 'AiFeature'.
Other apps declare camelCase or PascalCase slugs.
No code changes — every call site was already derived by looking the slug up in
lib/Settings/scholiq_register.json, and all 54 segments now in src/ were
re-verified to resolve to the correct schema under the invariant above.
…n error MyLearningRecordView threw on any non-2xx from /api/learning-records/me, including 404. A 404 there means the signed-in Nextcloud user has no bound LearnerProfile — the normal case for an admin or staff account — so the component logged '[MyLearningRecordView] loadRecord error' on a perfectly healthy session. In the console that is indistinguishable from a real outage, and the e2e suite rightly treats it as fatal. Render the component's already-declared loadError branch with an accurate message instead of throwing. Real failures (5xx, network) still throw and still log.
…f-contradictory test
Both live in accessibility-conformance.spec.ts; neither was an app bug.
1. `getByLabel(/^Description$/i)` matched nothing and burned the full 60 s
test timeout. The rendered accessible name of a REQUIRED field carries
the required marker — the control is `textbox "Description *"` (measured,
run 30835724202 error-context.md). The unanchored `/Affected Surface/i`
one line above matched fine, which is exactly why only this one field
looked "missing". Anchor now tolerates the marker.
The same test then never actually submitted: `tenant_id` is in
AccessibilityFeedback's `required` list, was never filled, and CnDetailPage
keeps Create DISABLED until it is — so `.click().catch(() => {})` was a
silent no-op and the test asserted on a submit that had not happened. It
now fills the tenant and ASSERTS the Create button becomes enabled, which
is the direct proof that every required field is satisfied.
2. "limitation detail route resolves ... not a blank/404 shell" navigated to
`00000000-0000-0000-0000-000000000000` while also asserting no console
errors. Those cannot both hold: the object store `console.error`s
`Error fetching <type>/<id>` on the resulting 404, once per schema-slug
resolution attempt. The page itself was fine — the run's error-context
shows the registered component mounted with heading "Accessibility
limitation", the Data widget, and all 11 fields as `—`. It now creates an
AccessibilityStatement + AccessibilityLimitation fixture and asserts the
page renders that record's OWN field value, which is a strictly stronger
bar than "the component mounted".
Also: workers 4 -> 6. The suite took 24.2 min against a 20-minute budget.
The config's justification for parallelism ("no spec creates, mutates or
deletes objects") was false — nextcloud-app.spec.ts POSTs settings and PUTs
notification preferences, and this spec creates rows. Replaced with the
reason parallelism is ACTUALLY safe (no spec asserts an exact global
collection count) plus a re-audit trigger.
`enable-playwright` was absent from the caller's `with:` block entirely, and the shared workflow defaults it to false, so "E2E Tests (Playwright)" has reported `skipped` on every run this repo has produced. tests/e2e/ meanwhile grew to 34 spec files / 433 tests. A skipped job renders in the Quality Report the same way a passing one does, so nothing ever said those tests were not running. Adds `enable-playwright: true` + `playwright-test-path: tests/e2e`, and ships tests/e2e/playwright.config.ts so the workflow's first config lookup hits it. The run step passes no `--project`, so the chosen config decides what runs; the root config additionally writes its HTML report to `test-results/playwright-report`, which matches neither path the upload step collects — with `if-no-files-found: ignore` that uploads an empty artifact exactly when a red run needs a report. The new config writes report and output to the app root and declares only the chromium project. The root playwright.config.ts is untouched and stays the local-dev entry point. No seed command is wired: global-setup.ts already runs seed-example-data.mjs, which imports the scholiq register over the admin API.
Contributor
Quality Report — ConductionNL/scholiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 750/750 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-05 18:38 UTC
Download the full PDF report from the workflow artifacts.
Deduplicates the two competing E2E-enablement PRs. #280 (this branch) is the current one — rebased on today's development — but it is a bare config: its last run was CANCELLED at the shared workflow's 45-minute cap (45m28s), which is not a verdict, and the kill also skips the artifact upload, so it measured nothing at all. #254 is stale (last touched Aug 4) and CONFLICTING, but it is the branch that actually produced verdicts. Its content is merged here wholesale rather than cherry-picked: the seed script, the base-url env names, the storageState anchoring, the fixture repairs, the ~20 src/views slug fixes and the src/manifest.json openconnector fix are all load-bearing for a run that means anything. Conflicts, both resolved as a union of the two intents: * .github/workflows/code-quality.yml — kept this branch's newer inputs (frontend-checks, enable-coverage-guard, enable-hydra-gates) and took #254's `playwright-seed-command: bash apps/scholiq/tests/e2e/ci-seed.sh`. This branch's comment claimed a seed command would run the import twice; that is no longer true — ci-seed.sh writes a marker and global-setup.ts skips its own seed when it sees it. The comment now records why occ's repair step cannot be trusted instead (no user session, swallowed exception, exit 0, non-forced import advances the version WITHOUT applying the register) and why the bundle gate is not redundant with an HTTP status check. * tests/e2e/playwright.config.ts — rewritten, see the next commit.
…found Two things, both sized on measurements from the runs this branch inherits. ── 1. THE JOB MUST RETURN A TALLY, NOT `cancelled` ────────────────────────── #280's last run (31030901245) was killed at the shared workflow's 45-minute cap after 45m28s. A killed job prints no tally, writes no HTML report, and skips both artifact uploads (`if-no-files-found: ignore`), so it reported `cancelled` — which is neither a pass nor a fail. Four changes, every one in the strict direction, none of them a relaxed assertion: globalTimeout: 38 min (NEW). Playwright now stops the run itself, below the job cap, prints `N passed / N failed / N did not run`, and flushes the report. A red run WITH evidence instead of a silent kill. ~7 min is left for the report writer and the two uploads (measured ~90 s for 7.0 + 6.1 MB). retries: 1 -> 0. A retry can only turn red into green, never green into red, so it carries no information about correctness — it hides instability and doubles the price of every failure. Run 30889902343 spent a minute replaying one failing test against a budget it was already close to. `trace: 'on-first-retry'` -> `'retain-on-failure'` so the FIRST failure still ships a trace. timeout: 60_000 -> 40_000. Sized on the slowest PASSING test measured at the worker count this config uses: 22.4 s (run 30835724202, detail-pages.spec.ts AccessibilityFeedback). 40 s is 1.79x that. No passing test was ever near 60 s — the per-test cap is a tax paid almost entirely by failures, which sit on it. workers: 6 -> 4. This one reverses a change made on the other branch, and the two runs disagree with the reasoning that motivated it: run 30835724202 workers 4 -> 24.2 min wall | 94.0 min test-time | 408 tests, mean 13.8 s, slowest PASS 22.4 s run 30889902343 workers 6 -> 29.2 min wall | 169.7 min test-time | 407 tests, mean 25.0 s, slowest PASS 37.4 s Going 4 -> 6 made the WALL CLOCK 21% WORSE and inflated every individual test by 81%. The runner has 4 cores and already hosts `php -S` with PHP_CLI_SERVER_WORKERS=8; the 5th and 6th Chromium do not get a core, they take one from the server answering their own requests. 4 is the measured optimum, not a compromise. Note what the contention was doing to the headline number: the "slowest passing test" looked like 37.4 s at 6 workers and 22.4 s at 4 — measuring the timeout budget under the wrong worker count would have justified keeping 60 s. ── 2. THE ONE FAILURE: AN OPTION THAT CANNOT BE CLICKED ───────────────────── `accessibility-conformance.spec.ts` — "a user fills and submits the AccessibilityFeedback create form" — failed in BOTH measured runs. It was reported as Error: the Create button must become enabled once every required field is filled … Expected: enabled Received: undefined which reads like the Create button does not exist. It does. The ARIA snapshot from the failing run has `button "Create" [disabled]`, and three required fields empty. `Received: undefined` is what an assertion reports when the test was ALREADY out of time — the real failure happened 60 seconds earlier and was swallowed by a `.catch(() => {})`. From the trace: the `serious` option resolves, Playwright calls it "visible, enabled and stable", and then refuses to click it for the entire test timeout: <input id="nc-vue-124" placeholder="Tenant UUID (multi-tenant isolation)."> from <div role="dialog" aria-modal="true" …> subtree intercepts pointer events The NcSelect dropdown (`vs__dropdown-menu`, `vs-nc-vue-123`) is rendered inline in the form rather than teleported, and the field immediately after it — Tenant ID, `nc-vue-124` — paints on top of it. Only the FIRST option is reachable with a mouse; `serious` is option-1. That is a real defect in the create dialog, it is not specific to this schema or this app (every required enum on every CnDetailPage create form is affected), and it is reported separately — fixing it is not in this repo. Severity is now chosen with the KEYBOARD, which is immune to the overlap and is in any case the interaction an accessibility-conformance spec should be exercising: click to open, type to filter, Enter to take the highlighted option. `keyboard.type` rather than `fill` because vue-select marks its search input readonly when the select is not searchable and `fill` throws on that. No assertion was weakened. Two were ADDED, and the swallow was deleted: * the `serious` option must be VISIBLE after typing; * it must then be HIDDEN after Enter — the listbox closing is the proof the Enter was taken as a selection rather than ignored. That check is only meaningful because the option was asserted visible first: `toBeHidden` on an element that never rendered passes for free. ── 3. A GREEN-BUT-DEAD GUARD IN THE SAME TEST ─────────────────────────────── The whole body of that test sat inside `if (await affectedSurfaceField.isVisible().catch(() => false)) { … }`. If the create form failed to mount, the test asserted NOTHING and reported green. That was defensible while the register import was best-effort. It is not any more: ci-seed.sh is now the workflow's `playwright-seed-command`, it verifies the register, the six core schemas and the seeded-row floor, and the step runs under `bash -e`, so a bad provision fails the job outright. An unmounted form can now only mean the form is broken — which is what the test is for. The guard is an assertion.
The gate already handled the hard case — a MISSING Nextcloud asset does not
404, it is served through index.php as the NC error page with HTTP 200 and
`Content-Type: text/html`, so every status-code check in the pipeline reads a
completely absent frontend as success. That is why the gate asserts the
content type.
It only covered half of it. A TRUNCATED bundle — webpack exiting 0 after
writing an empty chunk, an artifact uploaded before the write completed — is
still served with HTTP 200 AND `Content-Type: application/javascript`, at zero
bytes. Status: fine. Type: JavaScript. SPA: mounts nothing. Every UI spec then
fails on a selector timeout pointing at the specs instead of at the build.
`%{size_download}` was already being fetched and printed; nothing looked at it.
The gate now parses the three fields separately and enforces a 1 MB floor. The
real bundle measured 11,688,751 bytes on run 30889902343, so the floor is two
orders of magnitude below the true size and far above anything a truncation
leaves behind.
Verified both directions before pushing: "200 application/javascript 11688751"
passes, "200 application/javascript 0" trips the floor.
2 tasks
Contributor
Quality Report — ConductionNL/scholiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ⏭️ | ||||
| phpstan | ⏭️ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ❌ | ||||
| check-specs | ✅ | ||||
| composer | ✅ | ⏭️ | |||
| npm | ⏭️ | ✅ 750/750 | |||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-05 19:57 UTC
Download the full PDF report from the workflow artifacts.
Contributor
Quality Report — ConductionNL/scholiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 750/750 | |||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-05 20:44 UTC
Download the full PDF report from the workflow artifacts.
…endpoint
The E2E run went red on the coverage guard, not on a test: `Tests: 898,
Assertions: 4126, Skipped: 5` all passed, then
Coverage baseline: 71.16%
Coverage current: 71.15%
FAIL: Coverage dropped by 0.01%
That drop is this PR's. Porting #254 brought a try/catch into
`LearningRecordShareVerifyController::fetchObject()` — `find()` THROWS for an
id that does not resolve and, via `ObjectService::setSchema()`, for a schema
the register never imported; on a `#[PublicPage]` an escaping exception makes
Nextcloud render `printExceptionErrorPage()`, so the caller gets an HTML error
page where it asked for JSON and the verify view dies on `SyntaxError:
Unexpected token '<'`. The fix is right; it arrived with no test, so the
`catch` was dead weight in the coverage denominator.
Note WHY the five existing tests could not have covered it. The shared
ObjectService mock in setUp() RETURNS null for an unknown id, so
`testUnknownShareIsDenied` produces `not_found` through the `$obj === null`
branch and never enters the `catch` at all. The two paths are
indistinguishable from the response alone — same JSON, same 404 — which is
exactly how an untested fail-closed branch hides. This test installs a mock
that THROWS instead, which is the only way the behaviour is really exercised.
No baseline was moved and no threshold was relaxed.
Contributor
Quality Report — ConductionNL/scholiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 750/750 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-05 21:36 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes and absorbs #254 (closed; its whole tree is merged here, its measurements are recorded in its closing comment).
The
E2E Tests (Playwright)job had never executed on this repo.enable-playwrightwas not set tofalsewith a reason — it was absent, so the job reportedskippedon every run this repo has ever produced whiletests/e2e/grew to 33 spec files and 433 tests. The Quality Report rendersskippedexactly like a pass.The verdict — run 31041359993
failure— a real verdictThe previous attempt on this branch (31030901245) was
cancelledat 45m28s. That is not a red — a killed job prints no tally, writes no HTML report, and skips both artifact uploads (if-no-files-found: ignore). This run uploaded a 7.75 MB report.How the budget was fixed — every number measured, all four in the strict direction
globalTimeoutretriestimeoutworkersGoing 4 → 6 workers made the wall clock 21 % worse and inflated every individual test by 81 %. The runner has 4 cores and already hosts
php -SwithPHP_CLI_SERVER_WORKERS=8; the 5th and 6th Chromium do not get a core, they take one from the server answering their own requests.Note what the contention did to the headline number: the "slowest passing test" reads 37.4 s at 6 workers but 22.4 s at 4. Sizing the timeout under the wrong worker count would have justified keeping the loose 60 s.
No timeout was raised. No assertion was relaxed. No
.skip,test.fixme,continue-on-errororif: falsewas added, and no spec was deleted.Negative controls — proof the suite can actually fail
1. Bundle truncation. Branch
feature/e2e-negative-control-bundle(run 31041400714, since deleted, never merged, no PR) appended&& : > js/scholiq-main.jsto the build. Truncated, not deleted —global-setup's checks usefs.existsSync(), and a deleted file is also the case the existing content-type check already catches.Result: the bundle served
200 application/javascript 0and the job went red at the seed step, 5m56s in, before Playwright started:Note the content type:
application/javascript. The type check could not have seen this — which is why this PR adds the size floor. The pre-existing half of the gate covers the other case: a missing Nextcloud asset does not 404, it is served throughindex.phpas the NC HTML error page with HTTP 200 andContent-Type: text/html, so every status-code check in the pipeline reads a completely absent frontend as success.Because the gate fails the job before Playwright runs, no tests flipped — the suite never started. That is the correct behaviour for a broken frontend (433 selector timeouts blaming the specs is strictly worse evidence), and it is why the gate lives in the seed step.
2. Mutation of the code under test. The one failure this PR fixes was re-run against the real dialog and passes; the two failures below are the suite discriminating on a dependency change nothing else in the pipeline could see (next section).
The 2 remaining failures are a DEPENDENCY REGRESSION, not spec debt
Both passed in both of #254's measured runs and fail here. The cause is not this PR's config:
#278 repinned off
3.0.0-vue3.6because it was unpublished. The whole3.0.0line is now gone from the registry (npm viewlists only2.2.0-vue3.1 … 2.2.0-vue3.4), so #254's green runs were installing a version that no longer exists — served from the Actions npm cache. The repin was necessary. This job is the first thing in the pipeline capable of seeing what it cost, which is the whole argument for the gate.1.
accessibility-axe-scan.spec.ts:66— Dashboard,scrollable-region-focusable(serious, WCAG 2.1 SC 2.1.1 / 2.1.3)cn-data-table__scrollis@conduction/nextcloud-vue's own DOM, not scholiq markup.CnDataTable's scroll container carries notabindex, so a keyboard-only user cannot scroll the table. This is a real a11y defect in the shared library affecting every app that puts aCnDataTablein a widget, and it cannot be fixed from this repo.2.2.0-vue3.4is published and is the currentvue3dist-tag (one patch ahead of the pin) — whether it fixes this is a dependency decision, deliberately not taken here.2.
credential-verify.spec.ts:21— 2 console errors on a deliberately unknown idThe test navigates to
test-idon purpose ("shows valid/invalid status for unknown credential") and also asserts no fatal JS errors. The OpenRegister object storeconsole.errors on the resulting 404, once per schema-slug resolution attempt. A healthy, specified path is therefore indistinguishable from an outage in the console. Same defect class as #254'sMyLearningRecordViewfix ("a user with no learner profile is an empty state, not an error"), but theconsole.erroris in OpenRegister's store, not in scholiq. Left red deliberately — the honest fixes are upstream or a fixture-based rewrite, and neither is worth weakening the assertion for.Hydra Gates: RED, but strictly BETTER than the base
Hydra Gatesfails — and it fails ondevelopmenttoo. Compared properly:development@39ad3ea (run 31035634115)The PR's 11 are a strict subset of the base's 18 —
comm -13returns empty. This PR introduces zero new gate failures. The base run is genuinely red (56 s, named findings), not vacuous — noSCOPE WAS EMPTY, no unresolved diff base — so "pre-existing" is supported by evidence rather than assumed.Six of the eleven (12, 32, 38, 40, 43, 44) are app-wide accessibility debt that predates this branch. They are not the cause of axe failure #1, which is in the vendored library's DOM.
Coverage
The first run also tripped the coverage guard:
Tests: 898, Assertions: 4126, Skipped: 5all passed, then71.16% → 71.15%, FAIL: Coverage dropped by 0.01%. That drop was this PR's — porting #254 brought an untestedcatchintoLearningRecordShareVerifyController::fetchObject(). Fixed by covering it, not by moving the baseline. The five existing tests could not have covered it: the shared mock returns null for an unknown id, sonot_foundcame from the$obj === nullbranch and thecatchwas never entered — the two paths give the identical 404 JSON, which is exactly how an untested fail-closed branch hides.What else this fixes
src/manifest.json:openconnectorwas a HARD dependency. It was declared as a bare string, whichCnAppRoottreats as required — if the app is not installed and enabled, the shell switches to the blockingdependency-missingphase and nothing renders. Scholiq calls openconnector from exactly two places,LtiToolPlacementController(OIDC launch forward) andPaymentTransactionController(payment initiation), both optional integrations. A school running Scholiq without LTI or online payments got a completely blank app. Now{ "id": "openconnector", "required": false }. A product defect, independent of CI, and the most user-visible thing in this PR.The suite would otherwise have run LOGGED OUT.
global-setup.tswrote its authstorageStateto a CWD-relative path. Playwright resolvestestDiragainst the config directory butstorageState/globalSetupagainst the CWD; a mismatch does not error, it silently runs all 433 tests unauthenticated and the assertions then pass or fail for the wrong reason. Anchored to the app root.ci-seed.shis now theplaywright-seed-command.occ app:enablecannot be trusted to provision the register: the repair step runs with no user session (OR RBAC can deny it), swallows its exception as "Non-fatal",occexits 0, and the non-forced import advances the recorded version without applying the register. Measured on this run:Green-but-dead shapes, counted
index-pages.spec.tsgates threeexpect.softs per page (no-JS-error, nav-present, ≥1 row) behindSEEDED, across 98 index pages. The step now runs underbash -eandci-seed.shexits non-zero on a bad provision, so in CI this branch can no longer be taken silently.config.schema. Worth a follow-up — the seed's coverage, not its success, is the limit.test.skip(!fixture, …)+ 2test.describe.skipblocks ("live run deferred":peer-and-self-assessment,personal-timetable). All pre-existing; none added here.accessibility-conformance's outerif (isVisible), which made the whole body optional) is now a hard assertion.The failure that was fixed, with its mechanism
accessibility-conformance.spec.ts— "a user fills and submits the AccessibilityFeedback create form" — failed in both of #254's runs and now passes.It reported
Expected: enabled / Received: undefined, which reads like the Create button does not exist. It does — the ARIA snapshot hasbutton "Create" [disabled].Received: undefinedis what an assertion reports when the test was already out of time. The real failure was 60 seconds earlier, swallowed by.catch(() => {}):The NcSelect dropdown is rendered inline in the form rather than teleported, and the field immediately after it paints on top of it. Only the first option is reachable with a mouse;
seriousis option-1. Another real defect in the create dialog, affecting every required enum on everyCnDetailPagecreate form.Severity is now chosen from the keyboard — immune to the overlap, and the appropriate interaction for an accessibility-conformance spec. The swallow is replaced by two assertions: the option must be visible after typing, then hidden after Enter (the listbox closing is the proof the Enter was taken as a selection). The second is only meaningful because of the first —
toBeHiddenon an element that never rendered passes for free.For a human to decide
@conduction/nextcloud-vuepin.2.2.0-vue3.4is the currentvue3dist-tag, one patch ahead of the pinned2.2.0-vue3.3. Not touched here. Does it fixCnDataTable's unfocusable scroll region?cn-data-table__scrollhas no keyboard access (WCAG 2.1 SC 2.1.1, serious). Fleet-wide, fix belongs in nc-vue.NcSelectdropdowns are painted over by the next field inCnDetailPagecreate dialogs — mouse users can only pick the first option. Fleet-wide, fix belongs in nc-vue.credential-verify: should the OR object storeconsole.erroron an expected 404, or should the view own the not-found path?development, six of them accessibility.