Skip to content

Release: merge beta into main - #432

Merged
rubenvdlinde merged 425 commits into
mainfrom
beta
Aug 30, 2026
Merged

Release: merge beta into main#432
rubenvdlinde merged 425 commits into
mainfrom
beta

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Stable release: beta holds 418 commit(s) main does not.

Merged with --merge, never --squash. Squashing a promotion rewrites the carried commits into one beta does not contain, so the branches diverge again immediately and main's own commits read as reverted.

A failing … / release check on this pull request is the App Store publish step, not a quality gate. Eight fleet apps cannot publish today: seven have no signing key, and thematiq's certificate carries its old app id (Nextcloud issues one certificate per id, CN = the id). The GitHub release and tag are still created. Every other check must be green for this to merge.

rubenvdlinde and others added 30 commits July 28, 2026 11:30
…e3.1

Drops the USE_LOCAL_LIB dependency on a library source checkout. 2.0.7 could
not be used: the barrel re-exported each component's default straight from
its .vue2.js script module, so webpack redirected past the wrapper that runs
`script.render = render`, dropped the barrel, and app-root-level components
mounted with no render function — a blank page with zero console errors.
2.1.0-vue3.1 anchors that export module-locally so the wiring is reached by
construction.

Verified by building against the PUBLISHED tarball with the local-source
symlink moved aside, deploying, and loading the app authenticated.
Vue 2 required the key on each child of a `<template v-for>`; Vue 3 keys the
whole fragment, so a key on a child is IGNORED and the list renders unkeyed —
losing the identity Vue needs to patch rows correctly on reorder.

CalendarWidget's agenda groups and FilesWidget's path breadcrumbs both used the
Vue-2 form.
78 of 570 unit tests failed for six distinct Vue-2 -> Vue-3 mechanisms,
none of which surface as a compile error:

* `render(h)` — Vue 3 no longer passes `h` to the render function; it is
  imported from `vue`, and the vnode data object is flat rather than
  Vue 2's nested `{ attrs: {…} }`. Fixed in the shared
  `@conduction/nextcloud-vue` stub and the DashboardConfigModal spec.

* `Vue.use(PiniaVuePlugin)` — there is no global `Vue` to install onto,
  and a Pinia instance IS an app plugin under Vue 3. The v1 top-level
  `pinia` mount option is now `global.plugins: [pinia]`; handled in the
  existing vueTestUtilsCompat adapter alongside the other hoisted keys.

* Listener fallthrough — Vue 3 merges listeners into `$attrs` as `onClick`
  and auto-applies them to a stub's root element. A stub that also does
  `@click="$emit('click')"` therefore fires the parent handler TWICE.
  The wizard advanced two steps per click and the shell hamburger toggled
  itself shut. Declaring `emits: ['click']` removes `onClick` from
  `$attrs`, restoring a single path. Applied to every affected stub.

* `v-model` contract — the prop is `modelValue` and the event
  `update:modelValue` (was `value`/`input`, and `checked`/`update:checked`
  for NcCheckboxRadioSwitch). Stubs on the old contract never wrote back,
  so forms silently stayed empty.

* VTU v2 API — `wrapper.destroy()` is `unmount()`, `findAll()` returns a
  plain array rather than a WrapperArray with `.wrappers`, and v1's
  `scopedSlots` is now `slots` (silently ignored, so every slot in
  BeheerTabs rendered empty).

* `reactive()` identity — `state.selectedWidget` hands back a Proxy, so a
  bare `toBe` compared proxy to raw. `toRaw` keeps it an identity check.

Also raises `hookTimeout` to match `testTimeout`: several admin specs
resolve their component with `await import()` inside `beforeEach`, and the
first call pays Vite's full SFC transform cost, overrunning the 10s
default with "Hook timed out" and no assertion failure.
`@nextcloud/eslint-config@8` resolves eslint-plugin-vue's **Vue 2** preset
(visible via `eslint --print-config`: `vue/no-reserved-props` arrives as
`{ vueVersion: 2 }`). Four of the 17 errors were that preset forbidding
syntax Vue 3 *requires*:

  - `vue/no-v-for-template-key` — Vue 2 banned a key on `<template v-for>`;
    Vue 3 keys the fragment there. Replaced with the Vue-3 counterpart
    `vue/no-v-for-template-key-on-child`.
  - `vue/no-v-model-argument` — `v-model:arg` is Vue 3's replacement for
    Vue 2's `.sync`.

`no-multiple-template-root` and `valid-v-bind-sync` are off for the same
reason. Switching wholesale to `@nextcloud/eslint-config/vue3` is not an
option here: it references `@typescript-eslint/*` rules whose plugin this
project does not register, and ESLint crashes on load.

The `vue/no-deprecated-*` family is enabled as errors, because these are
Vue-2 idioms the Vue 3 compiler ignores rather than rejects — the failure
mode is a dead listener at runtime. It immediately found one:
`DashboardRowActions` bound `@click.native.stop`, and `.native` no longer
exists in Vue 3. The component's own docblock already described the
binding as `@click.stop`, so the modifier was stale as well as inert.

`@spec openspec/...` is registered via `definedTags`. It is this repo's
ADR-003 / ADR-020 traceability tag with its own enforcing gate
(`composer lint:spec-annotations`), so the linter needed to learn the tag
rather than the convention being bent — that alone was 746 of the 1242
warnings.

Remaining errors were genuine style violations in three files, autofixed.
…ng focus

`WidgetMovePanel.vue` — the WCAG 2.1 SC 2.1.1 keyboard equivalent of
GridStack's pointer-only drag — was fully implemented, unit-tested, and
never mounted. Its only references in `src/` were its own definition and a
doc comment in `WidgetContextMenu.vue` describing wiring that was never
done: passing tests against a component the app never renders.

The pieces that existed: `WidgetContextMenu` already declared and emitted
`move`, and `nudgePlacement()` in `useGridManager.js` already implemented
the geometry as a pure function returning the clamped rect plus push-down
side effects. Only the seam between them was missing:

  - `useGridManager` gains an `onMove` option and `triggerMove()`,
    mirroring the existing edit/remove/visibility-rules triggers.
  - `Views.vue` binds `@move`, mounts `<WidgetMovePanel>`, and persists a
    confirmed rect through `updatePlacements()` — folding the moved
    placement and any pushed placements into ONE call so the layout is
    written atomically on the same debounced path drag already uses.

Separately, `placementItemKey()` interpolated `updatedAt` and a JSON dump
of `styleConfig` into the per-item render key. Every persist therefore
changed the key, Vue tore down and recreated the grid item's DOM node, and
whatever was focused inside it lost focus. That defeats keyboard
repositioning even once wired: after a single arrow-key move focus fell
back to the document and a second move needed a full re-navigation of the
grid. The key is now the placement id alone — a content change re-renders
through prop updates without needing a new key.
Two distinct causes behind the jsdoc warnings.

