feat(vue3): migrate petstore to Vue 3 + @conduction/nextcloud-vue 2.1.0-vue3.13 - #4
Merged
Conversation
….0-vue3.13 Dependencies - vue 2.7 -> 3.5, vue-router 3 -> 4, @nextcloud/vue 8 -> 9, pinia 2 -> 3 - @nextcloud/dialogs 3 -> 7 (v6 is a trap: higher major, still peers vue@^2.7) - @conduction/nextcloud-vue pinned EXACTLY to 2.1.0-vue3.13 (a caret floats a prerelease when the dist-tag moves) - vue-loader 15 -> 17, drop vue-template-compiler - @nextcloud/webpack-vue-config 6 -> 7, plus its undeclared require()s terser-webpack-plugin and path-browserify - declare nc-vue's undeclared peers: @vueuse/core, axe-core, dexie, dompurify, marked, @nextcloud/capabilities, gridstack - @vitejs/plugin-vue2 -> @vitejs/plugin-vue, @vue/test-utils 1 -> 2 Build - vue-router$ absolute-FILE alias. @nextcloud/vue@9 hard-depends on vue-router@^5.1.0 so a second copy is installed under its own node_modules; two router instances make navigation from a library component a silent no-op. - vue-router added to the shared-vendor cacheGroup Bootstrap (lint cannot see any of these) - new Vue().$mount -> createApp().mount, Vue.mixin/Vue.use -> app.mixin/app.use - PiniaVuePlugin dropped (does not exist for Vue 3; the store IS the plugin) - Vue.extend + propsData -> createApp(Component, props) in the dashboard widget - Vue.observable -> reactive - vue-router `path: '*'` -> `path: '/:pathMatch(.*)*'` (removed in v4, silent) - mount host renamed #content -> #petstore-app with `display: contents`: Vue 2 $mount REPLACED the placeholder, Vue 3 mount keeps it, so reusing #content would have wrapped the app in a live Nextcloud-core #content - gridstack CSS imported (peer dep; without it dashboard items are 0px wide) Lint - adopt the shared conductionVue3Fixes preset from nc-vue, spread LAST. Verified by SEVERITY, not by name: 21 vue/no-deprecated-* rules at error, ecmaVersion latest, the two inverted Vue-2 rules off. Positive-controlled by injecting a beforeDestroy + filters and confirming they error. - fix the 2 pre-existing vue/order-in-components warnings -> 0 errors 0 warnings Fixes found on the way - settings.js mounted INSIDE the loadTranslations callback, which never fires when the l10n JSON 404s (installs that only allowlist JS/CSS) -> blank admin panel with no error. Mount is now unconditional and once-guarded. - exampleWidget.js relied on the bare window globals t/n; now imported. e2e targeting - tests/e2e/_base-url.ts is now the single source of truth. playwright.config, global-setup and three request contexts each computed `NEXTCLOUD_URL || 'http://localhost:8080'` independently — the shared dev container — and PLAYWRIGHT_BASE_URL was read by nothing. The pet-CRUD suite WRITES, so a bare `npx playwright test` mutated the shared instance. There is now no default at all: a missing target throws.
None of these are Vue-3 regressions — all four fail identically on `main`
against a clean isolated instance. They were masked by running against a
long-lived shared dev container that happened to carry seed data and an
older Nextcloud.
1. pet.category is a TYPED RELATION (format: uuid, $ref: category — ADR-062
rule 7), but every fixture posted the literal string 'Dogs'. OpenRegister
answers 400 "should match format 'uuid'", so the whole data-layer CRUD
suite failed. Fixtures now create a real category object and pass its uuid;
the read-back assertion checks the uuid persisted, not a display string.
2. The UI create-form spec typed 'Dogs' into `getByRole('textbox', {name:
/category/})`. Because category is a relation, CnFormDialog renders a
relation COMBOBOX there — the textbox has never existed. Verified live: the
dialog offers Category */Status * comboboxes and a Name * textbox.
3. Two specs asserted `#content-vue table` is visible on a register with no
pets. The object-table renders an EMPTY-STATE, not an empty <table>, so the
assertion is data-dependent. Each now seeds its own pet and asserts that
row, then cleans up.
4. 'app menu marks PetStore as the active app' located
`header a[href*="/apps/petstore"]`. Nextcloud 34 replaced the flat app-menu
anchor list with a waffle popover plus a single current-app button, so no
such anchor exists. Now asserts the current-app button reports
aria-label="…currently in PetStore" — which actually tests "active app",
which the anchor never did.
Also: dismissOverlays only pressed Escape at the first-run wizard, which does
not close it on NC 34. Its opaque modal mask then intercepted every nav click
for the full 30 s budget and surfaced as a routing failure. It now clicks the
wizard's own Close control first.
…ion option publicPath (REAL Vue 3 regression, would have shipped broken) @nextcloud/webpack-vue-config hardcodes output.publicPath='/apps/<app>/js/'. Conduction apps install into custom_apps/, served from /custom_apps/<app>/js/, and /apps/petstore/js/<chunk>.js does not 404 there — Nextcloud's router answers 200 with text/html, so the browser refuses it for its MIME type and webpack throws ChunkLoadError. The Vue 2 build never noticed because it produced no async chunks; the Vue 3 dependency set splits @nextcloud/dialogs@7 (FilePicker/ConflictPicker), @nextcloud/files, @nextcloud/paths and @mdi/js into 40+ of them. src/publicPath.js now derives the path from Nextcloud's own generateFilePath() and is imported first in all three entry points. e2e: first-run wizard Nextcloud's onboarding wizard is an opaque modal mask that swallows every pointer event. It appeared inside whichever spec ran first and produced 30 s 'subtree intercepts pointer events' timeouts in unrelated specs. Dismissing it from dismissOverlays is a race — it mounts asynchronously. globalSetup now calls the wizard's own DELETE /apps/firstrunwizard/wizard opt-out once, before any spec runs. Verified: occ user:setting admin firstrunwizard now reports show=34.0.0 after a run. e2e: relation-picker option name The category option label wrapped inside the dropdown, and innerText renders that line break as a SPACE — 'e2e-ms9fv4nl-vr569-dogs' was read as 'e2e-ms9fv4nl- vr569-dogs', so the exact-name option lookup matched nothing and timed out looking like a missing option. Fixtures matched through the UI now use makeShortLabel() (8 chars, no hyphens, does not wrap). Also: category cleanup moved inside try/finally, so a run that fails before submitting the form no longer leaks a category into the register.
- code-quality.yml declared app-name: app-template, so the shared workflow checked petstore out at server/apps/app-template and resolved every artifact path from a directory that is not this app's id. - src/store/store.js created its object store over schema 'example', which does not exist in the petstore register (pet / category / order). Nothing imports the module, so the first consumer would have been the one to find out. - AdminRoot.vue announced itself as 'App Template' in the Nextcloud admin settings panel.
`frontend-checks` defaults to [], so `npm run test:unit` (15 tests) and `npm run check:specs` (json-strict + manifest-v2 + register + registry) had never executed on a PR — only eslint and stylelint ran. Both pass locally on the Vue 3 tree.
Contributor
Quality Report — ConductionNL/petstore @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ❌ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ❌ | ✅ 100/100 | |||
| npm | ✅ | ✅ 778/778 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-07-31 21:33 UTC
Download the full PDF report from the workflow artifacts.
Contributor
Quality Report — ConductionNL/petstore @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ❌ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| test-unit | ✅ | ||||
| check-specs | ✅ | ||||
| composer | ❌ | ✅ 100/100 | |||
| npm | ✅ | ✅ 778/778 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-07-31 21:35 UTC
Download the full PDF report from the workflow artifacts.
rubenvdlinde
added a commit
that referenced
this pull request
Aug 14, 2026
CI caught what I could not: petstore has no vendor/bin/phpunit in this environment, so the suite never ran locally and a missed call site sat invisible until five PHPUnit cells went red with "Argument #4 ($throttler) not passed". PortalActionControllerTest builds the controller TWICE -- once in setUp() and once inline at line 442 -- and I had only updated the first. This is the fourth time in this sweep that a second construction site hid behind a replace_all that matched only the first spelling: BrpController (line 915), Segment, ContactSync and Messaging all did the same thing. The pattern is now well enough established to check for deliberately: count `new <Controller>(` occurrences BEFORE editing, not after CI complains.
rubenvdlinde
added a commit
that referenced
this pull request
Aug 14, 2026
* fix(security): throttle the portal action receiver petstore's one public endpoint, and the LAST unthrottled #[PublicPage] endpoint in the fleet. 161 of 161 are now covered. It was missed because every sweep enumerated "the 18 core apps" and petstore is the reference app, not a core app. It is also the file other teams COPY when they build an ADR-046 A6 receiver -- docudesk's portal signing receiver cites it by name -- so an unthrottled reference propagates by being followed. That makes it the worst one to have left, not the least important. Both halves, because the assertion is the only credential: #[BruteForceProtection] on the endpoint -- what makes BruteForceMiddleware apply the delay; registerRejectedAssertion() on the two fail-closed exits -- the failed verify (401) and the ownership refusal (403). The 403 is counted too, and the comment says why: the uniform answer stops this being an existence oracle over pet UUIDs, but a uniform answer only hides WHICH failure happened. It says nothing about how fast a caller may keep asking. Two controls, two problems. 20/60 -- the tightest ceiling in the sweep, matching the other citizen-facing write paths. NOT verified locally: petstore has no vendor/bin/phpunit in this environment. The test needed the IThrottler mock and is updated; CI is the first run. * style: satisfy petstore's phpcs on the throttle addition Two errors, both mine, both from writing this the way the other twelve apps in the sweep write it: Doc comment long description must start with a capital -- "petstore" began a sentence. Concat operator must not be surrounded by spaces -- `'msg: ' . $e->...` passes everywhere else in the fleet and not here. petstore runs a stricter phpcs than the apps that copy from it, which is the right way round for a reference implementation but does mean a pattern lifted from elsewhere lands with violations. * test: the SECOND construction site needed the throttler mock too CI caught what I could not: petstore has no vendor/bin/phpunit in this environment, so the suite never ran locally and a missed call site sat invisible until five PHPUnit cells went red with "Argument #4 ($throttler) not passed". PortalActionControllerTest builds the controller TWICE -- once in setUp() and once inline at line 442 -- and I had only updated the first. This is the fourth time in this sweep that a second construction site hid behind a replace_all that matched only the first spelling: BrpController (line 915), Segment, ContactSync and Messaging all did the same thing. The pattern is now well enough established to check for deliberately: count `new <Controller>(` occurrences BEFORE editing, not after CI complains.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wave 1b of the fleet Vue 2 → Vue 3 migration. petstore: 12
.vuefiles, ~1.75k LOC.Target:
@conduction/nextcloud-vue@2.1.0-vue3.13, pinned exactly (a caret floats a prerelease when thevue3dist-tag moves), installed with--min-release-age=0, verified withnpm ls(node -p "require('<pkg>/package.json')"throwsERR_PACKAGE_PATH_NOT_EXPORTEDon exports-map packages and reports a false MISSING).Results
main, same isolated instance)chromiumnpm run lintnpm run buildasset size limit, pre-existing)npm run check:specsnpm run stylelintAll 9 pre-existing e2e failures were reproduced on unmodified
mainagainst a clean isolated instance before anything was changed.Final verification run (build → deploy → e2e → vitest, all inside one heavy-lock hold so nothing tested a stale bundle): 20/20 Playwright, 15/15 Vitest, webpack clean, host load average 11.9 at start and 9.4 at end — comfortably below the level at which this box manufactures timeout-shaped failures. Zero fixture rows left behind in the
petstoreregister afterwards (pet/category/orderalltotal=0).Verified on a disposable, volume-backed instance (
petstore-vue3-e2e, port 8093, NC 34.0.0 + OpenRegister) —docker inspect --format '{{range .Mounts}}{{.Type}}{{end}}'printsvolume. The shared dev container on :8080 was never touched.Dependencies
vue2.7→3.5 ·vue-router3→4 ·@nextcloud/vue8→9 ·pinia2→3 ·@nextcloud/dialogs3→7 (v6 is a trap: higher major, stillpeer vue@^2.7.16) ·vue-loader15→17, dropvue-template-compiler·@nextcloud/webpack-vue-config6→7 plus its undeclaredrequire()sterser-webpack-plugin+path-browserify·@vitejs/plugin-vue2→@vitejs/plugin-vue·@vue/test-utils1→2.nc-vue peers that no consumer declares and that fail hard in an isolated clone are now declared:
@vueuse/core,axe-core,dexie,dompurify,marked,@nextcloud/capabilities,gridstack(+ its CSS — without the stylesheet every dashboard item renders 0 px wide with no error).Two build traps
@nextcloud/vue@9and@nextcloud/dialogs@7are exports-map-ONLY — nomain, nomodule. The existing directory aliases ('@nextcloud/vue$': resolve('node_modules/@nextcloud/vue')) therefore resolve to nothing: webpack applies an exports map to package requests, never to a request it has already rewritten to an absolute path. First Vue 3 build: 234 ×Can't resolve '@nextcloud/vue'. Both are now absolute file aliases. The Vue-2 spelling only worked because@nextcloud/vue@8still shipped amain.vue-routerdual copy —@nextcloud/vue@9hard-depends onvue-router@^5.1.0, so npm installsvue-router@5.2.0undernode_modules/@nextcloud/vue/node_modules/alongside the app's4.6.4. Confirmed present. Avue-router$absolute-file alias is mandatory; two router instances make navigation from a library component a silent no-op.Bootstrap (lint catches none of this)
new Vue().$mount→createApp().mount·Vue.mixin/Vue.use→app.mixin/app.use·PiniaVuePlugindropped (no Vue 3 equivalent; the store is the plugin) ·Vue.extend+propsData→createApp(Component, props)in the dashboard widget ·Vue.observable→reactive· vue-routerpath: '*'→path: '/:pathMatch(.*)*'(removed in v4, silent).Mount host
#content→#petstore-app. Nextcloud'slayout.user.phpalready wraps the app template's output in its own<div id="content">, so the old selector was a duplicate id — it matched Nextcloud's outer wrapper first. Vue 2's$mount()replaced whatever it matched and got away with it; Vue 3'smount()renders inside the match.publicPath— a real Vue 3 regression that would have shipped broken@nextcloud/webpack-vue-confighardcodesoutput.publicPath = '/apps/<app>/js/'. Conduction apps install intocustom_apps/, served from/custom_apps/<app>/js/. Requesting/apps/petstore/js/<chunk>.jsthere does not 404 — Nextcloud's router answers 200 withtext/html, so the browser refuses it (MIME type ('text/html') is not executable) and webpack throwsChunkLoadError.The Vue 2 build never noticed because it emitted no async chunks. The Vue 3 dependency set splits
@nextcloud/dialogs@7(FilePicker / ConflictPicker),@nextcloud/files,@nextcloud/pathsand@mdi/jsinto 40+.src/publicPath.jsnow derives the path from Nextcloud's owngenerateFilePath()and is imported first in all three entry points.Lint
Adopts the shared
conductionVue3Fixespreset from nc-vue, spread last. No local copies of the two inverted Vue-2 rules — the preset disables them itself.Verified by severity, not by name (
--print-config | grep -ccounts rules set toofftoo): 21vue/no-deprecated-*rules at severity 2,ecmaVersion: lateston bothlanguageOptionsandparserOptions,vue/no-v-model-argumentandvue/no-v-for-template-keyat 0,vue/v-on-event-hyphenation=[2,'always',{ignore:['update:modelValue']}].Positive-controlled: 23 files linted (12
.vue), 0 fatal parse errors; injecting abeforeDestroy+filters:into a component produced errors and removing them restored the clean run — so "0 errors" is a real zero, not a file that was never opened.Pre-existing e2e defects fixed (all reproduce on
main)pet.categoryis a typed relation (format: uuid,$ref: category— ADR-062 rule 7), but every fixture posted the literal string'Dogs'. OpenRegister answers400 … should match format 'uuid', so the entire data-layer CRUD suite failed. Fixtures now create a real category object and pass its uuid.'Dogs'intogetByRole('textbox', {name:/category/}). Because category is a relation,CnFormDialogrenders a relation combobox there — that textbox has never existed.#content-vue tableis visible on an unseeded register. The object-table renders an empty-state, not an empty<table>. Each spec now seeds its own pet and asserts that row.header a[href*="/apps/petstore"]— NC 34 replaced the flat app-menu anchor list with a waffle popover + a single current-app button, so no such anchor exists. Now assertsaria-label="…currently in PetStore", which actually tests "active app" — the anchor never did.subtree intercepts pointer eventstimeouts in unrelated specs.globalSetupnow calls the wizard's ownDELETE /apps/firstrunwizard/wizardopt-out once. Verified:occ user:setting admin firstrunwizardreportsshow=34.0.0after a run.innerTextrenders that line break as a space —e2e-ms9fv4nl-vr569-dogsread back ase2e-ms9fv4nl- vr569-dogs, so exact-name option lookup matched nothing and timed out looking like a missing option. UI-matched fixtures now use an 8-char label that cannot wrap.e2e was targeting the SHARED dev container
playwright.config.ts,tests/e2e/global-setup.tsand three request contexts each computedprocess.env.NEXTCLOUD_URL || 'http://localhost:8080'independently, andPLAYWRIGHT_BASE_URLwas read by nothing. The pet-CRUD suite writes (creates, updates and deletes real OpenRegister objects), so a barenpx playwright testmutated the shared instance while appearing to test this branch.tests/e2e/_base-url.tsis now the single source of truth, resolvingPLAYWRIGHT_BASE_URL → NEXTCLOUD_URL → NC_BASE_URL → BASE_URL(the last is what the sharedquality.ymlPlaywright job exports). There is deliberately no default: a missing target throws.Other scaffold leftovers fixed
code-quality.ymldeclaredapp-name: app-template, so the shared workflow checked petstore out atserver/apps/app-templateand resolved every artifact path from a directory that is not this app's id.src/store/store.jscreated its object store over schemaexample, which does not exist in the petstore register (pet/category/order). Nothing imports the module, so the first consumer would have been the one to find out.src/settings.jsmounted inside theloadTranslationscallback, which never fires when the l10n JSON 404s (installs that only allowlist JS/CSS through Apache —main.jsdocuments the same hazard). Blank admin panel, no error. Mount is now unconditional and once-guarded.src/exampleWidget.jsrelied on the barewindowglobalst/n; now imported.AdminRoot.vueannounced itself as "App Template" in the Nextcloud admin settings panel.CI — please read before judging red checks
Every red check on this PR is red on PR #3 as well — a single-file PHP change that predates this branch. Same workflow, both runs completed:
check-branch,Features Check,PHP Quality (phpmd),Security (composer),Quality Report(the aggregator, which fails because the four above do). Zero regressions.check / check-branchfails on every PR in this repo, including the two that were already open. The shared gate requiresbeta → mainorhotfix/* → main; this repo has neither abetanor adevelopmentbranch. Pre-existing and structural.quality / Security (composer)andquality / PHP Quality (phpmd)fail onmainitself (run30571391476, the last Code Quality run onmain). Not regressions.enable-playwrightis not set incode-quality.yml, so petstore's e2e suite has never run on a PR. Its 20 tests were run locally for this PR.phpunitandnewmanare gated onneeds.php-quality.result != 'failure' && needs.security.result != 'failure'. SincephpmdandSecurity (composer)fail, neither has ever executed on a pull request either.quality / Features Checkfails on every PR withdocs/features.json is out of date — run scripts/extract-features.py to regenerate. This repo has neitherscripts/extract-features.pynordocs/features.json. Identical failure on PR perf(events): declare OrderCustomerListener's register/schema interest #3, which predates this branch.frontend-checkswas[], sonpm run test:unit(the 15-test Vitest suite) andnpm run check:specshad never run on a PR either — only eslint and stylelint did. This PR turns both on; they pass locally on the Vue 3 tree.Confirmed from the CI log rather than the badge: the
Vue Quality (eslint)job rannpm ci(added 1471 packages) and> eslint srcand exited 0 — so the new lockfile installs cleanly under the pinned npm major and lints green in a fresh environment, not just on this box.Enabling Playwright in CI needs
additional-apps(OpenRegister) plus aplaywright-seed-command; deliberately left out of this PR rather than half-wired.PR triage
chore(deps): bump nextcloud-vue to beta.220— closed as superseded. It bumps to1.0.0-beta.220, the Vue 2 line; merging it would have moved the dependency backwards.perf(events): declare OrderCustomerListener's register/schema interest— left open. Single PHP file, zero overlap with this migration, and it cannot merge anyway (check-branch). No conflict either way.Not fixed / notes
rollup@4.62.xraised its glibc floor to 2.32; this box has 2.31, sonpx vitestdies withERR_DLOPEN_FAILEDlocally. Unrelated to the migration (it hits any fresh install on this host) and not reproducible on CI's glibc 2.39 — the Vitest numbers above come from running the identical tree innode:20-bookworm(glibc 2.36), 15/15 green.tests/integration/run-newman.shstill defaultsBASE_URLtohttp://localhost:8080. Left alone: it is a separate Postman suite that CI parameterises explicitly.visualanddocs-capturePlaywright projects are opt-in and non-gating (their PNG baselines are host-font/GPU specific); not run here.