feat(vue3): migrate larpingapp to Vue 3 (@conduction/nextcloud-vue@2.1.0-vue3.13) - #239
Conversation
- deps: vue 3.5, @nextcloud/vue 9, vue-router 4, @nextcloud/dialogs 7, @conduction/nextcloud-vue pinned exactly to 2.1.0-vue3.13 - lint: adopt the shared conductionVue3Fixes preset (spread last) - bootstrap: createApp/app.use, vue-router 4 catch-all, reactive() - webpack: vue-router$ singleton alias, entry-FILE aliases for the exports-map-only @nextcloud/vue and @nextcloud/dialogs - remove dead Vue-2 files (jest.config.js, MainMenu.vue, UserSettings.vue)
playwright.config.ts, global-setup.ts, workflows/fixtures.ts and detail-forms-admin.spec.ts each computed their own `?? localhost:8080` default. :8080 is the SHARED dev container, so the fixture WRITE paths seeded registers/schemas/objects into other people's environment, and a spec could create a fixture on one instance and open it on another. tests/e2e/_base-url.ts is now the single resolver: PLAYWRIGHT_BASE_URL > NEXTCLOUD_URL > NC_BASE_URL, and it THROWS when none is set. Also fixes the remaining lint warnings (JSDoc @PARAM types/descriptions).
…ed workflow refs webpack: @nextcloud/webpack-vue-config hardcodes output.publicPath to /apps/<app>/js/. An app deployed into Nextcloud's second apps path is served from /custom_apps/, so every LAZY chunk 404s, comes back as the text/html error page, is refused on MIME grounds and kills the page with a ChunkLoadError. The entry bundle is fine (NC writes that tag itself), so nothing in the build reports a problem. publicPath 'auto' derives it from the URL the entry script actually loaded from. ci: all 8 reusable-workflow calls pointed at Conduction/.github, which Actions cannot resolve. Every Code Quality run since 2026-06-01 produced ZERO jobs. The shared workflows live in ConductionNL/.github.
…lint failure) Four single-line rule blocks tripped `rule-empty-line-before`. The failure is pre-existing and was invisible because the Code Quality workflow had produced zero jobs since 2026-06-01.
…ure) The shared `features-extract` job that regenerates this file is skipped on pull_request, and on a push to development its bot commit is rejected by branch protection (ConductionNL/.github#61), so the file has been drifting from openspec/specs/ with nothing able to correct it. Regenerated with ConductionNL/.github scripts/extract-features.py; `--check` is now clean. Pre-existing: the same failure reproduces on an untouched development in the baseline probe (#238).
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 94/94 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Coverage: 0% (0/17 statements)
Quality workflow — 2026-07-31 21:30 UTC
Download the full PDF report from the workflow artifacts.
detail-forms-admin.spec.ts hardcoded register 8 and schemas 18-25 with a comment saying '8 on the dev instance'. On any other instance LarpingApp's register imports with a different id (15 on a fresh NC 34 install, with a different schema assignment), so every seed POST 404s, seedObject() swallowed it, the ids became the literal 'seed-missing', and eight specs failed 60s later as TIMEOUTS — which reads like a rendering regression. Now resolved at runtime from /apps/larpingapp/api/settings, the same source workflows/fixtures.ts and the SPA itself use, honouring the per-type <type>_register over the shared one. Explicit LARPING_* env vars still win. seedObject() now reports the status and body on failure instead of returning null silently.
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 94/94 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Coverage: 0% (0/17 statements)
Quality workflow — 2026-07-31 21:35 UTC
Download the full PDF report from the workflow artifacts.
nc-vue declares `sideEffects: ["**/*.css"]` (ADR-061), so webpack may legally drop registerDashboardWidgets.js's bare side-effect imports. The manifest's `stat` KPI tiles and `object-table` lists are registered ONLY that way, so getWidgetTypeEntry() missed and CnDashboardPage rendered its 'Widget not available' placeholder for 5 of the 7 dashboard widgets. The one widget that did render, `chart`, is registered by an INLINE registerDashboardWidget() call that tree-shaking cannot reach — which is what identified the mechanism. The library ships this explicit no-op for exactly this purpose.
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 94/94 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Coverage: 0% (0/17 statements)
Quality workflow — 2026-07-31 21:41 UTC
Download the full PDF report from the workflow artifacts.
The USE_LOCAL_LIB alias is opt-OUT and silently overrides the exactly pinned @conduction/nextcloud-vue dependency. The shared apps-extra/nextcloud-vue checkout sits on wip/cnindexpage-export-action at 1.0.0-beta.184 — the Vue 2 line — so any build run from apps-extra/larpingapp would have compiled Vue 2 library sources into this Vue 3 app, successfully, with the first symptom appearing at runtime as a migration bug. Now the alias is used only when the local checkout's major matches the pinned dependency's; a mismatch throws with the versions named. USE_LOCAL_LIB=false still disables it outright.
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 94/94 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Coverage: 0% (0/17 statements)
Quality workflow — 2026-07-31 21:43 UTC
Download the full PDF report from the workflow artifacts.
…utton
The app's first load opens a six-step onboarding tour ('Welcome to
LARPing') whose controls are labelled 'Close tour' and 'Skip'. All five
copies of dismissSupportDialog matched only button[aria-label="Close"],
so the tour stayed open across the viewport.
Measured on the isolated instance: two live [role="dialog"] nodes, two
modal masks, and elementFromPoint(innerWidth/2, innerHeight/2) returning
the tour's <video>. The page underneath renders perfectly, so
toBeVisible() keeps passing while every locator.click() hangs on
actionability until the test times out — which reads like a rendering
regression and is really an undismissed overlay.
One implementation now lives in _nav.ts and the five duplicates import
it; it closes any visible dialog by the labels actually used, repeating
until none remains (the tour and its step controls are two dialogs).
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 94/94 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Coverage: 0% (0/17 statements)
Quality workflow — 2026-08-01 06:39 UTC
Download the full PDF report from the workflow artifacts.
Every other mention of networkidle in this suite is a comment saying
never to use it (ADR-074 rule 4 — Nextcloud's notification poll keeps the
network permanently busy). One live call survived, in the gotoDetail
helper backing every character-detail spec.
The trailing .catch(() => {}) made it look safe. It is not: the call
takes no timeout, so it inherits the unbounded navigation timeout and
never settles; the TEST times out at 60s first and the catch never runs.
The failures surface as 'Test timeout of 60000ms exceeded', which reads
like a slow or broken page instead of a wait that can never succeed.
Replaced with the app-content visibility wait used everywhere else.
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 94/94 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Coverage: 0% (0/17 statements)
Quality workflow — 2026-08-01 06:58 UTC
Download the full PDF report from the workflow artifacts.
Re-verified on a clean isolated NC 34 instance, as the note itself asked. The cause is a MISSING player SCHEMA. larpingapp_register.json declares ten schemas including 'player'; after import the register holds nine — item/event/attendance arrive prefixed (larping_*) and player is dropped entirely, leaving player_register and player_schema empty in app config. The character schema REQUIRES ocName, typed format:uuid with $ref:player. With no player schema there is no player UUID, so the OR API rejects every character create. seedObject() swallowed it, ids became the string 'seed-missing', and every character-detail spec failed 60s later as a timeout. Register-import side, not the test layer.
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 94/94 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Coverage: 0% (0/17 statements)
Quality workflow — 2026-08-01 07:13 UTC
Download the full PDF report from the workflow artifacts.
Migrates larpingapp from Vue 2 to Vue 3 against
@conduction/nextcloud-vue@2.1.0-vue3.13(pinned exactly — a caret floats a prerelease when thevue3dist-tag moves).Wave 1b of the 13-app fleet migration.
Dependencies
vue^2.7.14^3.5.13@nextcloud/vue^8.16.0^9.5.0vue-router^3.6.5^4.5.0@nextcloud/dialogs^3.2.0^7.4.1@conduction/nextcloud-vue^1.0.0-beta.2132.1.0-vue3.13(exact)@nextcloud/webpack-vue-config^6.0.1^7.0.2vue-loader^15^17.4.2Added because nc-vue declares them as peers and an isolated clone cannot resolve them from a hoisted
node_modules:@vueuse/core,axe-core,dexie,dompurify,marked,@nextcloud/capabilities,gridstack(+ its stylesheet — without it every dashboard item renders 0 px wide with no error). Added because@nextcloud/webpack-vue-config@7require()s them without declaring them:terser-webpack-plugin,path-browserify.Dropped:
vue-template-compiler,vue-apexcharts(unused; nc-vue bundlesvue3-apexcharts), and thepostinstall: vue-demi-switch 2.7 vuehook.Lint
Adopts the shared
conductionVue3Fixespreset from nc-vue, spread last. No local copies ofvue/no-v-model-argument/vue/no-v-for-template-key— the preset disables both itself.Verified by severity, not by counting rule names (
--print-config | grep -ccounts rules set tooffand will happily report 21 when none are armed):Positive-controlled: a seeded
.vuefile withbeforeDestroy+filters:produces both errors, and a seeded.jsfile with an unused var is reported — so a clean run means the files were actually opened.srclint: 0 errors, 0 warnings (was 0 errors / 85 warnings).The 71
@spec"Invalid JSDoc tag name" warnings neededdefinedTagsas rule options;settings.jsdoc.definedTagsis ignored once an extended preset has configured the rule.Code
Lint does not cover bootstrap idioms, so these were found by hand:
new Vue({...}).$mount('#content')→createApp({...}).mount('#content')(main.js,settings.js)Vue.mixin/Vue.use→app.mixin/app.use;PiniaVuePluginremovedVue.observable({...})→reactive({...})(App.vuesidebar channel)new VueRouter({ mode: 'hash' })→createRouter({ history: createWebHashHistory() })path: '*'→path: '/:pathMatch(.*)*'— removed in vue-router 4; an unknown hash would have rendered the shell with an empty<main>and no errorFound by the newly-armed rules or by reading the v9 API:
beforeDestroy→beforeUnmountinSkillTree.vue. Vue 3 never calls the old name and never warns, so this hook would have leaked two live collection subscriptions and a$watchhandle for the lifetime of the page.@nextcloud/vue/dist/Components/*.js→@nextcloud/vue/components/*(v9 ships anexportsmap; the old paths throwERR_PACKAGE_PATH_NOT_EXPORTED).NcButton type="primary|secondary|tertiary"→variant="…". v9 repurposedtypeas the native button type; the Vue-2 spelling renders<button type="secondary">with no warning and no lint error. (NcNoteCardstill usestype— left alone.)NcSelect @input/@change→@update:modelValue. Neither is in@nextcloud/vue-select@4'semitslist, so both listeners were silently dead: picking a character in the skill tree would never have fetched the requirement report (tree stays uncoloured), and switching a type's storage to "Internal" would have left a stale register + schema attached. Verified against the compiler thatv-modeland an explicit@update:modelValuemerge into an array with the v-model assignment first, so the handlers see the new value.App.vuedual-instance hazard.useTenantContextwas pulled in withrequire('@conduction/nextcloud-vue')inside a try/catch while the rest of the file used a static ESM import — two module instances of the library. The injection key is a module-localSymbol, andCnAppRootprovides from the ESM copy, so the CJS-sideuseTenantContext()would have looked up a different key, silently returned the no-op fallback, and the tenant watcher would never have fired. Now a static import; the "absence must not crash" contract is still met by the composable's own fallback.Deleted as dead Vue-2 leftovers (unreferenced by any import, manifest or test):
jest.config.js(a@vue/vue2-jesttransform; jest is not a dependency and appears in no workflow),src/navigation/MainMenu.vue(superseded by the manifest-drivenCnAppNav),src/views/settings/UserSettings.vue(superseded byApp.vue's#user-settingsslot).webpack
vue-router$absolute-file alias — mandatory, not an optimisation.@nextcloud/vue@9hard-depends onvue-router ^5.1.0, so npm installs bothvue-router@4.6.4(root) andvue-router@5.2.0(nested). Without the alias,main.jsgets the 4.x singleton while every@nextcloud/vuecomponent callinguseRoute()/useRouter()resolves the 5.x copy — a different injection key, so those components see no router at all.@nextcloud/vueand@nextcloud/dialogsaliases now point at the entry FILE, not the package directory. Both declare nomainand nomodule, only anexportsmap; a directory alias bypasses that map. This produced 233Can't resolve '@nextcloud/vue'errors on the first Vue 3 build.output.publicPath: 'auto'.@nextcloud/webpack-vue-confighardcodes/apps/<app>/js/. An app installed into Nextcloud's second apps path is served from/custom_apps/, so every lazy chunk 404s, comes back as thetext/htmlerror page, is refused on MIME grounds, and kills the page with aChunkLoadError. The entry bundle is unaffected (Nextcloud writes that tag itself), so the build reports nothing. Reproduced in a browser on the isolated instance.No
optimization.sideEffects = falseworkaround was present.e2e safety
playwright.config.ts,global-setup.ts,workflows/fixtures.tsanddetail-forms-admin.spec.tseach computed their own?? 'http://localhost:8080'default.:8080is the shared dev container — its bind mounts reach real host checkouts, and this suite's fixture WRITE paths were creating and deleting OpenRegister objects there.tests/e2e/_base-url.tsis now the single resolver (PLAYWRIGHT_BASE_URL>NEXTCLOUD_URL>NC_BASE_URL) and it throws when none is set.Verified against a disposable instance on
:8094whose/var/www/htmlis a namedvolume(the shared container reports ~40bindmounts).Pre-existing issues fixed
Conduction/.github, which Actions cannot resolve, so Code Quality produced zero jobs and failed instantly on every push and PR since e2faa09 (2026-06-01, "org rename" — the rename went the wrong way). No ESLint, PHPCS, PHPMD, PHPStan, Psalm, license/security scan or PHPUnit ran in that window. The shared workflows live inConductionNL/.github; ci: restore the Code Quality gate (baseline probe — do not merge yet) #238 is a probe on an otherwise untoucheddevelopmentproving the restored gate runs 24 jobs.docs/features.jsonregenerated — the shared job that maintains it is skipped on PRs and its bot push todevelopmentis rejected by branch protection (Allow CI bot to push auto-generated files (.coverage-baseline, SBOM) to ruleset-protected branches .github#61).rule-empty-line-beforestylelint errors inSkillTree.vue.Baseline comparison
Measured against #238 — the same restored workflow on an otherwise untouched
development:Known, unchanged, reported not fixed
code-quality.ymlnever setsenable-playwright, soE2E Tests (Playwright)isskipping— including on the restored baseline. 17 spec files have never gated a PR. Not enabled here because the CI-side seed is unvalidated and flipping it on would make this PR red for reasons unrelated to Vue 3.npm run test:l10nreports 280 missing translations — byte-identical count ondevelopmentand on this branch. Not gated in CI (frontend-checksis unset).pipelinq,procest,opencatalogi,docudesk,softwarecatalog,decidesk,openbuild.beta.220) closed as superseded — merging it would have moved the dependency backwards onto the Vue 2 line. PR chore(deps): bump the npm_and_yarn group across 2 directories with 21 updates #235 (dependabot security group, green) merged first. PR Release: merge development into beta #90 is thedevelopment → betarelease PR and is untouched.e2e: what the 58 failures actually were
The first full run against the isolated instance was 90 passed / 58 failed / 24 skipped of 172. None of it was a Vue 3 rendering problem. Root-caused rather than retried:
1. Nextcloud's own first-run wizard (the dominant cause). On a freshly provisioned instance the admin account has never dismissed it, so it opens a modal at z-index 10005 — above larpingapp's own onboarding tour (10000) and above the entire app. Measured: two live
[role="dialog"]nodes, two modal masks, anddocument.elementFromPoint(innerWidth/2, innerHeight/2)returning the tour's<video>.The page underneath renders perfectly, so
toBeVisible()keeps passing and onlyclick()dies — 60 s actionability timeouts that read exactly like a rendering regression. Even"Close tour"was unreachable, because the wizard covered it.After
occ app:disable firstrunwizard: one click dismissed the app tour, and the next click landed in 35 ms.This is why the suite passed on the shared dev container and not here — the shared instance's admin dismissed that wizard months ago. Fixed in the shared
spin-up-e2e-instance.shso no other app in the fleet loses a day to it.2. Five copies of
dismissSupportDialogthat could not dismiss the tour. All matched onlybutton[aria-label="Close"]; the tour's controls are"Close tour"and"Skip". One implementation now lives in_nav.tsand the five duplicates import it.3. Stale fixture ids.
detail-forms-admin.spec.tshardcoded register8/ schemas18-25with a comment reading "8 on the dev instance". On a fresh install LarpingApp's register imports as15with a different schema assignment, so every seed 404'd,seedObject()swallowed it, ids became the literal string'seed-missing', and the specs failed 60 s later as timeouts. Now resolved at runtime from the app's settings API;seedObject()reports status and body on failure.4. Dashboard widgets rendered "Widget not available". 5 of 7. Cause: nc-vue declares
sideEffects: ["**/*.css"], so webpack legally droppedregisterDashboardWidgets.js's bare side-effect imports — andstat/object-tableare registered only that way. The one widget that did render,chart, is registered by an inline call tree-shaking cannot reach, which is what identified the mechanism. Fixed by calling the library'sregisterBuiltinDashboardWidgets()at bootstrap. Verified live: the KPI tiles now readCharacters 0 | Events 48 | Items 48.Also verified live, since the playbook calls it the silent case: gridstack items measure 231 × 160 and 462 × 320 px via
getBoundingClientRect(), not 0 px wide.Known pre-existing gap, not fixed here
The
playerschema does not exist on a fresh install.lib/Settings/larpingapp_register.jsondeclares ten schemas includingplayer, but after import the register contains nine —ability, skill, larping_item, condition, effect, larping_event, setting, xpAward, character, larping_attendance— andplayer_register/player_schemaare empty in the app config. Positive-controlled: acharacter-slug search finds schema 18; aplayersearch finds nothing in any register. Note thatitem,eventandattendancewere imported prefixed (larping_*) whileplayerwas dropped, which points at slug-collision handling in the register import. Players pages therefore have no storage on any new deployment. Out of scope for a Vue 3 migration, but it should not stay unreported.