The larger one was structural: ~60 methods carried a COMPLETE docblock
immediately followed by a second, `@spec`-only block. Only the block
adjacent to the function is read, so the real `@param`/`@return` docs
were invisible to both the linter and any reader's tooling. Merging the
`@spec` tag into the block above it recovers the existing documentation
rather than rewriting it.

The rest were genuinely undocumented parameters, concentrated in the HTTP
wrapper layer (`services/api.js`) and the admin group-priority editor.
Written out properly — no `@param {*}` filler.
Completes eslint to 0 errors / 0 warnings across `src/`.

The last three warnings were `vue/no-v-html`. Both sites are legitimate and
stay, with a scoped disable and the reason recorded:

  - DashboardFooter renders admin-authored HTML that only reaches the
    client through `FooterService::sanitiseHtml()`.
  - NewsWidget renders third-party RSS, sanitised server-side by
    `NewsWidgetService::sanitiseSummaryHtml()` and again through DOMPurify
    in `formattedSummary()` after truncation.

A `disable`/`enable` pair is used rather than `disable-next-line` because
the rule reports on the `v-html` attribute's own line, which is several
lines below the element's opening tag in both files.

`npm run lint` is a four-step chain, and its third step was failing on a
pre-existing REQ-INIT-003 violation in `src/public.js`. That call was worse
than a style breach — it was dead. It read a `public-share-token`
initial-state key that no PHP ever provides (`PageController::publicShare`
renders the template with no `provideInitialState` call), so it always
returned its fallback and the URL path parse below it did the real work,
exactly as `templates/public.php` documents. The comment above it asserted
the opposite. Removed the dead read and documented the actual source; the
page is anonymous, so adding the key to the initial-state contract would
have meant a server change with no consumer.
NewsWidget renders feed summaries through `v-html`, so how they are
shortened is a security property rather than a formatting detail — and the
file had no unit spec at all.

Covers what the previous raw-offset slice got wrong: the budget counts
visible characters rather than markup bytes, a cut that lands inside a tag
or attribute can no longer emit a truncated anchor, and the
`rel="noopener noreferrer"` REQ-NEWS-005 forces survives truncation.
Also pins the client-side re-sanitise, so script/img/onerror payloads are
stripped even if the server-side pass were bypassed.
Carries the CnDashboardGrid keyboard a11y this app's WCAG 2.1 SC 2.1.1
e2e coverage depends on, plus three crash fixes — notably CnLockedBanner,
whose `message` prop used a `default()` factory reading `this.lockedBy`.
Vue 3 invokes default factories with no `this`, so it threw on mount and
white-screened any page rendered while another session held a lock.

Pinned explicitly rather than via `npm install`: the lockfile had vue3.2
resolved, and the caret range does not force a prerelease bump on its own.
The stub's docblock explained why it exists — the published CJS bundle
`require()`s `.vue` files Vite cannot transform — but not what accepting it
gives up.

The alias in vitest.config.js redirects EVERY import of
`@conduction/nextcloud-vue` to the stub, so not one of this suite's 624
unit tests runs against the real library. A green unit run is evidence
that the stub works, not that the library does; the suite structurally
cannot catch a breaking prop change, a renamed event, or a component that
renders nothing.

The last case is not hypothetical. Earlier in this migration the library
shipped a dist whose render wiring had been tree-shaken away and every
`Cn` component rendered as a silent comment node — blank app, zero console
errors — while the unit suite stayed fully green, because these stubs
render fine. Only the browser caught it.

Recorded at both places a reader lands: the stub docblock and the alias.
Library integration is the Playwright suite's job, and nothing else's.
…k fixture

- src/composables/useGridManager.js: DEFAULT_MENU_HEIGHT/WIDTH were sized
  for the original 3-button context menu (132px/150px). The popover has
  since grown to 5 items (Edit, Move, Visibility rules…, Remove, Cancel)
  and measures ~232.5x156.5px in practice, so the viewport-edge clamp
  under-estimated its footprint and let the real popover render past the
  clamped edge (or push ctx-remove out of the fixed-position viewport
  entirely). Bumped both constants above the measured size with margin.

- tools/deploy-to-launchpad.sh: two pre-existing bugs hit while
  redeploying to verify the above.
  1. The JS-bundle rename loop computes `new` from `f` via a sed that is
     now a no-op (source/target app ids are both "launchpad"), so
     `sed ... "$f" > "$new"` opened-and-truncated its own input before
     reading it, and the trailing `mv "$f.LICENSE.txt" "$new.LICENSE.txt"`
     failed outright ("are the same file"). Now stages sed's output in a
     temp file and only renames the LICENSE sidecar when the path
     actually differs.
  2. `custom_apps/launchpad` is itself a bind mount on this dev box;
     `rm -rf "$DEST"` deletes every file underneath but then fails on the
     mount point itself ("Device or resource busy") — AFTER already
     wiping the directory's contents, leaving the shared dev instance's
     launchpad app empty until the next successful deploy. Now clears
     $DEST's contents (`find "$DEST" -mindepth 1 -delete`) instead of the
     directory itself.

- tests/e2e/fixtures/acknowledgements.ts (new) +
  tests/e2e/dashboard-acknowledgements.spec.ts: REQ-ACK-001 ships no
  admin-facing form UI for declaring an acknowledgement requirement in
  this pass (config path is @e2e excluded) — nothing in the app seeds a
  compulsory widget with an outstanding acknowledgement for the e2e specs
  to observe. Added ensureOutstandingAcknowledgement(), which seeds one
  through the real, authorised PUT /api/widgets/{id} contract (same
  pattern as fixtures/role-feature-permissions.ts) and wired it into a
  beforeAll so REQ-ACK-002 (forced-delivery gate) and REQ-ACK-004
  (read-receipt report) have real state to exercise. Idempotent: reuses
  the marked placement and bumps acknowledgementContentVersion each run
  so a prior run's receipt never leaves it satisfied.

- package.json / package-lock.json: consume
  @conduction/nextcloud-vue@2.1.0-vue3.4 (already in progress before this
  session).
…l safety

- src/main.js: nc-vue's CnNcWidgetWidget self-registers `nc-widget` with
  `form: null` ("CnNcWidgetWidgetForm is not yet present in this tree" —
  stale; CnNcDashboardWidgetForm + CnNcWidgetGridPicker DO exist in this
  nc-vue version, just never wired to the registration). listWidgetTypes()
  filters null-form entries, so Add Widget's type picker silently never
  offered "Nextcloud widget". Complete the registration from launchpad
  using nc-vue's own public last-registration-wins registry API — the
  same pattern nc-vue itself uses to re-register table/object-list/map.

