Skip to content

Release: merge development into beta - #22

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

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

Conversation

@github-actions

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

Copy link
Copy Markdown
Contributor

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

Merging this PR will trigger the beta release workflow.

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

SudoThijn and others added 30 commits June 29, 2026 14:40
…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.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ 7907ab0

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
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ 446c333

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.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ d010e3f

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.

rubenvdlinde and others added 2 commits August 9, 2026 16:46
…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>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ 598d026

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>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ 63b0e81

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>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ f6204e6

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>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ c489ce8

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.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ 540e5c9

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>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ f800a52

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>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ 62fbb65

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>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ 36247c0

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.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ b00bc4d

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).
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ 38eea6d

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>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ 3051135

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.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/launchpad @ 391d2c9

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants