Release: merge development into beta - #22
Conversation
…se-tile bug fixes' (#118) from feature/big-fixes into development Reviewed-on: https://codeberg.org/Conduction/launchpad/pulls/118 Reviewed-by: rjzondervan <rjzondervan@noreply.codeberg.org>
beta.109 did not export CnIconBrowser, CnDashboardIcon, mdiCatalogue, DEFAULT_ICON or isCustomIconUrl, which the icon-browser work imports, so the build failed at import time. beta.143 ships them.
The standalone <label for="dashboard-config-icon"> matched no rendered element, so screen readers never associated it with the picker. Drop the orphaned label and pass :label to CnIconBrowser, which renders the for/aria-labelledby association on the trigger control itself.
The org-nav editor was once a free-text input, so existing nav trees hold MDI-style names (star, folder, chart-bar) rather than the SVG paths the icon picker now emits. CnDashboardIcon can't resolve those against its PascalCase registry and silently falls back to the default icon. Add normaliseIconValue() to map such names to their @mdi/js path at render time, so the admin's original choice still shows. SVG paths, URLs, and names with no MDI match pass through untouched.
Legacy tiles store an MDI shortname (link) or key (AlertCircle) rather than the SVG path the picker is indexed by, so CnIconBrowser couldn't match the current value and the preview couldn't draw it — the editor looked blank/broken on open. Add a displayIcon computed that maps the stored value to its MDI path for the picker and preview via normaliseIconValue. The stored icon is left untouched until the user picks a new one, so no legacy data churn.
In popup mode CnIconBrowser's trigger falls back to a generic aria-label="Select icon" with no row context. Pass :label so the library renders a proper accessible name (WCAG 1.3.1 / 3.3.2).
Replace the unassociated standalone <label> in CreateGroupDashboardModal and TileEditor with CnIconBrowser's :label prop, and drop the now-dead .tile-editor__label rule. Note: in inline mode the library still renders the label's for= against the popup-only trigger id, so full programmatic association needs a follow-up fix in @conduction/nextcloud-vue.
- ICON_CATALOGUE: assert frozen array and { key, label, value, search,
path } entry shape
- TileEditor: cover onIcon iconType derivation (svg vs url) and the
isUrlIcon computed
- OrgNavigationItem: cover the CnDashboardIcon SVG-path branch and the
legacy-name -> path resolution, alongside the existing URL-icon test
…owser into development Reviewed-on: https://codeberg.org/Conduction/launchpad/pulls/123 Reviewed-by: WilcoLouwerse <wilcolouwerse@noreply.codeberg.org>
The Conduction and Sendent default tiles hardcoded
/apps/launchpad/img/<logo>.png. That URL only resolves when the app is
mounted under the default apps directory; on instances serving launchpad
from a secondary apps path (e.g. custom_apps) the logos 404 and render
broken.
Inject IURLGenerator and resolve the logo paths via imagePath('launchpad',
...) so they carry the app's real web prefix regardless of install
location. Falls back to the app-relative path when no generator is wired
(unit-test construction).
Standardizes the strict gate onto the canonical php:8.3-cli + node20 version (fixes the 404 ci-php image where present); validated on the pilot apps.
Fix 8 phpcs errors surfaced by the newly-enforced strict gate: - phpcbf autofix (blank line after control structure, long-condition closing comment) in DashboardService - named-parameter calls to appImagePath() (CustomSniffs NamedParameters) - replace disallowed inline-if ternaries with if/else in DashboardApiController::getActive() and WidgetPlacement::jsonSerialize() - capitalise inline comment in WidgetPlacement
launchpad was the last v1-shaped manifest in the fleet (2026-07-06 manifest audit, item 3): unpkg @latest v1 $schema, v1 top-level widgets[], '@runtime' string sentinels where v2 wants arrays, null register/schema on an index page, and a _note-less custom page. Migrated by hand (the manifest-migrate codemod cannot express these shapes): - $schema -> pinned Codeberg v2 URL; version 0.1.0 -> 0.2.0 - dashboard-detail: empty page-level widgets[] + _note documenting the ADR-036 Decision-8 runtime-manifest stub role (the real per-user pages come from GET /apps/launchpad/api/manifest) - admin-templates-index: type index (null register/schema) -> type custom with component TemplatesPage + _note (no OR register backs this data, so the declarative index contract cannot apply) - admin-settings: _note added - v1 top-level widgets[] block removed; the spend-analytics widget is registered in the JS registry, and its data contract lives in openspec/specs/launchpad-spend-analytics-widget - observability block (ADR-040) unchanged Validates against both the hydra-vendored canonical schema (gate-22 PASS) and current nextcloud-vue (manifest-migrate --validate-only: Valid (v2 schema)).
…2 (gate-22 green)' (#127) from feat/manifest-v2 into development
Fleet audit item 21: stale nc-vue pin. Bumps ^1.0.0-beta.143 -> ^1.0.0-beta.155, which bundles manifest schema 2.13.0 (top-level runtime key) plus the sideEffects/precompiled-validator perf fixes. Verified: installed schema 2.13.0, npm run build green, check_manifest.js gate exit 0. First half of the fix for #129: the bundled schema now accepts the top-level runtime key; the remaining blocker is the widget-entry shape (widgetId -> widgetKey), tracked in #129.
…ta.155 (audit item 21)' (#130) from chore/nc-vue-pin-155 into development
- website/bugs/repository/documentation github -> codeberg - drop github-only <discussion> (no codeberg equivalent) - point <screenshot> at real in-repo screenshots Part of fleet-wide App Store audit 2026-07-07.
Replace SPDX-License-Identifier AGPL-3.0-or-later with EUPL-1.2 across all LaunchPad-authored source (lib/ PHP, src/ Vue/JS, appinfo/info.xml docblock), and normalise SPDX-FileCopyrightText from "LaunchPad Contributors" to "2024 Conduction B.V. <info@conduction.nl>" to match REUSE.toml, composer.json, publiccode.yml and the PHPDoc @copyright/@license. Leaves the info.xml <licence>agpl</licence> app-store token unchanged (documented fleet convention). Fix README licence badge AGPL-3.0 -> EUPL-1.2 to match README §License prose. Add scripts/check-license-headers.sh regression guard (composer lint:licenses, wired into check:strict) that fails the build if an AGPL-3.0 SPDX identifier returns, naming the offending file. Archives openspec change align-source-license-headers-to-eupl.
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-08 14:20 UTC
Download the full PDF report from the workflow artifacts.
…-target fix(phpcs): stop the SpecTagSniff instructing the pattern gate-46 rejects
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-08 15:39 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-08 17:53 UTC
Download the full PDF report from the workflow artifacts.
…er (#76) Closes gates 32, 34, 38, 40 and 45 with the fix each finding actually describes, rather than the attribute that would have silenced it. gate-38 (skip-link, 1). LaunchPad roots on neither <NcContent> nor <CnAppRoot> — it writes its own shell — so it inherits no skip link from Nextcloud, and the org-nav rail, sidebar and toolbar all precede the grid in DOM order. Adds a real skip link to the grid region, which already carried tabindex="-1" for the quick-search Esc contract. Focus is moved explicitly: the bare href moves scroll everywhere but moves FOCUS only in Chrome, so in Firefox and Safari the next Tab returns to the navigation the user just asked to skip. gate-32 (semantic-controls, 4). Three overlays and one nav toggle. - The nav disclosure was a <span @click> INSIDE an <a href>: a control inside a control, so the anchor took the tab stop and the toggle never got one. It is hoisted out to a sibling <button> with a real accessible name. role+tabindex on the span would have passed the gate and left an axe nested-interactive violation in place. - Both aria-modal dialogs (LinkButtonHost, FilesWidget) could only be dismissed with a mouse. They now take tabindex="-1" + Escape, and FilesWidget moves focus INTO the dialog on open — without that the handler could never have fired, because focus stayed on the row button outside it. - The sidebar backdrop is role="presentation" and never takes focus, so a handler bound to it is unreachable by construction. The real keyboard equivalent is a document-level Escape in WorkspaceApp, guarded on sidebarOpen so it does not swallow quick-search's Escape. gate-34 (window-confirm, 3). Two confirms and a prompt become NcDialogs under src/dialogs/ per ADR-004 modal isolation. Native dialogs are unthemeable, block the browser thread, and therefore cannot be driven by the e2e suite at all. The prompt's empty/whitespace/unchanged rules move into the dialog's canSubmit, where they disable the button instead of silently discarding the click. gate-40 (form-label-association, 10). Four fields sat next to a <label> carrying neither `for` nor the field inside it — associated with nothing, so each was announced with no name. Those get real for/id pairs, or NcTextField's own `label` prop where an Nc component owns the input. Placeholders stay as example values; a placeholder is not a name, since it disappears as soon as the field has content. The remaining controls get aria-label. gate-45 (prefers-reduced-motion, 3). Hover transitions now collapse to 0.01ms under prefers-reduced-motion (WCAG 2.2 AA SC 2.3.3). The colour still changes — only the interpolation is dropped — and 0.01ms rather than 0 keeps transitionend firing. Also adds the six @SPEC tags gate-16 asks of the methods introduced here, all pointing at canonical openspec/specs/ paths. Verified: each gate re-run against this tree reports 0, and the same checker re-run against origin/development still reports 4 / 3 / 1 / 10 / 3 — so the counts moved because the code changed, not because a checker stopped looking. eslint 0 errors, 638 unit tests pass, webpack build ok. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…age that exists (#77) Closes gates 53 and 60. gate-60 (icon-vocabulary, 5). All three menu icons were legacy `icon-*` CSS classes, and the register's Dashboard schema used the filled `ViewDashboard` where ADR-077 Tier A requires the outline. Migrated to the canonical names: dashboard -> ViewDashboardOutline (Tier A), template -> FileReplaceOutline (Tier B), settings -> CogOutline (Tier A). An unbridged `icon-*` renders as an invisible white glyph on NC34+ light themes. The fifth finding was the load-bearing one: `src/main.js` never called `registerIcons()`. CnIcon resolves a manifest icon name ONLY through that registry and has no fallback — an unregistered name renders nothing at all: no glyph, no placeholder, no console error. So migrating the three names without registering them would have traded an invisible glyph for a missing one. Adds `src/icons.js` with the three components and wires the call before mount. All three verified to exist in vue-material-design-icons. The same legacy class was also hardcoded in the SERVER-side builder: ManifestController::buildManifest() emitted `icon-home` for every runtime menu entry — the wrong concept as well as a legacy class, since those entries are dashboards. That is not in gate-60's scope (it reads the JSON manifests, not PHP), but it is the same defect on the path users actually see, so it moves to ViewDashboardOutline too. gate-53 (effective-manifest-crossref, 1). The one menu entry LaunchPad ships pointed at `/dashboards`, which matches no page: this stub's only dashboard page is `/dashboards/:id`, and the runtime builder emits `/<slug>`. Retargeted to the page id `dashboard-detail`, which is the pairing the server itself emits — buildManifest() sets each menu entry's route to its page's id while the page carries the path. There is deliberately no `/dashboards` index page; the rationale now lives in the page's own _note, because the v2 schema sets additionalProperties:false on menu entries and rejects a note there. Verified: gates 53 and 60 report 0 against this tree and still report 1 and 5 against origin/development. check:manifest valid, eslint 0 errors, 638 unit tests pass, webpack build ok. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 14:56 UTC
Download the full PDF report from the workflow artifacts.
…at never comes (#78) Closes gates 29 and 58, and fixes a seed that could not report an error. THE SEED. `playwright-seed-command` was an inline `OC_PASS=… php occ user:add … || true`. The `|| true` was there for a real reason — the suite has to survive a re-run against a warm instance where the account already exists — but it does not say "tolerate an account that already exists", it says "tolerate ANYTHING". A rejected password, a missing occ, a broken database or a typo in the user id all exited 0 and let Playwright start against an instance with no grantee, so the grant spec would fail for a reason unrelated to the code under test. The tolerance is kept and narrowed to the postcondition that matters: after the seed, the account EXISTS. `user:add` failing is not an error; `user:add` failing while the account is still absent is. The script asks the instance (`user:info`) rather than parsing a localised error message. It is a script, not an inline command, because the shared workflow runs the seed through `eval` UNQUOTED — tests/integration/seed.sh already documents that constraint, and `bash <path>` is the one form eval cannot mis-parse. tests/e2e/seed.test.sh asserts all three arms with a stubbed occ, INCLUDING the one that must fail — a seed that always exits 0 looks exactly like a seed that works, so the failing case is the only one that proves anything. Measured against an occ that always fails: the old form exits 0, the new form exits 1. tests/integration/seed.sh had the same defect in a different shape: its else branch said "already present (or could not be created)" and carried on. Same fix, same three arms verified. gate-58 (e2e-networkidle, 4). Four `waitForLoadState('networkidle')` calls in docs-screenshots.spec.ts. networkidle never settles on Nextcloud (ADR-074 rule 4) — the notification poll keeps a request in flight for the life of the page — so each either burned its timeout or screenshotted an arbitrary moment. Each is replaced with the actual completion signal: the modal closing, or the target surface becoming visible. One of them is also a precondition the following assertions needed anyway. gate-29 (gitignore-then-commit, 2). `.phpunit.result.cache` and `phpmetrics/violations.xml` were tracked at paths .gitignore already covers, so the rules did nothing for them (a tracked file bypasses gitignore). Removed from the index; both are now genuinely ignored. Verified: gates 29 and 58 PASS against this tree at root scope, and still report 2 and 4 against origin/development. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 15:11 UTC
Download the full PDF report from the workflow artifacts.
) * fix(tiles): delete the write path under a permanently-Gone endpoint Closes one of gate-57's two orphaned write capabilities. TileApiController's create, update and destroy return HTTP 410 Gone unconditionally — tile creation moved onto widget placements (REQ-WDG-022 / REQ-TILE-PLACEMENT). The three TileService write methods behind them had no callers at all: not the controller, not the CLI commands, not the migrations, not the frontend. Their docblocks said they were "preserved for legacy callers and migration tooling"; there are neither, and the deprecation on the controller side is already complete. Traced before choosing, because "zero callers" has two opposite fixes and this repo has one of each. The other orphan gate-57 reports — DashboardService::writeDashboardContent — is the WIRE case and is deliberately left alone (see below). This one is the DELETE case, and the discriminator is the sibling seam: there is no live path that ought to be calling these and isn't. The 410 IS the intended behaviour, not a bug the orphans would fix. Deleting rather than leaving them is the point. A write path with no caller, sitting under a permanently-410 endpoint, is a way to put rows back into a table the app has deliberately stopped writing to — the deprecation would be bypassed by whoever wired it up next, with nothing to warn them. That is what gate-57 exists to catch. getUserTiles stays: `GET /api/tiles` still reads the table, so existing rows remain visible. Read-only was the intended end state and is now the only state the class can express. The three tests asserted `expects($this->never())->method('createTile')` and friends. A mock expectation cannot name a method that does not exist, and it would be the weaker check anyway — they now assert the methods are absent, so re-adding the write path fails a test instead of passing silently. Each test's real assertions (410 status + envelope + replacement hint) are untouched. NOT fixed here, deliberately: DashboardService::writeDashboardContent. That one is not dead code — it is the write half of the groupfolder-storage-backend capability (35 spec scenarios, two storage backends, a factory and two registered CLI commands) whose read, write AND delete accessors ALL have zero callers, while `launchpad:storage:migrate-to-groupfolder --prune-source` already writes to that backend and NULLs the DB column. Deleting the write half would be wrong and wiring it means deciding where dashboard content lives — a design decision with a spec behind it, not a gate fix. Filed separately. Verified: gate-57 goes 2 -> 1 against this tree and still reports 2 against origin/development. php -l clean on both files. * style(tiles): drop the trailing blank line phpcs flagged, and give TileService its @SPEC Removing the three write methods left a blank line before the class closer — phpcs's 'Expected 0 blank lines after function' error, and the only ERROR in the run (everything else in that job is a pre-existing WARNING). Also adds the canonical @SPEC tag the class docblock was missing, pointing at openspec/specs/tiles/spec.md rather than a change dir, since this commit rewrote that docblock anyway. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 15:22 UTC
Download the full PDF report from the workflow artifacts.
* test(e2e): cover the anonymous public-share page
Closes gate-26, and covers four dashboard-public-share scenarios that had
no e2e proof (gate-19: 262 -> 258).
The public share is the one page LaunchPad serves to unauthenticated
visitors — a separate entry point (templates/public.php -> src/public.js
-> DashboardPublicShareView.vue) that bypasses the app shell and the user
session entirely. It had no e2e coverage at all.
THE DISCRIMINATING ASSERTIONS ARE THE NEGATIVE ONES. "An anonymous
request with a valid token returns the dashboard" would also pass if the
endpoint ignored the token and served any dashboard to anyone — the
failure mode that actually matters on a route with no authentication in
front of it. So the positive case is bracketed by two controls against
the same endpoint in the same run:
- a well-formed but never-issued token must NOT return 200 (before)
- the token that just worked must STOP working once revoked (after)
A pass therefore means the token is what admits the request, rather than
the absence of a check.
The browser leg uses a fresh context with no credentials and asserts
`.public-share-view` is visible — DashboardPublicShareView's root
element, so the assertion is that the public bundle booted and mounted,
not merely that some HTML came back. It also asserts the visitor was not
bounced to /login: both API legs could pass while the rendered page still
demanded a session, which is precisely the bug a public link must not
have.
It lands in tests/e2e/ci/ — the directory the Playwright job actually
runs. A spec outside it would satisfy the gate while never executing,
which is the shape of every green-but-dead suite in this fleet.
* test(e2e): enable personal dashboards before publishing, and restore it after
CI measured: POST /api/dashboard answered 403 personal_dashboards_disabled.
A fresh instance ships allow_user_dashboards OFF (REQ-ASET-003), so the
test could never reach the surface it exists to cover.
Enabling it is setup, not an assertion — the prior value is read from
GET /api/admin/settings first and restored at the end, so the instance is
left as it was found for whatever runs next in the same job.
* test(e2e): read the uuid from the nested dashboard object
CI measured: POST /api/dashboard answers
{dashboard: {...uuid...}, placements: [...]} — the new dashboard is
nested and the default widget bundle comes back alongside it, so reading
uuid off the root gave undefined. The assertion message printed the whole
body, which is how the real shape was identified from one run.
---------
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 19:47 UTC
Download the full PDF report from the workflow artifacts.
* chore(deps): pin @conduction/nextcloud-vue to 2.2.0-vue3.9 Hard pin to the vue3 dist-tag head. Not a range: ^2.2.0 does not match 2.2.0-vue3.9, and latest/beta are the retired Vue 2 lineage. Lockfile control ran first: regenerating the lockfile with the pin UNCHANGED already produces +150/-1361 (npm 11 dedupe). That churn is pre-existing. Bump-attributable portion is +5/-5. installed off disk after a real npm ci: 2.2.0-vue3.9, one copy, peer vue ^3.5.0 unit 638 passed / 58 files -> 638 passed / 58 files check:manifest exit 0 -> exit 0 build 2 warnings -> 2 warnings bundle 99,625,314 -> 99,644,148 bytes * fix(deps): regenerate the lockfile with npm 10, the version CI actually runs The earlier lockfile was generated with npm 11.13.0, which prunes optional+peer entries npm 10 still requires. CI runs node 20.20.2 / npm 10.8.2, matching this repo's declared engines (node ^20, npm ^10). Regenerated with npm 10.8.2 under node 20. Under that toolchain the control (pin unchanged) is +1 line -- npm 10 normalising "license" into the root entry -- so the +150/-1361 previously attributed to pre-existing dedupe was an npm 11 artifact. Bump-attributable diff is exactly +5/-5. npm ci under npm 10.8.2 exits 0; installed off disk 2.2.0-vue3.9, one copy, peer vue ^3.5.0. unit 638/58 pass; check:manifest exit 0.
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-10 11:45 UTC
Download the full PDF report from the workflow artifacts.
…share scenarios (#85) * test(e2e): make CI run the suite it claims, and cover 20 more public-share scenarios CI executed 4 Playwright tests while 113 existed, and of 117 `@e2e` annotations only 9 were in files CI ran (launchpad#82). gate-19 reported 71 scenarios covered; 4 had an executing test behind them. The cause was two configs. `tests/e2e/ci/playwright.config.ts` selected a four-test subset for CI, because the shared workflow resolves `<playwright-test-path>/playwright.config.ts` before the app root; gate-19 reads the ROOT config. Nothing compared them, so the gate counted a suite nobody ran. Now there is one config. `tests/e2e` holds none, so the workflow falls back to the root `playwright.config.ts` — the same file gate-19 parses since .github#308. Its `testIgnore` is therefore both "what CI skips" and "what claims no coverage", and the two cannot drift without editing that file. Every entry names the run that measured it: 31367057618 executed all 80 root-suite tests against the CI fixture for the first time — 65 passed, 15 failed — and the 15 are concentrated in the eight excluded files. Specs that are excluded are still runnable, via configs gate-19 does not read: `npm run test:e2e:excluded` and `npm run test:e2e:docs`. Withholding coverage credit and deleting the ability to run a test are different acts. Also adds tests/e2e/ci/public-share-lifecycle.spec.ts — 20 openspec scenarios of dashboard-public-share with no executing test: creation options, the owner-only management endpoints, expiry in both directions, and the password gate. Each negative case is bracketed by the positive one it must differ from, in the same test, so a 403 from a broken route or a gate that always refuses cannot pass. Measured effect on gate-19, decomposed: 258 development (baseline) 269 scope fix alone — +11, the honest increase: scenarios "covered" by tests that never ran became uncovered 238 new tests alone — -20 249 both Covered scenarios 71 -> 80, and all 80 now have an executing test behind them (4 did before). Tests CI executes: 4 -> 65. Annotations in executed files: 9 of 117 -> 104 of 137. Refs #82 * fix(e2e): quarantine admin-group-management, and make the two flag tests set the flag Run 31388540423 executed 65 tests (up from 4) and turned up four failures, none of them a product regression and all of them a spec passing on a precondition somebody else left behind. spec-coverage's two Add-Dashboard tests named `allowUserDashboards` and set nothing. The flag is OFF on a fresh instance; they were green because allow-personal-dashboards-flag.spec.ts runs earlier and switches it on — and that spec is red here, so excluding it took the precondition with it. Worse, the second test read GET /api/admin/settings and fell through to `allowed = true` when the read FAILED, so an unreadable response silently became 'expect the card to be visible'. Both now set their own flag, restore it, and treat an unreadable settings response as a failure. The pair is now a real A/B: the arms differ only in the setting. admin-group-management.spec.ts passes 3 of 3 and is excluded anyway, because running it makes ci/manifest-grants and ci/public-share-lifecycle:352 fail — e2e-grantee receives admin's whole manifest, and its POST answers 201 with createdBy: admin. Both are green without it (31389002230). The mechanism is not understood, so this is a quarantine and says so. It costs no coverage: all three of its @e2e annotations name scenarios that exist in no spec. CI now runs 62 tests; gate-19 stays 249. * fix(e2e): an "anonymous" API context was inheriting the admin session My previous commit blamed admin-group-management.spec.ts for two failures and quarantined it. Run 31389746411 excluded that file and the two failures happened anyway, so the diagnosis was wrong and the quarantine is reverted. The real cause is in the specs. The root config sets `use.storageState` to the admin session global-setup.ts harvests, and a context created inside a test inherits it — so `request.newContext({baseURL, extraHTTPHeaders})` for a DIFFERENT user arrived carrying admin's cookies. Nextcloud prefers the session over the Authorization header, and every request the test believed it was making as `e2e-grantee` was served as `admin`. The response said so and I read past it twice: `"createdBy":"admin"` on a share the grantee had just been refused permission to create. This was invisible until now because CI ran these specs under a config with no storageState at all. It is the dangerous failure mode: a permission test that silently authenticates as the privileged user does not error, it stops discriminating. Note public-share.spec.ts KEPT PASSING under the same inheritance — token gating does not depend on who the caller is — so its "anonymous visitor" premise was false while green. Fixed there too. Every cross-user and anonymous context now passes `storageState: undefined` explicitly, and says why. Second fix, same run: spec-coverage's two flag tests reached the admin API through `page.request`, which authenticates by session and does not attach Nextcloud's requesttoken — `GET /api/admin/settings` answered NOT OK, and the old code read exactly that response as "personal dashboards are enabled". They now use the explicit Basic-auth context every other spec in this repo uses. The assertion I added last commit is what surfaced this: it turned a silent default into a named failure. CI runs 65 tests; gate-19 stays 249. * fix(e2e): the create-flow test skipped on the default environment while claiming coverage Run 31390900033 was green — 65 tests, 64 passed, 1 skipped — and the skip was the point. 'clicking Add-Dashboard card opens the Create dashboard dialog' carries @e2e dashboard-switcher::click-invokes-create-flow and self-skipped with 'allowUserDashboards is false in this environment'. The flag is off on a fresh instance, so that was not a rare escape hatch, it was the normal path: the scenario counted as covered while nothing exercised it, on every run. Same defect as its two siblings, one rung worse — a failure is visible in a tally and a skip reads like a pass. It now sets the flag it needs and asserts the card is present rather than skipping when it is absent. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-10 14:11 UTC
Download the full PDF report from the workflow artifacts.
…dialogs, extract the one that was genuinely inline (#89) * fix(fe): move eight modal components into src/modals and src/dialogs, and extract the one that was genuinely inline gate-13 (modal-isolation) reported nine files. They were not nine of the same thing, and two of them were mis-classified going in. EIGHT WERE PURE MOVES, NOT EXTRACTIONS Six of the nine were already single-purpose modal components whose entire template is one <NcModal>/<NcDialog> — they were simply in the wrong directory. Two more, TileEditor.vue and WidgetMovePanel.vue, look like ordinary components by name but are the same shape: the whole template is a modal. So eight of the nine needed `git mv` and import updates, not decomposition. src/components/WidgetPickerModal.vue -> src/modals/ src/components/DashboardConfigModal.vue -> src/modals/ src/components/admin/SetupWizardModal.vue -> src/modals/ src/components/TileEditor.vue -> src/modals/ src/components/Widgets/VisibilityRulesModal.vue-> src/modals/ src/components/Widgets/WidgetMovePanel.vue -> src/modals/ src/components/admin/group/CreateGroupDashboardModal.vue -> src/dialogs/ src/components/admin/group/ManageGroupDashboardsModal.vue -> src/dialogs/ The last two are NcDialog-based, so they land in src/dialogs/ rather than src/modals/. src/components/admin/group/ is now empty and removed. Six co-located specs moved with their components, following the existing src/<area>/__tests__/ convention. ONE WAS GENUINELY INLINE TemplatesPage.vue had its template editor written inline. It is now src/modals/TemplateEditorModal.vue, owning its working copy (seeded from a `template` prop on open), availableGroups, permissionOptions and saveTemplate(); it emits close/saved. TemplatesPage keeps only isEditorOpen + editingTemplate and reloads on saved. Modal-only CSS moved with it, and the @SPEC openspec/specs/admin-templates/spec.md tags carried across. IMPORTS Views.vue (5), AdminSettings.vue (1), GroupDashboardsTab.vue (2), plus relative-path fixes inside the moved files and their specs, and the component-tree diagram in docs/architecture.md. No dynamic imports, registries or index files reference these; a grep for every old path returns nothing. BOTH DIRECTIONS before [gate-13] modal-isolation: FAIL - 9 file(s) with inline modal/dialog after [gate-13] modal-isolation: PASS Red-then-green with two planted true positives, one per fix kind — TileEditor moved back to src/components/, and a throwaway <NcModal> re-inlined into TemplatesPage.vue: [gate-13] modal-isolation: FAIL - 2 file(s) with inline modal/dialog src/components/admin/tabs/TemplatesPage.vue: inline NcModal/NcDialog src/components/TileEditor.vue: inline NcModal/NcDialog Both reverted, re-run: PASS. NO OTHER GATE MOVED 19=249, 25=15, 26=1, 57=1 — identical to baseline. gate-26's detail log still names exactly TemplatesPage.vue and nothing else: the new TemplateEditorModal is neither under src/views//src/pages/ nor a manifest "type":"page" entry, so it is not a page component and no workaround was needed. gate-60 icon-vocabulary went SKIPPED(wiring) -> PASS, which is NOT an effect of this change: the baseline ran before `npm ci`, so vue-material-design-icons was not yet installed. lint 0 errors; build compiles; 638 tests across 58 files pass. * fix(spec): move the watcher's @SPEC onto the handler the gate actually reads gate-16 (spec-coverage) failed on the PR with one finding: src/modals/TemplateEditorModal.vue::handler — missing @SPEC The tag was not absent. It sat on the docblock above `open: {`, the watcher OBJECT, while the method gate-16 names is `handler`, two lines further down past `immediate: true`. The gate looks for a docblock directly above the method it reports, so it never saw one. This is a diff-scoped gate, which is why the full-tree run I used to verify the extraction reported nothing: gate-16 only considers methods that are CHANGED in the diff, and a full-scope run has no diff to scope to. It can only appear on the PR. Moving the docblock onto `handler` also reads better — the comment describes what the handler does, not what the watcher option is. before HYDRA_GATE_BASE_REF=origin/development ... -> FAIL 1 changed method(s) missing @SPEC after ... -> no gate-16 finding; gate-13 still PASS --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-10 21:26 UTC
Download the full PDF report from the workflow artifacts.
…ervable, checked reasons where not (#90) * test(e2e): license-header-consistency — whole-spec @e2e exclude, closes 7 gate-19 findings Every scenario in this spec asserts a property of the repository's own source files — SPDX identifiers, PHPDoc @license/@copyright tags, the README badge — so there is no running instance for a browser to observe. The exclusion names coverage that exists and was checked before it was written: scripts/check-license-headers.sh (composer lint:licenses, part of composer check:strict) fails the build on a reintroduced AGPL-3.0 identifier under lib/, src/ or appinfo/, which is REQ-LIC-005 exactly; hydra gate-1 enforces the PHPDoc pair under lib/; the hydra license-triangle gate cross-checks LICENSE, composer.json, publiccode.yml and REUSE.toml. gate-19: 249 -> 242. * test(e2e): groupfolder-storage-backend — reason-bearing @e2e excludes, closes 35 gate-19 findings Every scenario in this spec is one of four shapes, none of which a browser can observe: * which PHP collaborator the service layer delegated to. REQ-GFSB-010 requires the HTTP response to be identical whichever backend served it, so a UI assertion here is one that cannot fail. * the GroupFolder backend itself. The groupfolders app is NOT installed on the Playwright CI fixture — code-quality.yml provisions only ConductionNL/openregister via additional-apps, and neither seed script adds it — and AdminController::setWizardStorage() answers HTTP 400 to any attempt to select it. * fault injection: app removed mid-operation, permission denied, disk full, database connection lost. * HTTP response shape, which this repo routes to Newman by standing convention (playwright.config.ts excludes **/api-direct/** for that reason) and whose requests the collection already carries. Every named test was opened and confirmed before its reason was written. Two branches that are NOT covered are named as gaps rather than papered over: the locale branch of GroupFolderContentStorage:: resolvePath(), and the dashboard_content_storage_unavailable error key. gate-19: 242 -> 207. * test(e2e): adopt-or-abstractions — one real boot test + 19 reason-bearing excludes Closes 20 gate-19 findings, 1 of them with an actual test. NEW TEST — tests/e2e/manifest-boot.spec.ts. src/main.js registers the bundled manifest synchronously and then fires an async GET /apps/launchpad/api/manifest whose failure branch has never had a test: it must keep the stub, warn, and leave the app usable. The second test forces that endpoint to 500 via page.route and asserts the app still renders. A refactor that awaits the manifest, or that lets the rejection escape the IIFE, turns a degraded-but-working app into a blank page — and nothing else in the suite would catch it. --list: 65 tests in 16 files -> 67 in 17. The other 19 are static file reads (appinfo/info.xml, composer.json, src/manifest.json), build-script exit codes, docblock text, or an OR-LESS instance the CI fixture deliberately is not — it installs openregister via additional-apps on purpose. Three spec-ahead-of-code divergences are named in the reasons rather than papered over: useOrFeatureDetect() appears nowhere under src/; no widget fetches OpenRegister from the browser at all, so there is no ?_lang= request to assert on; and lib/Db/ColumnTypeRegistry.php, whose docblock one requirement is entirely about, does not exist. gate-19: 207 -> 187. * test(e2e): image-widget + label-widget — split the red test out, put tags on the tests that prove them Closes 11 gate-19 findings of the second shape: tags that were present but sat in files no Playwright project ran. THE REAL DEFECT WAS NOT THE CONFIG. Both files carried a HEADER BLOCK of @e2e tags above their tests — 8 tags over 3 tests in image-widget, 4 over 3 in label-widget. A tag above the first declaration binds to that declaration, so a header block claims every scenario it lists on the strength of whichever test comes first. Six of those twelve tags were proven by nothing at all: no test touched the fit select, forced an upload error, read the cell cursor, or used a <script> tag; the pre-fill tag sat on a test asserting ONE field where the scenario names six. Promoting the files as they stood would have handed gate-19 six false green. So: every tag now sits on the test that proves it, and the six unproven scenarios got real tests — fit options, upload failure, pointer/no- pointer cursor, direct-URL preview, script-tag XSS. testIgnore is FILE-granular, so the one red test in each file was holding two green ones hostage. Each moved to its own file, unchanged, still runnable via > launchpad@1.0.0 test:e2e:excluded > playwright test --config=playwright.excluded.config.ts — not skipped, not deleted, not weakened. REQ-IMG-003 is now proven BETTER than before: it says the cell MUST call window.open(link, '_blank', 'noopener,noreferrer'). Recording window.open asserts the two flag arguments a real popup cannot show, and dispatching the click event directly does not depend on the cell being in the viewport — which is exactly what made the popup version red. form-pre-fills-all-six-fields-when-editing takes a reason-bearing exclude naming the run that measured its test red (31367057618) and the fact that the test checks one field of six. --list: 67 tests in 17 files -> 77 in 19 (excluded config: 31 in 10). gate-19: 187 -> 176. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-11 08:45 UTC
Download the full PDF report from the workflow artifacts.
…ts, no exclusions (#91) * test: close gate-25 (15) and gate-26 (1) with real tests, no exclusions gate-25 contract-coverage went 15 -> 0 and gate-26 visual-coverage 1 -> 0. Every finding is closed by a test that asserts the endpoint's or the page's behaviour. No `@contract exclude` and no `@visual exclude` was written, and nothing was skipped, deleted, or given a wider timeout. PHPUnit — 8 controller test classes, 69 tests / 183 assertions -------------------------------------------------------------- Twelve of the fifteen endpoints are covered by unit-level controller tests run by `composer test:all` in CI. They are dominated by refusals, because each of these routes is `#[NoAdminRequired]` and takes a caller-supplied id: dashboardApi#byPath a slug the caller may not view returns the SAME 404 an unknown slug does — asserted by comparing both refusals field by field, so a 403 (an existence oracle) cannot pass dashboardApi#get/setDefaultDashboard the pin is per-acting-user dashboardApi#viewEvent 404 / permission refusal / 204 recorded dashboardTranslationApi#setPrimary owner-only promotion, 403 + 404 arms preferences#get/setPreference key sanitisation, namespacing, size limit asserted at the boundary and one past it dashboardVersionApi#createVersion, #fetchVersion the service's owner-or-admin sentinel maps to 403, every other failure to a 500 that does not echo its message; a create carries no snapshot body, a fetch does ruleApi#getRules a non-owner is refused BEFORE the rules are read, so a refusal cannot be a filtered 200 widgetApi#newsItems, #calendarEvents, #calendars limit clamp asserted at 0/1/50/51; the one-year cap asserted on the window actually handed to the aggregator, not on the status code; a caller who cannot view triggers NO outbound fetch, which matters because the placement holds caller-supplied ICS/RSS URLs Newman — 6 requests / 18 assertions for the two HTML page routes ---------------------------------------------------------------- `page#deepLink` and `page#publicShare` return a rendered TemplateResponse. They cannot be exercised by this repo's PHPUnit suite at all: its bootstrap falls back to the OCP stubs (CI does not set PHPUNIT_USE_NC_BOOTSTRAP), and `OCP\Util::addScript`/`addStyle` — the first statements in both methods — call `\OC` and `\OC_Util`, which the stubs do not provide. HTTP is the right layer for them anyway, and Newman is enabled in CI. The new "Page routes (HTML shells)" folder asserts the shells apart from each other rather than settling for "200 + text/html", which cannot tell the workspace, the public-share page and Nextcloud's own error page apart: `id="app-workspace"` + `id="workspace-vue"` + `launchpad-main` for the workspace, `id="app-public-share"` + `id="public-share-vue"` + `launchpad-public` for the share page, each asserting the ABSENCE of the other. The public-share requests carry `auth: noauth` against a collection whose default is admin Basic, so a 200 there is proof the route really is `#[PublicPage]`. A companion `/s/{token}/data` request proves the fixture token is a live share — without it the page assertions would pass on a garbage token, since the shell is token-agnostic by design. Playwright — tests/e2e/admin-templates-page.spec.ts --------------------------------------------------- `TemplatesPage.vue` is the default Beheer panel: it is what an admin sees on arrival at /settings/admin/launchpad, and a failed mount leaves an empty panel with no error. The spec drives it — default-panel mount, the create CTA opening `TemplateEditorModal`, and the unmount/remount across a tab switch that makes its `created()` list fetch re-run. Read-only: no template is created, edited or deleted, and the editor is dismissed without submitting. gate-57 (`DashboardService::writeDashboardContent`) is untouched — it is launchpad#87, an explicit product decision. * test(newman): drop two page-shell assertions that measured the CI fixture CI run 31473431105 failed one assertion, and chasing it found a second problem the green tick was hiding. FAILED — `boots the workspace bundle, not the public-share bundle` expected '<!DOCTYPE html>…' to include 'launchpad-main' Nextcloud's JS resource locator emits a `<script src>` only for a file that exists on disk, and the Newman job NEVER BUILDS THE FRONTEND: its steps are Install Nextcloud → Install and enable app (composer only) → Seed → Install Newman → Run. So `js/launchpad-main.js` is absent there and the tag is never written. The assertion was green locally purely because a developer box has a stale build lying around; it measured the CI fixture, not launchpad. PASSED FOR THE WRONG REASON — `boots the standalone public bundle` pm.expect(body).to.include('launchpad-public') Its sibling could not fail at all. `templates/public.php` renders `class="launchpad-public-share"`, so the needle is a substring of the shell's own markup whether or not any bundle exists. It read as a script-tag check and was a tautology — the more dangerous of the two, because nothing would ever have reported it. Both are replaced by the claim they were reaching for, expressed in markup that is present regardless of any build step: the two page templates are MUTUALLY EXCLUSIVE, and their mount ids are the "which SPA is this" fact (`#workspace-vue` is src/main.js's el, `#public-share-vue` is src/public.js's). Each page now asserts its own two ids AND the absence of the other page's two. Verified live: 6 requests / 17 assertions / 0 failed. Positive control — inverting the mutual-exclusion needles makes the new assertion fail, so it discriminates rather than always passing. gate-25 and gate-26 still PASS.
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-11 09:04 UTC
Download the full PDF report from the workflow artifacts.
… control (#92) * test(e2e): dashboard-public-share — one real test, 13 checked reasons, and the header-block tags moved onto the test that proves them Closes 14 gate-19 findings; gate-19: 176 -> 162 (measured, unscoped, both numbers observed on this tree). NEW TEST — "a logged-in owner opening the share link gets the same read-only page as a stranger" (tests/e2e/ci/public-share.spec.ts). REQ-PSHR-006's fourth scenario is the only one of its four a browser can decide, and it is the one with a real failure mode: the public view noticing a session and upgrading to the editable workspace, so that a link pasted into a group chat becomes an edit surface for every colleague who is logged in. The test opens the share as the dashboard OWNER — the caller with the most permission available — and requires `.public-share-view` plus the read-only badge AND zero `.launchpad-sidebar-toggle` / `.launchpad-grid-item`. The negative half matters: asserting the badge alone would still pass if the editable shell rendered around it. It carries a CONTROL. Before opening the link it loads /apps/launchpad and requires the workspace shell to be visible, which proves the shared admin session is live. Without that, an expired session would make every "no edit affordance" assertion pass for the wrong reason — read-only because the visitor is anonymous, not because the view refuses to upgrade. THE THREE SIBLING SCENARIOS ARE EXCLUDED, AND THE REASON IS STRUCTURAL. "Cannot create widget / edit / delete via public share" are guarded by PublicShareContext, a REQUEST-scoped PHP marker set exactly once, inside PublicShareController::renderShare(). No route mutates anything under /s/{token}, so no request a browser can issue is both a public-share bearer and a mutation: an anonymous PUT /api/dashboard/{uuid} is refused as unauthenticated, and a test asserting 403 there would pass while proving something else entirely. Recorded alongside: the endpoint the first of those scenarios names, POST /api/dashboards/{uuid}/placements, does not exist in appinfo/routes.php. Remaining exclusions, all with claims that were checked before they were written: * REQ-PSHR-007 (3) — viewCount/lastViewedAt are DB columns no anonymous response echoes back, plus distinct IPs or 60s+ waits. * REQ-PSHR-009 (3) + the PSHR-005 throttle scenario (1) — the IThrottler bucket is IP-global by the spec's own wording, and playwright.config.ts runs workers:1 from one runner IP, so spending it hands 429 to every later test. PublicShareControllerTest:: testUnlockReturns429WhenThrottled was opened and confirmed. * REQ-PSHR-010 (2) + the PSHR-004 groupfolder scenario (1) — GroupFolders is not installed on the fixture; code-quality.yml `additional-apps` provisions only openregister and neither seed script adds it. HEADER-BLOCK TAGS MOVED (.github#343). public-share.spec.ts listed four @e2e slugs above every test(). gate-19 resolves a tag's owner with _TestDoc.owner(), which for a tag above all tests returns simply the FIRST test in the file — so all four were credited to one body whatever that body asserted, and a tag for a scenario nobody had tested would have counted the same. These four happen to be genuinely proven by the test they now sit on, but that was luck of authorship, not something the gate checked. Every tag on this branch sits directly above its own test(). --list: 77 tests in 19 files -> 78 in 19. * test(e2e): dashboard-sharing — 11 real tests that provision the second user CI never had Closes 11 gate-19 findings, every one of them with a test. gate-19: 162 -> 151 (measured on this tree). WHY THESE WERE OPEN. tests/e2e/dashboard-sharing.spec.ts drives the share sidebar and needs a pre-seeded `recipient` account (LAUNCHPAD_E2E_SHAREE) that tests/e2e/seed.sh does not create — it makes `e2e-grantee` and nothing else — so all four of its tests fail in the CI job and playwright.config.ts excludes the file. gate-19 counts nothing from a file no project runs, so eleven REQ-SHARE scenarios had no proof. Its four @e2e slugs would not have closed them anyway: it writes `owner-adds-a-user-share` where the spec heading slugifies to `owner-adds-a-share`. The new file does not fight that. It PROVISIONS what it needs — three throwaway accounts and a group, through the same OCS provisioning API tests/e2e/fixtures/secondary-user.ts uses — and asserts the sharing contract at the HTTP layer, then removes them in afterAll. The sidebar spec still owns the UI and is untouched. HTTP AND NOT DOM, DELIBERATELY. Every scenario in REQ-SHARE-001/002/ 004/006/009 is about what a SECOND user may see or do. The sidebar renders for the owner; the recipient's half of each scenario has no owner-visible UI, so a DOM assertion could only prove the half already covered. Same reasoning ci/public-share-lifecycle.spec.ts records, and the same conventions: `Authorization: Basic` rather than Playwright's reactive httpCredentials, and `storageState: undefined` on every context so no request is silently served as the admin from global-setup. EVERY TEST CARRIES A CONTROL, because most of these assertions are satisfiable by a build with no check at all: * owner-adds-a-share — the share list is asserted EMPTY first. * recipient-cannot-manage-shares — bob is given `full`, the most permissive level, and proven able to READ the dashboard before his POST is required to 403. * recipient-sees-a-shared-dashboard — bob is proven NOT to see it before the share exists, so an endpoint returning every dashboard on the instance cannot pass. * group-share-grants-visibility — dave, a non-member, is required NOT to gain visibility. * most-permissive-level-wins — carol is proven `view_only` with only the direct share, so the final `full` must come from the group. * owner-has-viewonly-recipient-has-full — the widget add is required to be REFUSED at view_only before it is required to succeed at full. Without that arm the success assertion would pass on a build with no permission check. * search-returns-matching-users-and-groups — a query matching nothing must return nothing, or the search is not filtering. * non-owner-is-denied / idempotent-re-PUT — the row set is compared before and after, ids and timestamps included. TWO PLACES WHERE THE SPEC PROSE NAMES A PATH THE APP DOES NOT SERVE, recorded in the file header rather than worked around silently: 1. REQ-SHARE-002 says the recipient's dashboards come from GET /api/dashboards. That route is dashboardApi#list -> getUserDashboards() -> findByUserId(), the caller's OWN rows only. The union that folds in shares is GET /api/dashboards/visible (getVisibleToUser() -> findSharedDashboards(), under a comment naming REQ-SHARE-002). Behaviour implemented, path stale. 2. REQ-SHARE-002 names a field `effectivePermissionLevel`. No response carries that key; /visible carries `source` + `isOwner` and the resolved level comes from GET /api/dashboard/{id} as `permissionLevel` with `isOwner` and `sharedBy`. The tests assert the keys the app actually sends. The idempotent-re-PUT scenario has two clauses. "No rows change" is asserted here, exactly. "No notifications published" is not observable from a browser, which is why REQ-SHARE-008 already carries its own @e2e exclude; the test says so where it sits. NEGATIVE CONTROL, run before this commit: removing this file whole takes gate-19 from 151 back to 162 and dashboard-sharing reappears at exactly 11. Restoring it returns 151. The gate is reading the tests, not the annotations. --list: 78 tests in 19 files -> 89 in 20. * fix(e2e): spell the empty cookie jar explicitly, not as storageState: undefined `storageState: undefined` and `storageState: { cookies: [], origins: [] }` are not equivalent. Option merging treats an explicit `undefined` as "not supplied", which falls back to the project default — and the project default here is exactly the thing being guarded against: playwright.config.ts sets a top-level `use.storageState` pointing at global-setup's admin session. So the previous spelling risked being a no-op against the very inheritance it documents, and the consequence is silent: a valid session cookie outranks a later Authorization header, so every "bob is refused" assertion would have been an assertion about an administrator. The measurement already in the file header (run 31389746411, a non-admin create answering 201 with "createdBy":"admin") is what that failure looks like. The empty-jar literal cannot be read as "not supplied". The same fix, in the same form, closed an identical false green in pipelinq, where a context created to be anonymous read back ocs.data.id === "admin" — and was only caught because that suite's identity guard had been changed to read the resolved uid instead of an HTTP status. No test behaviour changes; 3 call sites, one comment. * fix(e2e): the REQ-SHARE-004 403 was the ROLE-FEATURE allow-list, not the share check — and the control was green for that same wrong reason CI on this branch failed one test: "a full-level recipient may add a widget to a dashboard whose own level is view_only", with `{"error":"Access denied"}`. That body was read as canAddWidget() refusing. It is not attributable that way. `WidgetApiController::denyAddWidget()` has TWO independent 403 branches and both return `ResponseHelper::forbidden()` — the byte-identical body `{"error":"Access denied"}`: 1. PermissionService::canAddWidget() — the share check, which is all REQ-SHARE-004 is about 2. RoleFeaturePermissionService::isWidgetAllowed() — the role-feature widget allow-list, about something else The body cannot distinguish them, so the diagnosis had to come from the fixture. tests/e2e/fixtures/role-feature-permissions.ts installs a RESTRICTIVE `default` row — allowedWidgets: ['activity', 'recommendations'] — and TEN widget-adding specs call it in beforeAll. playwright.config.ts runs workers:1, fullyParallel:false against one instance, so that row was in force here whether this file asked for it or not. isWidgetAllowed() short-circuits for Nextcloud admins; bob is a plain provisioned account, so branch 2 refused `label` outright. That is the failure, and it is a test-fixture collision, not a product bug. THE PART THAT MATTERS MORE THAN THE FIX: the view_only CONTROL arm PASSED THE WHOLE TIME. It expects 403 and branch 2 supplied one. So the control was green while proving nothing whatever about share permissions — a check that could not have failed for the reason it names. It only surfaced because the arm NEXT to it could still fail. Two changes remove the ambiguity instead of working around it: * this file now calls ensureDefaultWidgetRestriction() in its own beforeAll, so the allow-list state is KNOWN rather than inherited from whichever spec happened to run first in the job; * both arms now add `activity`, which that restriction ALLOWS, so branch 2 is constant across them and the share level is the only variable left. The refusal at view_only is attributable to the share check precisely because the SAME widget id succeeds for the SAME user at `full` — that arm is what rules branch 2 out. Also added the splitting probe: after the upgrade POST the share row is asserted to read `full` BEFORE the add is attempted, so any future failure names its own cause — a row still at `view_only` means the upgrade did not take, a row at `full` under a refused add means the per-share level is ignored downstream. Neither assertion was weakened and the control arm is unchanged in strength; it is now merely able to fail for the right reason. Also folds in the correction to the empty cookie jar: `storageState: undefined` does NOT clear an inherited jar — option merging reads an explicit `undefined` as "not supplied" and falls back to the project default, which is the very admin session it was meant to drop. The contexts spell it `{ cookies: [], origins: [] }`, and the stale header line that still described the old form is corrected. gate-19 unchanged at 151. --list unchanged at 89 tests in 20 files. * test(e2e): tile-quick-search — 9 real keyboard/DOM tests for REQ-QSEARCH-001..003 Closes 9 gate-19 findings, all with tests. gate-19: 151 -> 142. A browser is the only place these can be proven. Every claim in these three requirements is about the DOM or the keyboard: which element holds focus after `/`, whether the browser's own Ctrl+K was prevented, whether a non-matching tile is DIMMED rather than removed from the grid, whether aria-activedescendant tracks the arrow keys. A component test cannot reach the interesting half either, because that half is the bridge between RuntimeShellSearch.vue (the combobox) and Views.vue (the grid) — WorkspaceApp.vue joins them with a plain DOM query, by its own comment, "because the grid DOM lives in a sibling component's tree". The file seeds its own dashboard rather than guessing at the shared fixture, which other specs in this serial job are free to change. Four tiles are created through POST /api/dashboard/{id}/tile with titles chosen to make the spec's ranking rule decidable — "Verlof aanvragen" (prefix) against "Overzicht verlof" (mid-string) — and a per-run stamp so a re-run against a warm instance cannot match a leftover tile. Selectors are the component's own `data-test` hooks, not CSS classes. THE ASSERTIONS THAT DISTINGUISH A REAL IMPLEMENTATION FROM A PLAUSIBLE ONE, since most of these scenarios are satisfiable by something weaker: * slash-focuses-the-bar also requires the input to be EMPTY. A handler that focuses without preventing the default leaves a stray "/" in the field, and the focus assertion alone would not see it. * ctrlk-focuses-the-bar installs a listener and reads `event.defaultPrevented` on the very event the app acted on. Asserting focus alone passes on a handler that lets the browser's own Ctrl+K fire as well. * typing-filters-tiles-by-label counts the GRID before and after and requires it unchanged. A filter implemented by unmounting cells would satisfy every other assertion in that test. It also records every /apps/launchpad/ request fired while typing and requires none — the spec says the filter is entirely client-side. * no-query-stored types a unique probe string and requires it in no request URL, no request BODY, and no localStorage/sessionStorage key or value. It waits for the result list to settle first, so it cannot pass on a store that merely had not flushed yet. * arrow-keys checks that the option named by aria-activedescendant is the one carrying aria-selected="true", and that exactly ONE non-colour marker is un-hidden — colour alone would leave zero, which is the WCAG clause the scenario states. * enter-opens-the-selected-tile records window.open and aborts the route, so it asserts which target the app ASKS for rather than depending on a page load — the technique image-widget.spec.ts already uses for REQ-IMG-003. Three tests carry an explicit CONTROL that focus/dimming is not already in the asserted state before the key is pressed, so each assertion is a change rather than a description of the resting state. The four REQ-QSEARCH-004 fallback scenarios are NOT closed here. They need `quicksearch_fallback_target` changed instance-wide and the shell reloaded, and that setting is read from initial state; they are left open rather than annotated. --list: 89 tests in 20 files -> 98 in 21. * fix(e2e): the dimming selector was guessed, and a guessed selector fails SILENTLY Self-review of the tile-quick-search file before trusting its CI result. The de-emphasis class was written as `.is-search-dimmed, .search-dimmed, [data-search-dimmed="true"]` — three guesses, none of them real. The class `WorkspaceApp.vue::applySearchDimming()` actually toggles is `launchpad-grid-item--dimmed`. A wrong selector here does not fail loudly, which is the reason this is worth its own commit. It matches nothing, so it counts zero dimmed tiles before Escape and zero after — and the Escape test was written as `if (dimmedBefore > 0) { expect(...).toBe(0) }`, so the assertion was skipped entirely. It would have gone green on every run, including on a build where the undimming was broken or absent. A check that cannot fail is indistinguishable from one that passed. Fixed properly rather than patched: * the class is named once, in a `DIMMED` constant, with a comment saying why a wrong value is silent; * the Escape test now REQUIRES a non-zero dim count before pressing Escape, instead of tolerating zero. If the selector is ever wrong again, that control fails loudly instead of skipping; * the filter test gained the other half of its scenario — it already proved the grid keeps every tile, and now also requires the non-matching ones to actually carry the class, so "de-emphasised rather than removed" is proven in both directions rather than one. Also replaced the speculative `.launchpad-grid-item, .grid-stack-item` pair with the real `.launchpad-grid-item` (Views.vue:143, and the selector the app's own DOM bridge queries). gate-19 unchanged at 142. --list unchanged at 98 tests in 21 files. * fix(e2e): the Enter-opens-tile test recorded window.open, and the app never calls it Second self-review finding on the tile-quick-search file, and unlike the dimming selector this one would have failed CI loudly rather than silently — which is the only reason it is the less dangerous of the two. I wrote the activation test around recording `window.open`, by analogy with image-widget.spec.ts's REQ-IMG-003. The analogy does not hold. `WorkspaceApp.vue::activateSearchResult()` does not open a window: it finds the rendered cell by `data-placement-id`, scrolls it into view, and calls `.click()` on the cell's own `a[href]`. `__opened` would have stayed empty and the poll would have timed out. Rewritten around the mechanism the app actually uses. A capture-phase `click` listener records the clicked anchor's `href` and `target` and calls preventDefault, so the assertion is about the request the app makes and never touches the network — the seeded links point at the reserved `.invalid` TLD and must not be dialled. This also let the test grow the half of the scenario it was missing. "Enter opens the selected tile, honouring its configured link target (same-tab / new-tab)" has two clauses, and the href assertion only covers the first. `TileWidget.vue` renders `:target="tile.linkType === 'url' ? '_blank' : '_self'"`, the tile is seeded `linkType: 'url'`, so the test now also requires `_blank`. A regression that dropped the target — opening an external link inside the dashboard's own tab — would have passed on href alone. Both defects came from writing against a spec and a plausible mental model instead of against the source. The dimming one was found by checking a selector I had guessed; this one by reading the function the scenario names. Neither was found by the type checker or by `--list`, and the first could not have been found by CI at all. gate-19 unchanged at 142. --list unchanged at 98 tests in 21 files. * fix(e2e): both tile-quick-search failure modes were one weak gate and one click that left the page CI on 2ff919a: 7 failed, 94 passed — every failure in this one file. The 11 dashboard-sharing tests and the public-share test are green, so the rest of the branch stands. Two causes, not seven. CAUSE 1 — THE CLICK NAVIGATED AWAY (tests at :220 and :242). Both failed on their CONTROL with "element(s) not found" on the search input, which reads like a focus problem and is not one: the input was not on the page any more. `page.locator('body').click({ position: {x:5, y:5} })` measures from the BODY's box, whose origin is the top-left of the Nextcloud chrome, so the click landed on core's own header and left the app. Replaced with a `blurToGrid()` helper that focuses `.workspace-shell__grid` — the shell's own container, `tabindex="-1"` per WorkspaceApp.vue, and exactly where `focusGrid()` puts focus after Escape. In-page, representative of a real resting state, and not a typing target, which matters because `isSlashFocusShortcut()` ignores `/` while focus is in a text field. Worth recording: the reading that this was autofocus — that the bar already held focus, making the scenario unobservable as a state change — was offered and is wrong. The error was "element(s) not found", not "element is focused". The control was correct and was reporting accurately; only its setup step was broken. CAUSE 2 — THE WRONG DASHBOARD WAS ACTIVE (the other five). All five failed on an EMPTY result list. The seeding is sound — `PlacementService::addTileFromArray()` -> `applyTileConfig()` sets `tileType='custom'` and `tileTitle=$tileData['title']`, which is exactly what `WorkspaceApp.vue::tileSearchLabel()` searches — so by elimination the tiles being searched were not this suite's. There are two activation mechanisms and they are not interchangeable. `POST /api/dashboard/{id}/activate` sets the legacy id-based `is_active` column; `POST /api/dashboards/active` sets a per-user UUID PREFERENCE, and `getActive()` resolves the shell's dashboard through THAT — its own comment says the active dashboard "can now be a group/default (showcase) dashboard the user does not own (resolved via the last-used preference)". Only the first was being called, so a preference left behind by an earlier spec in the same serial job won. Both are now set. AND THE GATE THAT SHOULD HAVE CAUGHT IT. `openWorkspace()` required only `GRID_ITEM count >= TILES.length`. A dashboard carrying the default widget bundle clears that bar easily, so the wrong dashboard sailed through and five tests failed three steps downstream on a symptom that named nothing. The gate now requires THIS suite's own tiles, matched by the per-run stamp, and says in its message that a failure there means the shell resolved a different dashboard and nothing below is meaningful. That is the same defect shape as the two found by self-review before CI: a check whose passing did not depend on the thing it was there to establish. Also dropped an unused `STATUS` selector constant. gate-19 unchanged at 142. --list unchanged at 98 tests in 21 files. * fix(e2e): CSS.escape ran in Node, and the activation test now names which link broke CI on ea11d48: 2 failed, 99 passed — down from 7. Both remaining failures are in tile-quick-search. FAILURE 1 — `ReferenceError: CSS is not defined`, and it never reached an assertion, so it says nothing about the app. `CSS.escape` appears at two sites in this file and they run in DIFFERENT contexts: * inside `page.evaluate()` — BROWSER, where `CSS` exists. Correct, and left alone. * in the Node test body, building `page.locator('#' + CSS.escape(id))` — where `CSS` does not exist at all. Replaced with `page.locator('[id="…"]')`. An attribute selector needs no escaping and is exact, so this removes the dependency rather than reaching for a Node-side escaper. The consequence worth stating: because this test died before asserting, THE SELECTION MECHANISM WAS UNVERIFIED on that run — and the activation test below depends on the selection being sound. Fixing this one first is not just tidying; it is the prerequisite for reading the other failure at all. FAILURE 2 — "Enter must activate the selected tile", an empty activation list. That message names none of the four links in the chain that could have produced it, which is why the fix here is not another harness rewrite. Two splitting probes now sit before the Enter press: * PROBE 1 asserts the rendered cells carry an `a[href]`, found exactly the way `activateSearchResult()` finds them. That function clicks the cell's anchor and FALLS BACK to `el.focus()` when the anchor is absent — in which case no click is ever dispatched and no harness could record one. If this probe fails, the finding is about tiles seeded through `POST /api/dashboard/{id}/tile` not rendering a link, which is a product observation and will be filed as one rather than absorbed into the test. * PROBE 2 asserts the input still holds focus when Enter is pressed. The assertion itself is unchanged and unweakened — it still requires the tile's own href and its `_blank` target. Only the diagnosis is better: a red run now points at a link in the chain instead of at "nothing happened". gate-19 unchanged at 142. --list unchanged at 98 tests in 21 files. * fix(e2e): Enter-opens-tile is a PRODUCT bug (#95) — test removed, finding left OPEN, dashboards now cleaned up CI on cc5718f: 1 failed, 100 passed. The last failure is not a test defect. WHAT THE SPLITTING PROBES SETTLED. Both PASSED: the rendered cells do carry `a[href]`, and the input does still hold focus when Enter is pressed. The sibling test proving `aria-activedescendant` tracking now passes too, so the selection is sound. Anchor present, focus correct, selection correct, and still no click. `WorkspaceApp.vue::activateSearchResult()` explains it exactly: const placementId = item?.placement?.id // an INTEGER … placementId.replace(/"/g, '\\"') … // TypeError, always `Number.prototype.replace` does not exist. The truthiness guard above it does not help, and the throw is inside a Vue event handler, so nothing surfaces: Enter silently does nothing. A COMPETING HYPOTHESIS WAS OFFERED AND IS REFUTED BY EVIDENCE ALREADY IN HAND — that `this.$el.querySelector(...)` returns null because the grid lives in a sibling component's tree, making the method a silent no-op. It cannot be that. `applySearchDimming()` runs the SAME `this.$el.querySelectorAll('.launchpad-grid-item[data-placement-id]')` query, and the filtering test asserts `dimmed > 0` and PASSES. If `this.$el` could not see the grid, `items` would be empty, no class would be toggled, and that assertion would fail. The scope is fine; the crash happens before `querySelector` is even called, while building its argument. NO `@e2e exclude` IS ADDED. The scenario is browser-observable — the reason it has no passing test is that the feature is broken. An exclusion would record "a browser cannot see this", which is false. The finding stays OPEN against #95, which is the standing rule for a scenario that fails because the product is wrong rather than because a browser cannot reach it. gate-19 therefore goes 142 -> 143, and that increase is deliberate. A SECOND DEFECT FROM THE SAME ROOT, also filed in #95: the dimming assertion requires only `dimmed > 0`, and that is satisfied by "EVERY tile is dimmed" — which is what the app does, because `applySearchDimming()` compares a STRING `getAttribute(...)` against NUMERIC ids with `Array.includes`, so no tile ever matches and the matches get dimmed along with everything else. My own assertion therefore passes for the wrong reason. Tightening it to "matches are NOT dimmed" belongs with the fix, since it would be red on current `development`; it is recorded in the file and in the issue rather than quietly left. ALSO — TEARDOWN. dashboard-share-api.spec.ts created ten dashboards and deleted none, leaving them in a register every later spec reads, and `POST /api/dashboard` also moves the caller's ACTIVE dashboard. That is not hypothetical: tile-quick-search failed earlier on this very branch because a dashboard it did not create was active. Ids are now recorded inside `createDashboard()` at the moment of creation — not by the caller afterwards — so a test that throws still gets its dashboard removed, and `afterAll` deletes them tolerantly before the accounts. --list: 98 tests in 21 files -> 97 in 21. gate-19: 143 (measured).
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ⏭️ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-11 12:50 UTC
Download the full PDF report from the workflow artifacts.
…clusions, plus the quick-search fix (#95) (#99) * fix(quick-search): Enter opens the tile, and only non-matches dim (#95) Two shipped defects, one root cause: a WidgetPlacement id is an integer and a DOM attribute is a string. * `activateSearchResult()` called `.replace()` on the id. `Number.prototype.replace` does not exist, so every activation threw a TypeError inside a Vue event handler — where nothing surfaces it — and pressing Enter on a search result silently did nothing. The truthiness guard above it could not help: a non-zero integer is truthy. * `applySearchDimming()` compared `getAttribute('data-placement-id')` against the raw ids with `Array.includes`, which uses SameValueZero and does not coerce. `[7].includes('7')` is false, so EVERY tile dimmed on every query, including the matches the user was searching for. Why the unit suite was green throughout: all five existing fixtures in WorkspaceApp.spec.js seed a placement id as a STRING ('p1', 'match'), a type the API never sends. Re-running the same assertions with integer ids against the unfixed source fails twice, one of them with the TypeError verbatim. Those integer-id regressions are added here. The e2e side lands with the fix rather than before or after it: * REQ-QSEARCH-003 enter-opens-the-selected-tile now has a real test. It records the anchor's href from a capture-phase click listener with preventDefault, because the seeded links point at example.invalid and a real navigation would fail for a network reason instead of a product one. The two splitting probes from the investigation are kept, so a regression names its own cause. * The filtering test's dimming assertion was 'dimmed > 0', which 'every tile is dimmed' satisfies — it was passing for the wrong reason, and was disclosed as such rather than quietly tightened, because tightening it before the fix would simply have been red. It is now per-element and by identity: the non-matching tile carries the class and the matching one does not, with the aggregate count kept as a control so a refactor that stops applying the class at all cannot pass. No @e2e exclude was added while the feature was broken. Per .github#345 the gate reads an exclusion as positive coverage, so excluding a scenario the browser can plainly observe buys a green with a false statement. gate-19: 143 -> 142. Verified against a local NC fixture: 9/9 Playwright, 26/26 vitest. Closes #95 * test(e2e): cover 14 conditional-visibility-editor scenarios, and find three defects doing it gate-19: 142 -> 128, all of it real tests. Zero @e2e exclusions added, and no spec file touched. Negative control, same tree, one command apart: with the file 128 file removed 142 (+14, exactly its anchor count, all in this capability) restored 128 Verified against a local NC 34 + PostgreSQL 16 fixture: 13/13 Playwright. PostgreSQL matters here — it is the shared workflow's default database, and two of the three defects below are invisible on SQLite and MySQL. WHAT IS COVERED (14 of the capability's 23 scenarios) REQ-CVUI-001 the section loads and renders stored rules; edit sends PUT /api/rules/{id} with the updated ruleConfig; remove sends DELETE and the row goes. REQ-CVUI-002 time / date / attribute operands, asserted on the REQUEST BODY rather than on the re-rendered row — the requirement is about the canonical shape on the wire, and a purely local component could fake the row. Includes the open-ended date range, where the point is that `endDate` is ABSENT, not empty. REQ-CVUI-003 the include/exclude distinction survives every colour in the document being overridden to one value; the empty state, with a control proving it disappears when a rule exists. REQ-CVUI-004/5 preview evaluates an unsaved row and persists nothing (with a positive control, because "no rules stored" is what a broken editor also produces); preview and save send byte-identical ruleConfig; an unknown ruleType is refused 400 while a valid one is accepted; an anonymous caller is refused while the same request with credentials succeeds. THREE DEFECTS FOUND, ALL FILED, NONE PAPERED OVER #96 No EXCLUDE rule can be created at all. `isInclude: false` is a boolean bound into a `smallint` column, so POST and PUT both answer 400 on PostgreSQL. The controller reports it via ResponseHelper::error() WITHOUT a logger, so nothing reaches nextcloud.log either. #97 The group picker is always empty — Views.vue never passes :available-groups — so group rules cannot be authored or previewed. #98 Conditional visibility is NEVER ENFORCED. checkRulesForPlacement() has one production caller and it is the editor's own read; isWidgetVisible() does not exist. Preview says Hidden and the dashboard renders the widget anyway. Proven three ways: browser, a 200 from GET /api/dashboard/{id} still carrying the placement at isVisible:1, and a caller search taken with a positive control. NINE SCENARIOS LEFT UNCOVERED AND DELIBERATELY NOT EXCLUDED Every one of them is browser-observable; what a browser observes is that the feature does not work. Per .github#345 the gate scores an `@e2e exclude` as POSITIVE coverage, so excluding them would have bought nine findings with a false statement. Each is recorded in the file where its test would sit, with the evidence, so the next person does not rediscover it. `includeexclude-toggle` IS covered, narrowly: that requirement is about what the row EMITS and where it MOVES, both observable before the request leaves the browser. It deliberately does not assert persistence, which would be asserting #96 is fixed. TWO TRAPS WORTH CARRYING ELSEWHERE, both recorded in the file * NcCheckboxRadioSwitch has NO <label>. The data-test lands on a visually hidden <input>, and `label[for=...]` matches nothing — association is aria-labelledby only. The clickable element is `#${inputId}-label`. Both wrong locators fail as a 10s timeout that reads like a missing control. `force: true` is not used: it would dispatch at an element no user can reach and stay green if the control became unreachable. * A non-matching INCLUDE rule hides the tile whose editor you then need to reopen, so the fixture locks itself out. Every non-matching rule is seeded alongside one that keeps the subject reachable. * fix(spec): anchor the two quick-search DOM-bridge methods to their spec (gate-16) CI's Hydra Gates job reported `[gate-16] spec-coverage: FAIL — 2 changed method(s) missing @spec`, and it is correct: gate-16 is diff-scoped, and the previous commit changed `applySearchDimming()` and `activateSearchResult()`, neither of which carried an anchor. Reproduced locally with the canonical checker, invoked the way the runner invokes it: HYDRA_GATE_BASE_REF=origin/development check_spec_coverage.py . src/views/WorkspaceApp.vue::activateSearchResult — missing @SPEC src/views/WorkspaceApp.vue::applySearchDimming — missing @SPEC # count=2 Both get a real anchor, not an `@spec exclude`. They are the two halves of REQ-QSEARCH-002 (dim non-matching tiles) and REQ-QSEARCH-003 (Enter opens the selected tile) — the DOM bridge the sibling methods in this file already point at the same canonical spec for. The target is `openspec/specs/…`, never a change directory, and the file was confirmed to exist: a dangling anchor resolves to nothing and gate-19 does not report that as an error. The JSDoc `@param` types are corrected in the same edit, because they were part of how launchpad#95 hid: both said the ids were strings. They are integers. After: gate-16 count=0, gate-19 unchanged at 128, vitest 26/26. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-11 17:26 UTC
Download the full PDF report from the workflow artifacts.
…e-16 84→0, CVE-2026-65954 (#100) * fix(security): bump phpcsstandards/phpcsutils to 1.2.3 for CVE-2026-65954 phpcsstandards/phpcsutils < 1.2.3 carries CVE-2026-65954 (arbitrary code execution, GHSA-r6hr-vr92-vv28, affected >=1.0.0-alpha1,<1.2.3). The advisory was published today, so composer audit turns red on a lock file that has not changed. The Security (composer) job in run 31516037194 passed at 17:25Z only because it ran before the advisory landed. Negative control before the bump, on this tree: composer audit --locked -> Found 1 security vulnerability advisory affecting 1 package phpcsstandards/phpcsutils / CVE-2026-65954 / exit 1 After 'composer update phpcsstandards/phpcsutils --no-install --no-scripts' (1.2.2 => 1.2.3, a lock-only change, 0 installs 0 removals): composer audit --locked -> No security vulnerability advisories found / exit 0 The bump is exercised rather than merely locked: phpcs runs green against the new library on PHP 8.4 -- 0 ERRORS AND 468 WARNINGS IN 211 FILES, exit 0 so no sniff regressed on the upgrade. * refactor: retire the inert groupfolder-storage-backend capability (closes #87, gate-57 1 -> 0) gate-57 (orphaned-write-capability) reported exactly one finding on this repo: lib/Service/DashboardService.php:3189 method=writeDashboardContent rule=orphaned-write-capability class=DashboardService It is a true positive and the real scope is larger than one method. Task 5 of the original change -- the wiring that would have routed dashboard get/create/update/delete through the storage factory -- was ticked in tasks.md but never performed. Everything around it landed, which is why the capability looked complete from every angle except the one that mattered. Measured on this tree, every absence claim taken with a positive control (the same search resolves callers for getUserDashboards and ->findByUuid): DashboardService::writeDashboardContent() 0 callers DashboardService::readDashboardContent() 0 callers DashboardService::deleteDashboardContent() 0 callers DashboardContentStorageFactory::getStorage() 0 (only the three above) DashboardApiController::storageUnavailableResponse() 0 callers DashboardMapper::findAll() 1 -- the migration command So nothing ever populated oc_launchpad_dashboards.content, Dashboard:: jsonSerialize() emitted a content key that was always null, launchpad. content_storage was a no-op setting, and launchpad:storage:migrate-to-groupfolder migrated nothing. Retired rather than completed, per Ruben's decision on #87: finishing it needs an ADR-level ruling on whether the content blob or the WidgetPlacement rows are the source of truth for a dashboard's layout. The spec asserts the blob, the code has used the rows since long before this capability existed, and wiring the blob in as a second source of truth invites divergence. Nobody uses it, so retiring costs nothing and removes that risk permanently. Removed: lib/Service/DashboardContentStorage/ (interface, both backends, three exceptions), DashboardContentStorageFactory, the three DashboardService facade methods and the factory constructor parameter, storageUnavailableResponse(), DashboardMapper::findAll(), both CLI commands and their <command> registrations, the $content/$locale properties with decodeContent() and their jsonSerialize() keys, and the five PHPUnit classes covering the removed tree. Version002009Date20260811000000 drops the content and locale columns. That loses no data: the only writer, DbContentStorage::write(), was reachable only through the factory, which was reachable only through the three orphaned facade methods, so the columns were NULL on every row of every install. The step is idempotent (hasColumn-guarded). Version002001Date20260603000000 and DashboardTableBuilder::addContentStorageColumns() are deliberately KEPT -- a shipped migration is part of the version ledger and deleting it would desynchronise instances that have already run it. Deliberately NOT removed, and tracked as Task 8 on #87: launchpad.content_storage, AdminSettingKey::CONTENT_STORAGE, SetupWizardService::{get,set}ContentStorage / hasGroupfolderApp, AdminController::setWizardStorage and setup-wizard step 2. Those belong to the setup-wizard capability, which WRITES the setting; this capability was the READER. The setting was already inert before this change, so removing the reader does not make it worse, and retiring the wizard step edits a different spec and deserves its own review. The spec is marked withdrawn rather than deleted, which also retires its 16 @e2e exclude markers -- their stated reasons cited DbContentStorageTest.php, a file this change deletes, so they would otherwise have become sixteen exemptions resting on a class that no longer exists. Verification, all on this tree: gate-57 over lib/Service/*.php 1 -> 0 planted true positive (postGate57Probe, a tracked write method with no caller) 0 -> 1, naming exactly the plant plant removed 1 -> 0 PHPUnit 1570 tests, 4032 assertions, 0 failures, 3 pre-existing skips phpcs 0 errors, 468 warnings, 211 files -- identical to the pre-change baseline check_spec_anchors.py over lib/ 0 dangling anchors gate-19 unchanged at 77 (base origin/beta) NOTE for anyone reproducing gate-57: its checker takes a FILE LIST, not a directory. `check_orphaned_write_capability.py .` prints nothing and exits 0 whatever the tree contains, which reads exactly like a clean repo. * chore(openspec): add the missing .openspec.yaml to the retirement change Every other active change dir under openspec/changes/ carries one (schema/created/status); the retirement change was missing it. * docs(spec): annotate the 84 changed frontend methods gate-16 reports (84 -> 0) gate-16 (spec-coverage) reported 84 changed methods without an @SPEC tag against base origin/beta. All 84 were in src/; zero were PHP -- the 273 lib/**/*.php files in the same diff are fully annotated already, established with a positive control (WeatherService.php carries 2 tags, HealthPingController 3) so that "all in one layer" is a measurement rather than a failed lookup. Closed with 84 real @SPEC anchors and exactly 2 reason-bearing exclusions. The anchors point at the specs the methods actually implement -- principally clock-weather-widgets, iframe-embed-widget, live-data-tile-widget, service-health-ping and tile-quick-search. Every one resolves: the canonical check_spec_anchors.py reports 0 dangling anchors across all of src/. That matters because a dangling anchor fails SILENTLY in this gate family, so an unresolvable tag would have made the gate green while asserting nothing. The two exclusions, both verified against the source rather than argued from the scenario text: ClockWidget.vue::pointOnCircle -- pure polar-to-cartesian trigonometry. The body reads neither this.now nor this.content, so it encodes no clock, timezone, format or accessibility behaviour. Verified its only two callers are tickCoords and handCoords, and both carry the REQ-CLOCK-003 tag. useTileClickTracking.js::__resetTileClickTrackingForTest -- test-only harness hook. Verified by grep that its only importer anywhere in the repo is src/composables/__tests__/useTileClickTracking.spec.js; it implements no product behaviour, only clearing cachedEnabled / inflightConfigPromise so each test starts from a cold cache. Both reasons name a concrete artifact (a caller, an importing test file) rather than a state of the world, which is the shape that survives re-checking. This diff is comment-only and provably so: 0 added and 0 removed lines fall outside a docblock. No runtime behaviour can change. Verification: gate-16 (base origin/beta) 84 -> 0 check_spec_anchors.py 0 dangling anchors across src/ tools/check-spec-annotations.php unchanged at 12 pre-existing PHP findings (TileClickMapper, WidgetPlacement, TileClick, ImportLaunchpadRegister, Version002007) -- none introduced here, none in a file this PR touches phpcs 0 errors, 468 warnings, 211 files (baseline) No gate was weakened: no baseline, no allowlist widening, no skipped test. * docs(spec): anchor WeatherWidget::temperatureAriaLabel to REQ-WEATHER-003 Landed a moment after the main annotation commit. gate-16 was already 0 without it, so this adds no gate movement -- but the method builds the accessible label that spells out the temperature unit, which is exactly what REQ-WEATHER-003 ("Locale-aware units and language, WCAG AA") governs, so the tag belongs there. Verified: check_spec_anchors.py reports 0 dangling anchors for the file, and REQ-WEATHER-003 exists at openspec/specs/clock-weather-widgets/spec.md:119. * docs: make the placementItemKey NOTE precise and point it at #101 The note claimed a 'REQ-GRID-010 "Placement key regeneration"' requirement. REQ-GRID-010 is titled **Grid Styling**; the stale scenario merely sits inside it, at spec.md:405. Reworded to say exactly that, to name the line, and to record that getPlacementKey() -- the method the scenario requires -- no longer exists. A wrong diagnosis written into a code comment is worse than no comment: the next reader inherits it as established fact and nobody re-measures it. Filed the substance as #101 rather than leaving it only in a docblock.
Quality Report — ConductionNL/launchpad @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| composer | ✅ | ✅ 102/102 | |||
| npm | ✅ | ✅ 548/548 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-11 21:46 UTC
Download the full PDF report from the workflow artifacts.
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.