Skip to content

feat(vue3): migrate petstore to Vue 3 + @conduction/nextcloud-vue 2.1.0-vue3.13 - #4

Merged
rubenvdlinde merged 5 commits into
mainfrom
feat/vue3-migration
Aug 1, 2026
Merged

feat(vue3): migrate petstore to Vue 3 + @conduction/nextcloud-vue 2.1.0-vue3.13#4
rubenvdlinde merged 5 commits into
mainfrom
feat/vue3-migration

Conversation

@rubenvdlinde

@rubenvdlinde rubenvdlinde commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Wave 1b of the fleet Vue 2 → Vue 3 migration. petstore: 12 .vue files, ~1.75k LOC.

Target: @conduction/nextcloud-vue@2.1.0-vue3.13, pinned exactly (a caret floats a prerelease when the vue3 dist-tag moves), installed with --min-release-age=0, verified with npm ls (node -p "require('<pkg>/package.json')" throws ERR_PACKAGE_PATH_NOT_EXPORTED on exports-map packages and reports a false MISSING).

⚠️ This repo has no development branchorigin carries only main plus feature branches. The PR therefore targets main. See "CI" below: the shared check-branch gate requires beta → main or hotfix/* → main, and there is no beta branch either, so this gate fails on every PR to this repo including the two that were already open.

Results

Suite before (main, same isolated instance) after
Playwright chromium 11 passed / 9 failed 20 passed / 0 failed
Vitest 15 passed / 15 15 passed / 15
npm run lint 0 errors, 2 warnings 0 errors, 0 warnings
npm run build 4 warnings 2 warnings (both asset size limit, pre-existing)
npm run check:specs PASS PASS
npm run stylelint clean clean

All 9 pre-existing e2e failures were reproduced on unmodified main against 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 petstore register afterwards (pet/category/order all total=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}}' prints volume. The shared dev container on :8080 was never touched.

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 peer vue@^2.7.16) · vue-loader 15→17, drop vue-template-compiler · @nextcloud/webpack-vue-config 6→7 plus its undeclared require()s terser-webpack-plugin + path-browserify · @vitejs/plugin-vue2@vitejs/plugin-vue · @vue/test-utils 1→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@9 and @nextcloud/dialogs@7 are exports-map-ONLY — no main, no module. 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@8 still shipped a main.

vue-router dual copy@nextcloud/vue@9 hard-depends on vue-router@^5.1.0, so npm installs vue-router@5.2.0 under node_modules/@nextcloud/vue/node_modules/ alongside the app's 4.6.4. Confirmed present. A vue-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().$mountcreateApp().mount · Vue.mixin/Vue.useapp.mixin/app.use · PiniaVuePlugin dropped (no Vue 3 equivalent; the store is the plugin) · Vue.extend + propsDatacreateApp(Component, props) in the dashboard widget · Vue.observablereactive · vue-router path: '*'path: '/:pathMatch(.*)*' (removed in v4, silent).

Mount host #content#petstore-app. Nextcloud's layout.user.php already 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's mount() renders inside the match.

publicPath — a real Vue 3 regression that 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/. Requesting /apps/petstore/js/<chunk>.js there does not 404 — Nextcloud's router answers 200 with text/html, so the browser refuses it (MIME type ('text/html') is not executable) and webpack throws ChunkLoadError.

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/paths and @mdi/js into 40+. src/publicPath.js now derives the path from Nextcloud's own generateFilePath() and is imported first in all three entry points.

Lint

Adopts the shared conductionVue3Fixes preset 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 -c counts rules set to off too): 21 vue/no-deprecated-* rules at severity 2, ecmaVersion: latest on both languageOptions and parserOptions, vue/no-v-model-argument and vue/no-v-for-template-key at 0, vue/v-on-event-hyphenation = [2,'always',{ignore:['update:modelValue']}].

Positive-controlled: 23 files linted (12 .vue), 0 fatal parse errors; injecting a beforeDestroy + 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)

  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 entire data-layer CRUD suite failed. Fixtures now create a real category object and pass its uuid.
  2. The UI create-form spec typed 'Dogs' into getByRole('textbox', {name:/category/}). Because category is a relation, CnFormDialog renders a relation combobox there — that textbox has never existed.
  3. Two specs asserted #content-vue table is 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.
  4. 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 asserts aria-label="…currently in PetStore", which actually tests "active app" — the anchor never did.
  5. First-run wizard. An opaque modal mask that swallows every pointer event, appearing inside whichever spec runs first and producing 30 s subtree intercepts pointer events timeouts in unrelated specs. globalSetup now calls the wizard's own DELETE /apps/firstrunwizard/wizard opt-out once. Verified: occ user:setting admin firstrunwizard reports show=34.0.0 after a run.
  6. Wrapped relation-picker option. The category option label wraps inside the dropdown and innerText renders that line break as a spacee2e-ms9fv4nl-vr569-dogs read back as e2e-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.ts and three request contexts each computed process.env.NEXTCLOUD_URL || 'http://localhost:8080' independently, and PLAYWRIGHT_BASE_URL was read by nothing. The pet-CRUD suite writes (creates, updates and deletes real OpenRegister objects), so a bare npx playwright test mutated the shared instance while appearing to test this branch.

tests/e2e/_base-url.ts is now the single source of truth, resolving PLAYWRIGHT_BASE_URL → NEXTCLOUD_URL → NC_BASE_URL → BASE_URL (the last is what the shared quality.yml Playwright job exports). There is deliberately no default: a missing target throws.

Other scaffold leftovers fixed

  • 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.
  • src/settings.js mounted inside the loadTranslations callback, which never fires when the l10n JSON 404s (installs that only allowlist JS/CSS through Apache — main.js documents the same hazard). Blank admin panel, no error. Mount is now unconditional and once-guarded.
  • src/exampleWidget.js relied on the bare window globals t/n; now imported.
  • AdminRoot.vue announced 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-branch fails on every PR in this repo, including the two that were already open. The shared gate requires beta → main or hotfix/* → main; this repo has neither a beta nor a development branch. Pre-existing and structural.
  • quality / Security (composer) and quality / PHP Quality (phpmd) fail on main itself (run 30571391476, the last Code Quality run on main). Not regressions.
  • enable-playwright is not set in code-quality.yml, so petstore's e2e suite has never run on a PR. Its 20 tests were run locally for this PR.
  • phpunit and newman are gated on needs.php-quality.result != 'failure' && needs.security.result != 'failure'. Since phpmd and Security (composer) fail, neither has ever executed on a pull request either.
  • quality / Features Check fails on every PR with docs/features.json is out of date — run scripts/extract-features.py to regenerate. This repo has neither scripts/extract-features.py nor docs/features.json. Identical failure on PR perf(events): declare OrderCustomerListener's register/schema interest #3, which predates this branch.
  • frontend-checks was [], so npm run test:unit (the 15-test Vitest suite) and npm run check:specs had 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 ran npm ci (added 1471 packages) and > eslint src and 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 a playwright-seed-command; deliberately left out of this PR rather than half-wired.

PR triage

Not fixed / notes

  • rollup@4.62.x raised its glibc floor to 2.32; this box has 2.31, so npx vitest dies with ERR_DLOPEN_FAILED locally. 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 in node:20-bookworm (glibc 2.36), 15/15 green.
  • tests/integration/run-newman.sh still defaults BASE_URL to http://localhost:8080. Left alone: it is a separate Postman suite that CI parameterises explicitly.
  • The visual and docs-capture Playwright projects are opt-in and non-gating (their PNG baselines are host-font/GPU specific); not run here.

….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.
@rubenvdlinde
rubenvdlinde requested a review from Rem-Dam as a code owner July 31, 2026 21:30
`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.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/petstore @ 47b1804

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.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/petstore @ 512b751

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
rubenvdlinde merged commit a1cd6b9 into main Aug 1, 2026
22 of 27 checks passed
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.
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.

1 participant