- src/views/Views.vue: CnNcDashboardWidgetForm injects a `widgets` catalog
  for its grid picker; nothing provided it, so it always saw the inject
  default ([]) and rendered empty regardless of the form fix above. The
  widget store already fetches this exact Nextcloud-native dashboard-widget
  list (loadAvailableWidgets — its own comment notes it feeds
  CnNcWidgetWidget's runtime renderer). provide('widgets', ...) it down
  from Views.vue's setup() so the picker has real data.

- src/styles/workspace.css: CnLabelWidget renders a flex container with a
  <span> text child; flex items default to min-width:auto, which floors
  the span's size at its longest unbreakable word regardless of the
  component's own overflow-wrap:break-word, so a very long single word
  overflows the widget cell. Patched with an additive (non-conflicting)
  min-width:0 rule, the same flex-child pattern already used for
  .launchpad-workspace above it in this file.

- tests/e2e/widget-context-menu.spec.ts: right-edge and bottom-edge popover
  tests picked ".grid-stack-item.first()" and clicked via raw
  page.mouse.click(x, y) at its boundingBox() coordinates. GridStack
  positions items via gs-x/gs-y, not DOM order, so on this long-lived,
  heavily-populated shared dashboard the "first" item can sit well below
  the fold; toBeVisible() doesn't catch that (it's a CSS-visibility check,
  not a viewport-scroll check), so the click silently landed off-screen
  and the popover never opened. scrollIntoViewIfNeeded() before reading
  the box fixes it without touching any assertion.
tools/deploy-to-launchpad.sh resolved its target container with
`docker ps -qf name=nextcloud` — a SUBSTRING match. On a box that also runs
a shared `nextcloud` dev container (used by other apps/worktrees), that
substring silently matched the WRONG container: one that bind-mounts
`custom_apps/launchpad` straight onto a real host checkout. Every
"successful" deploy this session wrote through that mount onto disk
instead of reaching `lp-vue3-e2e` (the container the e2e suite actually
points at via NC_BASE_URL=http://localhost:8098) at all — the suite kept
testing a stale bundle while every app-source fix silently landed on a
real checkout instead of a disposable instance.

Fixes:
- Resolve the target via `LAUNCHPAD_DEPLOY_CONTAINER` (default
  `lp-vue3-e2e`), verified by exact name/id via `docker inspect`, never a
  substring filter.
- Refuse to deploy — loudly, before touching anything — if the resolved
  container's app directory (or any ancestor of it) is a BIND mount. A
  bind mount means the container is wired onto a real host path; a volume
  or a plain in-container directory is fine. This is the general guard:
  even with the container-name bug fixed, nothing should silently write
  through to someone's checkout again.

Verified both directions against the live containers on this box:
LAUNCHPAD_DEPLOY_CONTAINER=nextcloud is now refused (bind mount detected
at custom_apps/launchpad), and the lp-vue3-e2e default proceeds (volume
only, no bind mount in its ancestry).
Picks up nc-vue PR #558 (fix(CnIndexPage): scroll the table, not the page
column, in table view), merged into feat/vue-3 this morning. Folded into
the same re-baseline cycle as the deploy-target fix so there is one clean
measurement against the library version the app will actually ship with,
rather than two rounds.
Picks up the CnWalkthrough persistence fix: completionConfigKey was
schema-declared, documented, and spec-mandated, but had neither a read
nor a write path wired up (grep only hit the schema file) — so dismissal
was localStorage-only and the tour reopened in every fresh browser
context. Full e2e re-run at this pin: 73 passed / 1 failed / 4 skipped.
…vue3.7

- tests/e2e/widget-context-menu.spec.ts: the right/bottom-edge popover tests
  read boundingBox() immediately after page.setViewportSize(), but that
  resize triggers a GridStack column reflow and every .grid-stack-item
  animates to its new position over GridStack's own 300ms CSS transition
  (.grid-stack-animate, gridstack.css). A box read mid-transition describes
  an in-flight frame, not the rest position — harmless for a click well
  inside a widget, but these tests deliberately click within 20-30px of
  the edge, so the animation delta was enough to land the click in the
  grid gutter and never open the popover. Measured 2-in-3 failure rate
  before; added waitForStableBox() (polls until two consecutive reads
  match, no fixed sleep) and verified 3/3 clean runs after.

- tests/e2e/fixtures/secondary-user.ts (new): throwaway-user provisioning
  (OCS API), known-password reset for a pre-seeded account, and a
  loginAs() that authenticates a SECOND, genuinely separate session.
  storageState: undefined is load-bearing: browser.newContext() otherwise
  inherits playwright.config's top-level use.storageState (the shared
  admin session), so every login attempt was silently already
  authenticated as admin and never hit the login form at all.

- tests/e2e/runtime-shell-canEdit.spec.ts: enabled the two empty-state
  scenarios (provision a real zero-dashboard account, toggle
  allowUserDash, assert). Also fixed setAllowUserDashboards: it was
  issuing its PUT through the cookie-authenticated built-in `request`
  fixture, which Nextcloud's CSRF check rejects outright on a
  state-changing route (measured: 412) — the failure was swallowed by a
  console.warn instead of failing the test, so the "false" scenario ran
  against whatever the flag happened to still be. Rewritten to use its
  own Basic-Auth + OCS-APIRequest admin context (matching
  allow-personal-dashboards-flag.spec.ts) and to throw on a non-OK
  response instead of warning.

- tests/e2e/active-dashboard-resolution.spec.ts: enabled the fresh-user
  empty-state scenario using the same throwaway-user fixture.

- tests/e2e/dashboard-sharing.spec.ts: investigated and restored the
  recipient-visibility skip with an accurate reason (two independently
  confirmed causes, neither a stale selector): DashboardResolver
  (lib/Service/DashboardResolver.php) only resolves a user's active
  dashboard from owned/group/template rows and never considers dashboards
  merely shared to them, so a recipient with no dashboard of their own
  lands on the empty state regardless of what's shared; and the ADR-023
  action-authorization matrix defaults every action to admin-only, with
  nothing on this instance ever broadening it — confirmed live via
  OCSForbiddenException on the account's first non-admin AJAX call. Both
  are real product/environment considerations for a follow-up change, not
  something one spec's fixture should paper over.

- package.json / package-lock.json: consume
  @conduction/nextcloud-vue@2.1.0-vue3.7 (nested-modal stacking fix).
  Checked launchpad's modal usage — every NcModal/NcDialog/Cn*Modal is a
  sibling in Views.vue's template, none nested inside another, so this is
  expected to be a no-op here; full e2e run confirms no regression.
feat(vue3): migrate Launchpad to Vue 3 and @conduction/nextcloud-vue 2.1.0-vue3.7
…admin action baseline

Defect 1 — a share was inert. DashboardMapper::findVisibleToUser() unions
exactly three buckets (owned rows, group_shared rows in the user's groups,
the 'default' sentinel); share rows were in none of them. A recipient with
no dashboard of their own landed on the empty state and the shared
dashboard was unreachable everywhere: not in the switcher, never a
resolution candidate, not fetchable by id.

