Follow-up from the review of #306. Not a regression — a structural gap surfaced by #301.
The gap
src/runtime/plugins/ contains three files — colors.ts, platform.ts, ui-version.ts — and none of them can be reached by the test runner. Both vitest projects list only three directories:
// vitest.config.ts:30-33 — project 'nuxt'
include: [
'components/**/**.spec.ts',
'composables/**.spec.ts',
'utils/**/**.spec.ts'
]
// vitest.config.ts:53-56 — project 'vue'
include: [
'components/**.spec.ts',
'composables/**.spec.ts',
'utils/**/**.spec.ts'
]
There is no plugins/** entry in either, and test/ contains no plugin spec at all.
This matters because the crash in #301 — injectHead(...).hooks.hookOnce is not a function, which broke app startup for every SPA user on Nuxt >= 4.5.1 — lived in src/runtime/plugins/colors.ts. The suite was green throughout: it never had the chance to fail.
Adding plugins/** to include is necessary but not sufficient
The vulnerable branch is gated on runtime state that component tests never reproduce:
// src/runtime/plugins/colors.ts:65
if (import.meta.client && nuxtApp.isHydrating && !nuxtApp.payload.serverRendered) {
A plain mountSuspended never enters it — which is corroborated by the fact that the full suite stayed green while the published package was crashing. So a spec has to either:
- set
nuxtApp.isHydrating = true and nuxtApp.payload.serverRendered = false explicitly before invoking the plugin — the repo already has this pattern, test/components/nuxt/LinkLocale.spec.ts:11-18 hand-patches nuxtApp.$localePath; or
- extract the SPA branch of
colors.ts into a named exported function that can be called directly with a mock head / nuxtApp, without booting Nuxt.
The second option is cleaner and makes the branch testable without fighting the runtime.
Cheap first step, independent of the above
A contract test pinning the shape this code depends on does not need any vitest.config.ts change — createHead from @unhead/vue/client is already imported by test/utils/mount.ts:4, so a spec under test/utils/ runs in both projects as-is.
Worth being careful about how it is written, though: asserting typeof head.hooks.hookOnce !== 'function' pins the absence of a method, and would fail if unhead ever reintroduced it even though nothing would be broken. Better to assert the behaviour we actually rely on — that registering with hook() and self-unhooking fires exactly once and does not throw.
Also uncovered
platform.ts and ui-version.ts sit in the same blind spot, and there is no e2e layer anywhere in .github/workflows/ci.yml (only lint -> typecheck -> test -> build) that would catch a "fails on real client-only boot" class of bug from the side.
Suggested scope
- Add
'plugins/**/**.spec.ts' to the nuxt project include.
- Add a contract spec for the unhead hooks shape under
test/utils/.
- Add a spec for the
colors.ts SPA branch — refactoring the branch into an exported function first, if that turns out simpler than faking the hydration state.
Follow-up from the review of #306. Not a regression — a structural gap surfaced by #301.
The gap
src/runtime/plugins/contains three files —colors.ts,platform.ts,ui-version.ts— and none of them can be reached by the test runner. Both vitest projects list only three directories:There is no
plugins/**entry in either, andtest/contains no plugin spec at all.This matters because the crash in #301 —
injectHead(...).hooks.hookOnce is not a function, which broke app startup for every SPA user on Nuxt>= 4.5.1— lived insrc/runtime/plugins/colors.ts. The suite was green throughout: it never had the chance to fail.Adding
plugins/**toincludeis necessary but not sufficientThe vulnerable branch is gated on runtime state that component tests never reproduce:
A plain
mountSuspendednever enters it — which is corroborated by the fact that the full suite stayed green while the published package was crashing. So a spec has to either:nuxtApp.isHydrating = trueandnuxtApp.payload.serverRendered = falseexplicitly before invoking the plugin — the repo already has this pattern,test/components/nuxt/LinkLocale.spec.ts:11-18hand-patchesnuxtApp.$localePath; orcolors.tsinto a named exported function that can be called directly with a mockhead/nuxtApp, without booting Nuxt.The second option is cleaner and makes the branch testable without fighting the runtime.
Cheap first step, independent of the above
A contract test pinning the shape this code depends on does not need any
vitest.config.tschange —createHeadfrom@unhead/vue/clientis already imported bytest/utils/mount.ts:4, so a spec undertest/utils/runs in both projects as-is.Worth being careful about how it is written, though: asserting
typeof head.hooks.hookOnce !== 'function'pins the absence of a method, and would fail if unhead ever reintroduced it even though nothing would be broken. Better to assert the behaviour we actually rely on — that registering withhook()and self-unhooking fires exactly once and does not throw.Also uncovered
platform.tsandui-version.tssit in the same blind spot, and there is no e2e layer anywhere in.github/workflows/ci.yml(onlylint->typecheck->test->build) that would catch a "fails on real client-only boot" class of bug from the side.Suggested scope
'plugins/**/**.spec.ts'to thenuxtproject include.test/utils/.colors.tsSPA branch — refactoring the branch into an exported function first, if that turns out simpler than faking the hydration state.