DashboardResolver now owns the share lookup (findSharedLevels,
findSharedDashboards, tryGetSharedDashboard) and DashboardService folds it
into getVisibleToUser(), resolveActiveDashboard() (new last-resort step 6b)
and getEffectiveDashboard() (before the writing template step). Precedence:
a share is the LAST candidate, so it can never displace anything the user
owns, reaches through a group, or explicitly selected. Shared results carry
the SHARE's permission level, not the owning row's.

Defect 2 — the app was unusable by non-admins on a fresh install. The
ADR-023 seed mapped every declared action to ["admin"], so a non-admin hit
'Action dashboard.list requires admin rights' on the first AJAX call —
including the "Create your first dashboard" CTA the empty state shows to
exactly those users. Added a GROUP_ALL_USERS ('@ALL') sentinel, granted it
to the ordinary end-user surface in the seed, kept every administrative
action admin-only, and added a version-gated ApplyActionBaseline repair
step so already-installed instances get the baseline too without
overwriting admin customisation.

Tests: 28 new PHPUnit tests, all verified to fail against the unfixed code
(Defect 2 fails behaviourally with the real OCSForbiddenException).
…nd the @ALL matrix column

Both suites verified to FAIL against origin/development's components:
 - DashboardSwitcherSidebar: 4 of 5 new tests fail (shared rows leak into
   the primary-group section, no data-section="shared", switch emits no
   shared discriminator, section order is [group, default, user]).
 - ActionAuthMatrix: 2 of 6 fail (displayGroups is ['admin','editors'] —
   the @ALL column the shipped baseline needs is never rendered, so an
   admin can neither see nor revoke the non-admin grant).
…ipient e2e

The server-side fix alone was not enough end-to-end. Every source-aware
getter in the dashboard store filters by an EXPLICIT source value, and
Views.vue's sidebarGroupDashboards only concatenated the group and default
buckets — so 'shared' rows survived the server-rendered initial state but
vanished the moment the store refreshed from /api/dashboards/visible. Added
a sharedWithMeDashboards getter, wired it into the sidebar input and into
activeDashboardSource so the row cog gates owner-only entries correctly.

e2e: un-skipped 'recipient sees the shared dashboard in their switcher'
(both stated blockers are now fixed) and added two ADR-023 scenarios in
runtime-shell-canEdit.spec.ts — an ordinary user may call the end-user API
surface but not instance analytics, and the empty-state Create CTA the app
itself offers actually returns 200 and persists.

Store spec verified failing against origin/development's store (rc=1);
30/30 pass with the getter.
…ally runs

A repair step only executes when NC sees a version increase. The e2e
instance was confirmed live to still hold the all-admin matrix with
`actions_baseline_version` unset while installed_version already equalled
info.xml — so without this bump `occ upgrade` is a no-op, the baseline
never reaches an existing install, and the fix would have looked shipped
while changing nothing.
toHaveCount(0) on .workspace-shell__empty passes trivially on a blank
page, before Vue mounts. Wait for the app to render EITHER the shell or
the empty state first, then assert it is not the empty state.
e2e caught what every unit test here missed. DashboardFactory sets
STATUS_DRAFT on every dashboard it creates (all 15 rows on the e2e
instance are drafts), and filterByPublicationState() hides drafts from
non-owners — so the previous commit appended the share and then filtered
it straight back out. Live: GET /api/dashboards/visible returned
{items:[]} for the recipient while GET /api/dashboard returned the
dashboard, because tryGetSharedDashboard() never went through the filter.
Two paths, opposite answers, and the switcher showed nothing.

Shares are now appended AFTER the publication filter and are exempt from
the draft/scheduled hide: a share row is the owner's explicit, named
grant to that specific user — the same class of entitlement that already
exempts the owner and admins, and what PermissionService::resolveAccessLevel()
already honours. A user WITHOUT a share still cannot see a draft
(testUnsharedDraftStaysHiddenFromNonOwners). Due scheduled rows still
materialise in memory via the extracted materialiseDueSchedule().

Root cause of the miss: the unit fixtures called setPublicationStatus(
STATUS_PUBLISHED) explicitly, so they were unrepresentative of what the
factory produces. The new test builds through DashboardFactory and
asserts the factory default, so a change there fails a unit test rather
than a browser.

Also fixes a made-up URL in the new ADR-023 e2e test: it hit
/api/analytics/instance-summary (404) instead of the real
/api/admin/analytics/summary — a 404 proves nothing about authorization.
…est non-admins

There is NO authorization hole. The reported 200 from the admin-only
instance-analytics endpoint was my own test authenticating as admin.

pwRequest.newContext() inherits playwright.config's top-level
use.storageState — the shared ADMIN session cookie from global-setup — so
Nextcloud authenticated the request by cookie and never looked at
httpCredentials at all. This is the exact trap loginAs() already documents
for browser.newContext() in this same file. Measured three ways against
the deployed code: a freshly provisioned zero-group user gets 403 on
/api/admin/analytics/{summary,dashboards/top,export}; unauthenticated gets
401; only an admin gets 200.

Consequences and fixes:
 - storageState: undefined, so the context is genuinely the throwaway user.
 - httpCredentials send:'always' — NC replies with a bare 401 carrying no
   WWW-Authenticate header, so Playwright's default challenge-response mode
   has nothing to respond to and would never send the credentials.
 - the end-user-surface assertions were not.toBe(403), which also passes on
   401 and 404; they now assert toBe(200) so 'never authenticated' and
   'wrong URL' can no longer read as success. Both false greens really
   happened while writing this spec.
 - the Create CTA expects 200 or 201; 201 Created is correct for that POST.
   401/403/404/5xx still fail.

loginAs(): click() also waits for the navigation it schedules, bounded by
actionTimeout (10s), NOT navigationTimeout (60s). The NC login POST +
redirect + hydration exceeds 10s on a loaded box, so the click failed while
the login had SUCCEEDED — the saved snapshot at failure shows a fully
authenticated page (#app-dashboard, Applications nav, Settings menu), not
the login form. Handed the wait to the explicit waitForSelector('#header')
that already followed it with its own 45s budget. Not a timeout increase:
if the login really fails, #header never appears and it still fails.
…bac-baseline

fix(sharing,rbac): make a shared dashboard actually reachable, and a fresh install usable by non-admins
Fix Conduction → ConductionNL org reference (all workflows failed with
"workflow was not found") and rename the branch-protection caller job
`check` → `branch-protection` to satisfy the org ruleset's required check.
… for two months (#21)

* ci: point reusable-workflow calls at ConductionNL, not the non-existent Conduction org

All 8 reusable-workflow calls referenced `Conduction/.github`. That GitHub org
does not exist (`GET /orgs/Conduction` is a 404) — the 2026-06-01 rename went
the wrong way. Actions cannot resolve the ref, so every run produced ZERO jobs
and failed instantly. No ESLint, PHPCS, PHPMD, PHPStan, Psalm, licence scan,
security scan, SBOM, PHPUnit or Newman has run on this repo since.

The tell: in `gh run list --json name`, an unresolved run's `name` is the raw
path (`.github/workflows/code-quality.yml`) rather than the workflow's declared
`name:` (`Code Quality`).

Two workflows additionally pinned `@feature/openspec-project-sync`, a branch
that does not exist on ConductionNL/.github either — those would still not have
resolved after an org-only fix. Both now use `@main`.

An earlier branch (`feature/workflow-update`, commit c6b8424) claimed to fix
this but only touched `branch-protection.yml`; `code-quality.yml` was left
broken and its run still produced 0 jobs.

`.forgejo/workflows/` is deliberately untouched — on Codeberg the org really is
`Conduction`, so those refs are correct.

* fix(deps): resync package-lock.json so `npm ci` resolves again

Every npm-based quality job (ESLint, Stylelint, License (npm), Security (npm))
died before running a single check:

  npm error `npm ci` can only install packages when your package.json and
  npm error package-lock.json ... are in sync.
  npm error Missing: pinia@4.0.2 from lock file
  npm error Missing: vite@8.2.0 from lock file
  ... + the whole esbuild/rolldown/lightningcss platform-binary fan-out

Cause: `@nextcloud/vue` carries a nested `vue-router@5.2.0` whose
`peerDependenciesMeta`-optional peers are `pinia: ^3.0.4 || ^4.0.2` and
`vite: ^7.3.0 || ^8.0.0`. Those versions have since been published, so npm's
ideal tree now includes them while the committed lockfile predates them. The
lockfile was simply never regenerated — nobody noticed, because the workflow
that would have caught it produced zero jobs.

Regenerated with the npm major this repo pins (`engines.npm: ^10.0.0`) on
Node 20. npm@11 must NOT be used here: it reports this lockfile as "up to
date" and produces one that npm@10 `ci` then rejects.

The diff is additive and does not touch the app's own dependency graph:
  - 0 packages removed
  - 0 nested version changes
  - 1 version change: @napi-rs/wasm-runtime 1.1.6 -> 1.2.2 (a wasm shim)
  - ~60 additions, all platform binaries pulled in by the optional peers above
Vue, pinia (top-level 2.3.1), @nextcloud/vue and @conduction/nextcloud-vue are
all unchanged, so the produced bundle — and the e2e runs verified against it —
are unaffected.

Fixed by resyncing rather than by `--legacy-peer-deps` or an `overrides` block:
suppressing the resolution would leave the lockfile lying about the tree that
`npm ci` actually builds.

* fix(quality): clear everything the revived Code Quality gates surfaced

With the workflow refs and lockfile fixed, all 23 jobs actually run. This
clears what they found. ESLint and Stylelint needed nothing once `npm ci`
worked — they were failing at install, not on findings.

PHPCS: 277 errors -> 0
  Applied the repo's own `phpcbf` for the 237 mechanical ones (docblock
  alignment, parameter spacing, operator spacing), then hand-fixed the rest:
  22 ternaries rewritten as statements (`Squiz.PHP.DisallowInlineIf`),
  5 uncapitalised inline comments, 3 doc short-descriptions, 3 over-length
  lines, 2 missing `@param` tags on IframeService::__construct(), and a
  missing short description.

  On DisallowInlineIf specifically: launchpad's phpcs.xml is byte-identical to
  openbuild's apart from the description line, and openbuild has zero
  violations of this sniff. It is the fleet standard and it is met elsewhere,
  so the ternaries were rewritten rather than the rule relaxed.

PHPStan: 2 errors -> 0 (both real)
  - PublicShareService::renderShareContent() is documented
    `@return array{share, dashboard}` but actually returns a third key,
    `placements`, which PublicShareController::show() reads. The declared
    contract omitted a key the only caller depends on. Corrected the shape and
    imported WidgetPlacement.
  - LiveTileService::extractValue() looked unreachable to PHPStan because it
    cannot model PHP filling an unmatched alternation group with `''`. The
    branch was genuinely reachable, but discriminating on the group that is
    actually absent (`$token[2]`) rather than the one that is empty is both
    clearer and analysable. No suppression.

PHPMD: 37 violations -> 5
  Fixed outright: 2 UndefinedVariable (PHPMD cannot see `preg_match`'s by-ref
  `matches:` out-param — declared it explicitly), 3 ElseExpression,
  2 LongVariable, 1 CountInLoopExpression, and 4 complexity offenders
  decomposed into named helpers (readBaseline, checkFramable, extractValue,
  diffCopy, updateSettings).

  Suppressed per-site WITH a written reason, using this codebase's existing
  `@SuppressWarnings` convention — not a blanket ignore, and each still fails
  if the justification stops holding:
  - 6 UnusedFormalParameter: `changeSchema()` / `preSchemaChange()` override
    SimpleMigrationStep, whose signature Nextcloud fixes. The parameters
    cannot be removed.
  - 8 StaticAccess: every call is to a genuinely stateless `public static`
    helper (ResponseHelper, AnalyticsService::periodToDateRange,
    UniqueViewerDedup::utcDateFor, TileClicksTableBuilder::create). Injecting
    their owners to reach them would add collaborators used for nothing else
    and push two of these classes further over CouplingBetweenObjects.

NOT fixed — stated plainly rather than hidden
  5 PHPMD violations remain and `PHP Quality (phpmd)` stays red:
  3x CouplingBetweenObjects (TileAnalyticsController, LiveTileService,
  WeatherService — all at exactly 13, threshold 13) and 2x
  ExcessiveClassComplexity (AdminSettingsService 58, HealthPingService 61).
  These need real architectural change — splitting services and reducing
  constructor dependencies — which should not ride along in a CI-restoration
  PR. I did NOT add a phpmd baseline or relax the thresholds to bury them.

* fix(deps): pin @conduction/nextcloud-vue 2.1.0-vue3.13 to get off proprietary vue3-apexcharts

The revived licence gate caught a real exposure: `vue3-apexcharts` was MIT up
to and including 1.8.0, and at 1.9.0 (2025-10-13) moved to a proprietary
dual-license — free only under $2M USD annual revenue, forbidding
"sublicensing under different terms" (which is what redistributing inside an
EUPL-1.2 app is) and requiring a paid OEM licence for "No-code dashboards",
"Embedded BI tools" and "White-labeled apps or SDKs".

Not the same as the `apexcharts` entry in .license-overrides.json: that one is
a genuine license-checker misread of an MIT package, and the core
apexcharts@4.7.0 is still MIT. Only the Vue 3 wrapper changed licence. The
identical `Custom: <image-url>` symptom made them look alike.

The dependency is transitive through @conduction/nextcloud-vue, and the fix
already existed upstream:

    2.1.0-vue3.7  -> vue3-apexcharts ~1.10.0   (proprietary)
    2.1.0-vue3.13 -> vue3-apexcharts ~1.8.0    (MIT)

This repo declared `^2.1.0-vue3.7`. A caret does not move an already-resolved
prerelease in a lockfile, so the pin held us on the proprietary line even
though a fixed release had shipped. Now pinned EXACTLY, without a caret — a
caret floats prereleases, and that is how this drifted in the first place.

Verified from the lockfile rather than the manifest:
  - node_modules/vue3-apexcharts: 1.10.0 -> 1.8.0
  - exactly one copy in the tree; zero 1.9.0+ entries at any nesting depth
  - installed package.json now declares `"license": "MIT"` (was
    `"see LICENSE in LICENSE"`), which is what license-checker reads

No override was added. .license-overrides.json is unchanged and contains no
vue3-apexcharts entry — the gate passes because the dependency changed.

Lockfile churn is exactly two entries: the nc-vue bump and the apexcharts
downgrade. 0 additions, 0 removals, 0 nested version changes.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Aligns launchpad on the current nc-vue release, pinned EXACTLY (no caret).

Verified from the LOCKFILE (not package.json):

  - `@conduction/nextcloud-vue` resolves to exactly `2.1.0-vue3.16`,
    a single instance, no nested duplicate.
  - `vue3-apexcharts` resolves to `1.8.0` — below the 1.9.0 boundary at
    which that package became proprietary and stopped permitting
    sublicensing, which our EUPL-1.2 apps require.

Opt-ins evaluated:

  - `@nextcloud/initial-state` `overrides` entry — not present, nothing
    to drop.
  - local `vue/no-multiple-template-root: 'off'` — DELIBERATELY KEPT.
    vue3.16 does switch this rule off in nc-vue's shared eslint preset,
    which is why the fleet sweep removes the local disable elsewhere.
    launchpad, however, does not extend that preset: it corrects the
    inverted Vue-2 rules individually on top of `@nextcloud` (removing
    the local disable was verified with `eslint --print-config` to take
    the rule from `[0]` straight to `[2]`, i.e. it would ARM a rule that
    forbids valid Vue 3 fragment syntax). Only a comment recording that
    finding is added. The line can go when launchpad adopts the shared
    preset.
  - e2e base-URL resolver — NOT switched to nc-vue's shared resolver.
    launchpad's `playwright.config.ts` and several specs still carry a
    `process.env.NC_BASE_URL ?? 'http://localhost:8080'` fallback, and
    `localhost:8080` is the SHARED development container. Swapping the
    resolver underneath a live 8080 default is a change of blast radius,
    not a version alignment, so it is left for its own PR.
…bject grants

Task 8.3 of openregister's object-level-sharing-and-private-scope. The task was
written as "migrate the derived sharedWith list to the primitive" — there was no
list to migrate. `sharedWith` is declared in launchpad_register.json, seeded as []
by Version002000Date20260519000000, and named in ManifestController's docblock,
and NOTHING READS IT. Only the `owner` filter ever ran. A shared dashboard has
never appeared in anybody's manifest.

The `$seen` dedup map in fetchUserDashboards() is the tell: it exists to reconcile
two sources, and there was only ever one.

So this is net-new rather than a migration. fetchGrantedDashboards() asks
OpenRegister's ObjectGrantResolver for the object UUIDs granted to the caller with
`read`, loads them in one IN(...) query, and folds them through the same $seen map
so a dashboard both owned and granted appears once. The docblock now describes
what the code does.

WHY ADDITIVE, rather than dropping the owner filter and letting RBAC decide.

Letting RBAC decide is tidier, and it is what the OpenRegister `private` scope is
for. But it is only safe once the `dashboard` schema actually carries
`scope: private`, and a register-descriptor change lands through a repair step on
upgrade — so there is necessarily a window, and on any instance where that import
did not apply an indefinite one, in which the schema is still unscoped. An
unfiltered findAll() against an unscoped schema returns EVERY user's dashboards.
The owner filter therefore stays and grants only ever ADD rows. The failure mode
of this shape is a missing dashboard; the failure mode of the other is a
cross-tenant leak in the manifest. Giving the dashboard schema `scope: private` is
a separate, breaking policy change and is deliberately not in this commit.

`read` is the verb, because appearing in a manifest is a read. The resolver
answers only for the five core permission verbs and refuses the rest, so this
cannot silently widen.

Fails soft: an OpenRegister release predating the sharing primitive simply has no
such class, and the manifest degrades to owned-only — exactly its behaviour
before this change. That path is tested, and its control is the test above it:
with the resolver present the same fixture yields two pages, so one page means
the resolver's absence is what dropped it rather than a fixture that never had a
granted row.

The over-cap path warns rather than truncating silently, because a truncated
manifest that logged nothing is indistinguishable from "nothing is shared with
this user".

The tests earned their keep immediately: they caught array_values() where
array_keys() was needed. grantedObjectUuidsFor() returns uuid => bitmask, so the
first version queried `uuid IN (1)`, matched nothing, and would have shipped a
feature that silently returned zero shared dashboards — the exact failure the
tests assert against.

ALSO FIXED, both pre-existing and both found on the way here.

1. 94 call sites of `$qb->select(selects: ...)`. select() is VARIADIC, so a named
   argument arrives as ['selects' => ...] — a string-keyed array — and core's
   `count($selects) === 1 && is_array($selects[0])` unwrap reads a key that does
   not exist, emitting "Undefined array key 0" on every call. The string form
   worked by luck, since quoteColumnNames() maps over values; that is why 94
   sites survived unnoticed. The ARRAY form was genuinely broken —
   Version001006Date20260430130000 passed ['id','share_type','share_with'] where
   the unwrap was the thing meant to flatten it, so an array went in as a column
   name. Note that our own PHPCS standard ("all arguments in calls to internal
   code must use named parameters") is what manufactured this; the rule needs a
   variadic exemption or every new mapper reintroduces it.

   This was carried in openregister's task list as a pre-existing OpenRegister
   warning. It is not in OpenRegister at all — the backtrace lands in launchpad's
   DashboardMapper, reached from OR's tests only because a tearDown() user
   deletion fires launchpad's UserDeletedListener.

2. `composer psalm` and `composer test:all` both ended in `|| echo '...skipping'`,
   so both ALWAYS EXITED 0 and neither could ever fail check:strict. A PR
   breaking all 1513 unit tests would have reported success. Replaced with the
   `if [ -f ... ]` form, which still skips explicitly when a tool is genuinely
   absent but propagates a real failure. Verified rather than assumed: the new
   form exits 1 on a deliberately failing test where the old form exits 0.

Verification: 1513 unit tests pass (3 skipped) — unchanged from baseline, so the
94 select() edits regressed nothing. 5 new tests. phpcs error-severity clean on
lib, identical to baseline. phpstan "no errors". psalm "no errors found".
…be nested

Found by live-verifying the grant feature on a real instance, which is the only
reason it was found at all: every unit test passed, because they double
ObjectService and therefore confirm the call shape I invented rather than the one
OpenRegister accepts.

Measured on the dev instance, admin owning two dashboards:

  no owner filter at all                    -> 2
  'owner'       => 'admin'                  -> 0     <- what shipped
  '@self.owner' => 'admin'   (dotted)       -> 0     <- my first fix, also wrong
  '@self' => ['owner' => 'admin'] (nested)  -> 2     <- correct
  '@self' => ['owner' => 'nobody-xyz']      -> 0     <- control

OpenRegister splits filters into metadata filters — the magic table's
`_`-prefixed columns, addressed as a NESTED `@self` array — and property filters,
matched against the schema's own properties. A bare `owner` is read as a property
filter on an `owner` property, which the dashboard schema does not have, so it
matched nothing. `GET /apps/launchpad/api/manifest` therefore returned
`pages: []` to EVERY user, including the owner of the dashboards. Dotted
`@self.owner` fails the same way; only the nested array works.

The last row is the control, and it is the point: without it, "nested returns 2"
is indistinguishable from "the filter is ignored and returns everything".

Same class of bug in the granted query, fixed the same way: `filters['uuid']`
would have degraded to a property filter, so it now uses the top-level `ids`
config key, which OpenRegister matches against `_uuid` OR `_slug`.

Both spellings are now pinned by assertions rather than left to comments, since
each one fails SILENTLY — returning zero rows, never an error.

RBAC and multitenancy are NOT involved, which was worth ruling out rather than
assuming: with no owner filter, `_rbac: true, _multitenancy: true` returns both
rows, so the caller's own scoping was already correct.

LIVE END-TO-END, every row with its HTTP status checked:

  admin (owns both)                     200   both dashboards
  recipient, no grant                   200   []
  recipient, granted read on 1 of 2     200   exactly that one
  recipient, grant revoked              200   []

The recipient seeing exactly ONE of the two owned dashboards is the assertion that
matters: it is the positive result and the negative control in the same
measurement. The revoke row proves the grant is what admitted it.

A note on how nearly this was mis-verified: my first recipient check reported
`pages: []` and I read it as "no shared dashboards". It was a 403 body,
`{"error":"Forbidden"}`, and the probe did `d.get('pages', [])` — so a refusal
rendered as an empty success. Checking the status code is what exposed it. The
403 itself was instance state, not a defect: launchpad's stored action matrix
predates PR #19's `@all` baseline, so `manifest.index` is still admin-only there.
dependabot Bot and others added 16 commits August 30, 2026 09:58
Bumps [vue](https://github.com/vuejs/core) from 3.5.41 to 3.5.42.
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](vuejs/core@v3.5.41...v3.5.42)

---
updated-dependencies:
- dependency-name: vue
  dependency-version: 3.5.42
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [phpmetrics/phpmetrics](https://github.com/phpmetrics/PhpMetrics) from 2.9.1 to 2.11.0.
- [Release notes](https://github.com/phpmetrics/PhpMetrics/releases)
- [Changelog](https://github.com/phpmetrics/PhpMetrics/blob/master/CHANGELOG.md)
- [Commits](phpmetrics/PhpMetrics@v2.9.1...v2.11.0)

---
updated-dependencies:
- dependency-name: phpmetrics/phpmetrics
  dependency-version: 2.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…408)

Bumps [phpcsstandards/phpcsextra](https://github.com/PHPCSStandards/PHPCSExtra) from 1.5.0 to 1.5.1.
- [Release notes](https://github.com/PHPCSStandards/PHPCSExtra/releases)
- [Changelog](https://github.com/PHPCSStandards/PHPCSExtra/blob/develop/CHANGELOG.md)
- [Commits](PHPCSStandards/PHPCSExtra@1.5.0...1.5.1)

---
updated-dependencies:
- dependency-name: phpcsstandards/phpcsextra
  dependency-version: 1.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…407)

Bumps [squizlabs/php_codesniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer) from 3.13.6 to 4.0.4.
- [Release notes](https://github.com/PHPCSStandards/PHP_CodeSniffer/releases)
- [Changelog](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/4.x/CHANGELOG-3.x.md)
- [Commits](PHPCSStandards/PHP_CodeSniffer@3.13.6...4.0.4)

---
updated-dependencies:
- dependency-name: squizlabs/php_codesniffer
  dependency-version: 4.0.4
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [twig/twig](https://github.com/twigphp/Twig) from 3.27.0 to 3.28.0.
- [Release notes](https://github.com/twigphp/Twig/releases)
- [Changelog](https://github.com/twigphp/Twig/blob/3.x/CHANGELOG)
- [Commits](twigphp/Twig@v3.27.0...v3.28.0)

---
updated-dependencies:
- dependency-name: twig/twig
  dependency-version: 3.28.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.8 to 2.2.9.
- [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits)

---
updated-dependencies:
- dependency-name: phpstan/phpstan
  dependency-version: 2.2.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [nextcloud/ocp](https://github.com/nextcloud-deps/ocp) from 34.0.2 to 34.0.3.
- [Commits](nextcloud-deps/ocp@v34.0.2...v34.0.3)

---
updated-dependencies:
- dependency-name: nextcloud/ocp
  dependency-version: 34.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…260830084126

chore(sync): carry beta back into development
beta held 19 commit(s) development did not. Merged with -s ours:
development's tree is kept BYTE FOR BYTE and only the ancestry is
recorded. That is the payload -- without it the merge base never moves
and the next development -> beta promotion conflicts on the version file
exactly as before. 13 of 19 promotion PRs were CONFLICTING for this
reason.

Nothing is silently imported. What beta holds and development does not,
and which this deliberately does NOT bring over:

  lib/Settings/launchpad_register.json

Those are dead Forgejo/Codeberg CI (removed from development on
2026-08-24/25 by 'chore(ci): remove dead Forgejo/Codeberg CI
configuration'), generated Docusaurus build output, and community-health
files that never existed on development. Each can be added deliberately
if wanted; resurrecting them as a side effect of a sync is how a merge
silently undoes a decision.
…0841

chore(sync): record beta's ancestry on development
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Two dependabot bumps landed on development on 2026-08-30 without CI, and each
leaves the tree unresolvable:

- @vitest/coverage-v8 went to 4.1.11 while vitest and @vitest/ui stayed on
  ^3.2.7, so coverage-v8 peers vitest 4.1.11 against @vitest/ui's vitest 3.2.7
- stylelint went to 17.14.1, which @nextcloud/stylelint-config 2.4.0 cannot
  peer: it wants ^15.6.0

Both are reverted to the version the rest of their own ecosystem is on, rather
than bumping the ecosystem, because a vitest 3 to 4 move is a separate change
that deserves its own testing.

Verified with npm ci --dry-run from the committed lock: exit 0.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…426)

* fix(deps): hoist @vue/server-renderer so the unit suite can run

Every one of the 63 frontend test files failed, with no tests executed
at all:

  Error: Cannot find package '@vue/server-renderer' imported from
  node_modules/@vue/test-utils/dist/vue-test-utils.cjs.js

The package was installed, but NESTED at
node_modules/vue/node_modules/@vue/server-renderer rather than hoisted.
@vue/test-utils declares it as a PEER dependency ('3.x'), and a peer is
resolved upward from the importing package's own directory -- so
@vue/test-utils looked for node_modules/@vue/server-renderer, which did
not exist. Nothing was missing from the lockfile; it was in the wrong
place.

Declaring it directly pins it at the top level, which is where a peer
has to be. Version tracks vue itself (^3.5.42).

Verified locally: reproduced the failure first (1 file, 'no tests'),
then after the change the full suite runs -- 63 files passed, 682 tests
passed, 0 failed.

* fix(stylelint): extend the config the app actually declares

stylelint exited 78 -- a CONFIGURATION failure, not a lint finding:

  Could not find "stylelint-config-recommended".

stylelint.config.js extended 'stylelint-config-recommended-vue', which
was never declared in package.json. It was present only transitively,
and the config it in turn extends, stylelint-config-recommended, was not
installed at all.

launchpad already declares @nextcloud/stylelint-config ^2.4.0, and that
is what openregister, opencatalogi, dossiq and shillinq all extend. This
points the config at the package the app declares and the fleet uses,
rather than adding two more dependencies to prop up an outlier.

That made stylelint RUN, which surfaced 44 real violations the
configuration error had been hiding. 42 were auto-fixable
(rule-empty-line-before, plus a few over-indented selector continuation
lines) and were fixed with --fix; the diff outside css/ is whitespace
only.

The last two were a genuine CSS bug in css/header-override.css:

  background-color: #ffffff !important;
  background-image: none !important;
  background:       #ffffff !important;   <- discards both of the above

The shorthand alone already sets the colour and resets background-image
to none, so keeping only it preserves the computed result EXACTLY.
Keeping the longhands instead would not have, because the shorthand also
resets the other background sub-properties.

Verified: stylelint now exits 0.

* fix(stylelint): let Prettier own whitespace, and stop the fixer loop

The previous commit made stylelint run, and it and Prettier then
disagreed about the same six lines. Prettier indents a wrapped selector
list; stylelint's `indentation` rule demanded 0 tabs there. Running
either fixer broke the other check:

  npm run stylelint:fix  ->  Frontend Check (format) fails
  npm run format:fix     ->  Vue Quality (stylelint) fails

Both `indentation` and `string-quotes` are DEPRECATED in stylelint 15 --
it prints a deprecation warning for each on every run -- precisely
because formatters do this better. Turning them off resolves the conflict
in favour of the tool that owns formatting and leaves stylelint judging
what only it can judge: CSS semantics.

The three files are re-formatted to Prettier's shape.

Verified: stylelint exit 0 AND prettier exit 0 together, with the two
deprecation warnings gone.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Release: merge development into beta
Dependabot proposed stylelint 17 on its own, which cannot work: it takes
coordinated bumps, and the one that actually blocks it is easy to miss.

- @nextcloud/stylelint-config -> ^3.2.2, the version that peers stylelint ^17.9.1
- stylelint -> ^17.9.1
- stylelint-config-recommended-scss -> ^17.0.1 and -recommended-vue -> ^1.6.1
  where this app pins them
- stylelint-webpack-plugin -> ^5.1.0 where this app uses it. This is the
  blocker: 5.0.1 peers stylelint only to ^16, and 5.1.0 is the first release
  that accepts ^17.

The findings stylelint 17 then reports are fixed rather than silenced:
`word-break: break-word` is deprecated and `overflow-wrap: break-word` is what
it actually meant, the deprecated `clip` property becomes `clip-path`, and
stylelint --fix's logical-property rewrites (`text-align: left` -> `start`) are
correct for RTL.

Verified: npm ci, npm run stylelint and npm run build all exit 0.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
rubenvdlinde and others added 6 commits August 30, 2026 13:59
Dependabot bumped `@vitest/coverage-v8` to 4 on its own in several apps and
left `vitest` and `@vitest/ui` on 3. coverage-v8 4 peers vitest 4.1.11 exactly,
so a split trio cannot resolve at all: that is what took launchpad's npm ci
from green to red.

The three move together here, to 4.1.11, which is the current published version
of all of them.

Verified: npm install and the app's own test script both exit 0.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…260830125059

chore(sync): carry beta back into development
E2E went from 127 passed / 0 failed to 127 passed / 11 failed between two
runs 36 minutes apart:

  10:45  4d3819d  @nextcloud/vue 9.9.0    E2E success
  11:21  b0a322a  @nextcloud/vue 9.11.0   E2E failure

Nothing about the tests changed -- both failing specs
(conditional-visibility-editor, dashboard-sharing) were last touched on
2026-08-13. What changed is the lockfile. package.json asked for ^9.5.0,
so regenerating the lockfile for an unrelated stylelint PR silently moved
@nextcloud/vue two minor versions.

9.10.0 reworked NcSelect:

  fix(NcSelect): floating label design using NcTextField      #8570
  fix(NcSelect): truncate long selected labels ...            #8829

All 11 failures share one signature, and it is not 'element missing'. The
locator RESOLVES, Playwright logs 'attempting click action', and then
times out on 'waiting for element to be visible, enabled and stable':

  - waiting for locator('.vs__dropdown-option').nth(2)
  locator resolved to <li role="option" class="vs__dropdown-option">...
  attempting click action    -> Timeout 10000ms exceeded

An option that exists but never settles is what a re-laying-out floating
label produces.

~9.9.0 holds the last version whose NcSelect these tests were written
against. This is deliberately a PIN, not a fix: adapting the specs to the
new NcSelect is real work and should be its own change, made against the
new component on purpose rather than as a side effect of a lockfile
regeneration.

Verified locally: 9.9.0 installed, 682 unit tests pass, format exits 0.
The E2E itself only runs in CI, and that is the check this is aimed at.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Release: merge development into beta
main held 28 commit(s) beta did not. Merged with -s ours: beta's
tree is kept BYTE FOR BYTE and only the ancestry is recorded, so the
beta -> main promotion stops conflicting on files where beta is simply
newer.

Not brought over -- beta is hundreds of commits ahead of main, so these
are the OLDER copies, and several are dead Forgejo/Codeberg CI that
development deliberately removed:

  .forgejo/workflows/release-beta.yml .forgejo/workflows/release-stable.yml .github/dependabot.yml .github/workflows/branch-policy.yml .github/workflows/branch-protection.yml .github/workflows/code-quality.yml .github/workflows/documentation.yml .github/workflows/issue-triage.yml .github/workflows/openspec-sync.yml .github/workflows/release-beta.yml .github/workflows/release-stable.yml .github/workflows/sync-to-beta.yml appinfo/info.xml docs/features.json
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/launchpad @ 0147eb7

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
format
check-schema-l10n
composer ✅ 104/104
npm ✅ 526/526
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-30 17:23 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 65af8ef into main Aug 30, 2026
44 checks passed
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.

3 participants